haveapi 0.31.2 → 0.32.1

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 (123) 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 +188 -0
  8. data/doc/documentation-ui.md +192 -0
  9. data/doc/index.md +3 -6
  10. data/doc/performance.md +48 -14
  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 +411 -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 +1058 -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/event_baseline_spec.rb +97 -2
  115. data/spec/performance/json_activation_spec.rb +78 -1
  116. data/spec/performance/release_lineage_spec.rb +4 -3
  117. data/spec/performance/runner_spec.rb +1 -0
  118. data/test_support/all_client_performance_fixture.rb +6 -10
  119. data/test_support/documentation_oauth_fixture.rb +54 -0
  120. data/test_support/documentation_performance_fixture.rb +19 -1
  121. data/test_support/dynamic_documentation_api.rb +35 -10
  122. data/test_support/dynamic_python_login_spec.rb +3 -11
  123. metadata +52 -3
@@ -0,0 +1,45 @@
1
+ require 'spec_helper'
2
+
3
+ describe HaveAPI::DocumentationOAuth::Transport do
4
+ let(:uri) { URI('https://issuer.example/token') }
5
+ let(:http) { instance_double(Net::HTTP) }
6
+ let(:response) { instance_double(Net::HTTPOK, code: '200') }
7
+
8
+ before do
9
+ allow(Net::HTTP).to receive(:start).and_yield(http)
10
+ allow(http).to receive(:max_retries=)
11
+ allow(http).to receive(:request).and_yield(response)
12
+ allow(response).to receive(:read_body).and_yield('{}')
13
+ end
14
+
15
+ it 'requires verified HTTPS with bounded timeouts and sends credentials only in the body' do
16
+ expect(described_class.new.post(uri, 'client_secret' => 'private')).to eq('{}')
17
+ expect(Net::HTTP).to have_received(:start).with('issuer.example', 443,
18
+ use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_PEER,
19
+ open_timeout: 5, read_timeout: 10, write_timeout: 10)
20
+ expect(http).to have_received(:max_retries=).with(0)
21
+ expect(http).to have_received(:request) do |request|
22
+ expect(request.uri.to_s).to eq('https://issuer.example/token')
23
+ expect(request.body).to eq('client_secret=private')
24
+ end
25
+ end
26
+
27
+ it 'rejects redirects without following them or exposing their body' do
28
+ allow(response).to receive(:code).and_return('302')
29
+ allow(response).to receive(:read_body).and_yield('PRIVATE_PROVIDER_BODY')
30
+ expect { described_class.new.post(uri, {}) }.to raise_error(HaveAPI::DocumentationOAuth::Failure, 'Documentation OAuth request failed')
31
+ expect(http).to have_received(:request).once
32
+ end
33
+
34
+ it 'bounds the body while streaming rather than reading an unbounded response' do
35
+ allow(response).to receive(:read_body).and_yield('x' * 8192).and_yield('x' * 8193)
36
+ expect { described_class.new.post(uri, {}) }.to raise_error(HaveAPI::DocumentationOAuth::Failure)
37
+ end
38
+
39
+ it 'converts TLS and DNS failures into generic authentication failure' do
40
+ [OpenSSL::SSL::SSLError.new('PRIVATE_TLS_DETAIL'), SocketError.new('PRIVATE_DNS_DETAIL')].each do |error|
41
+ allow(Net::HTTP).to receive(:start).and_raise(error)
42
+ expect { described_class.new.post(uri, {}) }.to raise_error(HaveAPI::DocumentationOAuth::Failure, 'Documentation OAuth request failed')
43
+ end
44
+ end
45
+ end
@@ -4,10 +4,24 @@ require_relative '../../test_support/documentation_performance_fixture'
4
4
 
5
5
  describe HaveAPI::Server, '#mount' do
6
6
  let(:server) do
7
- HaveAPI::DocumentationPerformanceFixture.build(resource_count: 3, actions_per_resource: 2)
7
+ HaveAPI::DocumentationPerformanceFixture.build(resource_count: 3, actions_per_resource: 2, documentation_oauth: true)
8
8
  end
9
9
  let(:contexts) { [] }
10
10
  let(:help_inputs) { [] }
11
+ let(:resource_derivations) { [] }
12
+
13
+ around do |example|
14
+ trace = TracePoint.new(:call) do |call|
15
+ next unless call.defined_class == HaveAPI::ClientExample::Context && call.method_id == :normalize
16
+ next unless call.binding.local_variable_get(:field) == :resource
17
+
18
+ value = call.binding.local_variable_get(:value)
19
+ resource_derivations << value if value
20
+ end
21
+ trace.enable { example.run }
22
+ ensure
23
+ trace&.disable
24
+ end
11
25
 
12
26
  before do
13
27
  HaveAPI::ClientExample.clients.each do |client|
@@ -25,19 +39,24 @@ describe HaveAPI::Server, '#mount' do
25
39
 
26
40
  def request_as(user)
27
41
  contexts.clear
42
+ resource_derivations.clear
28
43
  Rack::MockRequest.new(server.app).get(
29
- '/benchmark/api/v1/', 'HTTP_HOST' => 'example.test',
30
- 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:fixture-only")}"
44
+ "#{server.fixture_documentation_oauth.origin}/benchmark/api/v1/?resource=resource0", 'HTTP_HOST' => 'example.test',
45
+ 'HTTP_COOKIE' => server.fixture_documentation_cookie(user)
31
46
  )
32
47
  end
33
48
 
34
49
  def expect_shared_request(response)
35
50
  expect(response.status).to eq(200)
36
51
  expect(response.body).to include('PUBLIC_SAMPLE_0', 'example-resource0-action0-0',
37
- 'init-javascript-callback', 'init-javascript-async',
38
- 'init-typescript-callback', 'init-typescript-async',
39
- 'init-python-sync', 'init-python-async')
52
+ 'data-client="javascript-callback"', 'data-client="javascript-async"',
53
+ 'data-client="typescript-callback"', 'data-client="typescript-async"',
54
+ 'data-client="python-sync"', 'data-client="python-async"')
55
+ expect(response.body).not_to include('id="example-resource1-', 'id="example-resource2-', 'init-examples')
40
56
  expect(contexts.length).to be > 20
57
+ expect(contexts.map(&:resource_path).uniq).to eq([['resource0']])
58
+ expect(contexts.map { |context| context.resource.__id__ }.uniq.length).to eq(1)
59
+ expect(resource_derivations.length).to eq(1)
41
60
  description = contexts.first.description
42
61
  contexts.each { |context| expect(context.description).to equal(description) }
43
62
  expect(description).to be_frozen
@@ -76,4 +95,26 @@ describe HaveAPI::Server, '#mount' do
76
95
  expect(help[:resources]['resource0'][:actions]['action0'][:examples].first[:request]).not_to be_frozen
77
96
  end
78
97
  end
98
+
99
+ it 'does not alias distinct raw resources at the same canonical path' do
100
+ helper = Object.new
101
+ helper.extend(HaveAPI::Server::ServerHelpers)
102
+ parent = HaveAPI::ClientExample::Context.new(version: 1, host: 'example.test',
103
+ base_url: 'http://example.test/', version_url: 'http://example.test/v1/',
104
+ description: {})
105
+ helper.instance_variable_set(:@documentation_context, parent)
106
+ first = { actions: { 'show' => { description: 'first' } }, resources: {} }
107
+ second = { actions: { 'show' => { description: 'second' } }, resources: {} }
108
+ before = Marshal.dump([first, second])
109
+ a = helper.documentation_context(resource_path: [:widget], resource: first)
110
+ b = helper.documentation_context(resource_path: ['widget'], resource: first, detail: :thorough)
111
+ c = helper.documentation_context(resource_path: ['widget'], resource: second)
112
+ expect(a.resource).to equal(b.resource)
113
+ expect(a.resource).not_to equal(c.resource)
114
+ expect(a.resource).not_to equal(first)
115
+ expect(a.description).to equal(c.description)
116
+ expect(a.resource[:actions]['show'][:description]).to eq('first')
117
+ expect(c.resource[:actions]['show'][:description]).to eq('second')
118
+ expect(Marshal.dump([first, second])).to eq(before)
119
+ end
79
120
  end
@@ -0,0 +1,190 @@
1
+ require 'spec_helper'
2
+
3
+ module DocumentationReferenceSpec
4
+ module AuthenticationResources
5
+ class Session < HaveAPI::Resource
6
+ version :all
7
+ auth false
8
+ class Show < HaveAPI::Action
9
+ route ''
10
+ http_method :get
11
+ input(:hash) { string :token }
12
+ output(:hash) { string :token }
13
+ authorize { allow }
14
+ # rubocop:disable RSpec/NoExpectationExample
15
+ example 'provider sample' do
16
+ request(token: 'visible')
17
+ response(token: 'visible')
18
+ end
19
+ # rubocop:enable RSpec/NoExpectationExample
20
+ end
21
+ end
22
+ end
23
+
24
+ class FirstProvider < HaveAPI::Authentication::Base
25
+ auth_method :first
26
+ def register_routes(_sinatra, _prefix)
27
+ @server.routes[@version] ||= { authentication: {} }
28
+ @server.routes[@version][:authentication][name] = { resources: {} }
29
+ end
30
+
31
+ def resource_module
32
+ AuthenticationResources
33
+ end
34
+ end
35
+
36
+ class SecondProvider < FirstProvider
37
+ auth_method :second
38
+ def resource_module
39
+ AuthenticationResources
40
+ end
41
+ end
42
+ end
43
+
44
+ describe HaveAPI::DocumentationHelpers, '#render_version_documentation' do
45
+ api do
46
+ define_resource(:Parent) do
47
+ version 1
48
+ auth false
49
+ define_action(:Index) do
50
+ route ''
51
+ http_method :get
52
+ authorize { allow }
53
+ end
54
+ define_action(:Show) do
55
+ route '{parent_id}'
56
+ http_method :get
57
+ output(:hash) { string :value }
58
+ authorize { allow }
59
+ # rubocop:disable RSpec/NoExpectationExample
60
+ example '<Example>&' do
61
+ path_params 1
62
+ response(value: 'visible')
63
+ end
64
+ # rubocop:enable RSpec/NoExpectationExample
65
+ end
66
+ define_resource(:Child) do
67
+ define_action(:Show) do
68
+ route '{child_id}'
69
+ http_method :get
70
+ authorize { allow }
71
+ end
72
+ define_resource(:Grandchild) do
73
+ define_action(:Show) do
74
+ route '{grandchild_id}'
75
+ http_method :get
76
+ authorize { allow }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ default_version 1
83
+
84
+ def ids(body)
85
+ body.scan(/\sid="([^"]+)"/).flatten
86
+ end
87
+
88
+ def assert_fragments(body)
89
+ body.scan(/<a[^>]*\shref="#([^"]+)"/).flatten.each do |target|
90
+ expect(ids(body).count(target)).to eq(1), target
91
+ end
92
+ end
93
+
94
+ def reference(**options)
95
+ Object.new.extend(described_class).documentation_reference_anchor(**options)
96
+ end
97
+
98
+ it 'encodes typed provider/resource/action/section/example tuples without delimiter collisions' do
99
+ arguments = { resource_path: %w[a-b c], action_name: 'show', section: :input }
100
+ target = reference(**arguments)
101
+ expect(target).to eq(reference(**arguments))
102
+ expect(JSON.parse([target.delete_prefix('reference-')].pack('H*'))).to eq([nil, %w[a-b c], 'show', 'input', nil])
103
+ alternatives = [reference(**arguments, authentication: :first), reference(**arguments, authentication: :second),
104
+ reference(**arguments, resource_path: %w[a b-c]), reference(**arguments, section: :output),
105
+ reference(**arguments, example_index: 0), reference(**arguments, example_index: 1)]
106
+ expect([target, *alternatives].uniq.length).to eq(7)
107
+ expect(reference(resource_path: ['žluťoučký'], section: :examples)).to match(/\Areference-[0-9a-f]+\z/)
108
+ end
109
+
110
+ it 'provides native per-action sections including empty input/output and titled examples' do
111
+ get '/v1/?resource=parent'
112
+ expect(last_response).to be_ok
113
+ body = last_response.body
114
+ expect(body).to include('data-doc-nav-owner="root-parent-index"', 'data-doc-primary href="#root-parent-index"',
115
+ 'data-doc-subnav="root-parent-index"', 'data-doc-subsection', 'No parameters.',
116
+ 'aria-label="Link to Input parameters"', '&lt;Example&gt;&amp;')
117
+ index_menu = body.match(%r{<ul data-doc-subnav="root-parent-index">(.*?)</ul>}m)[1]
118
+ expect(index_menu).to include('Input parameters', 'Output parameters')
119
+ expect(index_menu).not_to include('Examples')
120
+ expect(body).to include('id="example-parent-show-0-examples"', 'id="example-parent-show-0-0"')
121
+ expect(body).not_to include('data-doc-subnav="root-parent-index" hidden')
122
+ assert_fragments(body)
123
+ File.write(File.join(ENV['HAVEAPI_REFERENCE_FIXTURES'], 'resource.html'), body) if ENV['HAVEAPI_REFERENCE_FIXTURES']
124
+ end
125
+
126
+ it 'emits one panel per group declaration and page metadata from actual rendered groups' do
127
+ ['/v1/', '/v1/?resource=parent', '/v1/?page=resources'].each do |path|
128
+ get path
129
+ body = last_response.body
130
+ groups = body.scan(%r{<div class="docs-example" data-example data-example-details="([^"]*)" id="([^"]*)">(.*?)</div>}m)
131
+ union = {}
132
+ groups.each do |json, base, html|
133
+ levels = JSON.parse(CGI.unescapeHTML(json))
134
+ panels = html.scan(/data-client="([^"]+)"[^>]*data-detail="([^"]+)"/)
135
+ expect(panels.sort).to eq(levels.flat_map { |client, details| details.map { |detail| [client, detail] } }.sort)
136
+ expect(panels.uniq).to eq(panels)
137
+ levels.each do |client, details|
138
+ union[client] = (union.fetch(client, []) | details)
139
+ if details == ['simple']
140
+ expect(ids(body)).to include("#{base.delete_suffix('-examples')}-#{client}-thorough")
141
+ end
142
+ end
143
+ end
144
+ clients = JSON.parse(body.match(%r{data-documentation-clients>(.*?)</script>}m)[1])
145
+ clients.each { |client| expect(client['details']).to eq(union.fetch(client['id'], ['simple'])) }
146
+ expect(clients.find { |client| client['id'] == '0' }.fetch('details')).to eq(path == '/v1/?page=resources' ? ['simple'] : %w[simple thorough])
147
+ end
148
+ end
149
+
150
+ it 'lists direct subresources with full dotted identities and reuses existing reference targets' do
151
+ get '/v1/?resource=parent'
152
+ sidebar = last_response.body.match(%r{<nav aria-label="Subresources">(.*?)</nav>}m)[1]
153
+ expect(sidebar).to include('href="/v1/?resource=parent.child"', '>parent.child</a>')
154
+ expect(sidebar).not_to include('grandchild')
155
+ expect(last_response.body).to include('aria-label="Link to Subresources"', 'aria-label="Link to parent"')
156
+ get '/v1/?resource=parent.child'
157
+ expect(last_response.body).to include('>parent.child.grandchild</a>')
158
+ get '/v1/'
159
+ expect(last_response.body).to include('aria-label="Link to Initialization"', 'aria-label="Link to Client libraries"',
160
+ 'aria-label="Link to Using this reference"', 'aria-label="Documentation settings"')
161
+ expect(last_response.body).not_to include('M4 6h16', 'Generated SDKs')
162
+ assert_fragments(last_response.body)
163
+ end
164
+
165
+ it 'scopes duplicate authentication resource/action targets while keeping legacy aliases once' do
166
+ server = HaveAPI::Server.new(get_opt(:api_module))
167
+ server.use_version(1)
168
+ server.auth_chain << DocumentationReferenceSpec::FirstProvider
169
+ server.auth_chain << DocumentationReferenceSpec::SecondProvider
170
+ server.mount('/')
171
+ body = Rack::MockRequest.new(server.app).get('/v1/?page=authentication', 'HTTP_HOST' => 'example.test').body
172
+ %i[first second].each do |provider|
173
+ action = Object.new.extend(described_class).documentation_resource_target(resource_path: ['session'], action_name: :show, authentication: provider)
174
+ expect(body).to include("data-doc-nav-owner=\"auth-#{provider}\"", "data-doc-subnav=\"auth-#{provider}\"", "href=\"##{action}\"")
175
+ expect(ids(body).count(action)).to eq(1)
176
+ %i[input output].each do |section|
177
+ expect(body).to include("href=\"##{reference(resource_path: ['session'], action_name: :show, authentication: provider, section:)}\"")
178
+ end
179
+ end
180
+ expect(ids(body).uniq.length).to eq(ids(body).length)
181
+ %w[example-session-show-0-examples example-session-show-0-0 example-session-show-0-0-thorough].each do |legacy|
182
+ expect(ids(body).count(legacy)).to eq(1)
183
+ end
184
+ expect(ids(body).count('root-session')).to eq(1)
185
+ expect(ids(body).count('root-session-show')).to eq(1)
186
+ expect(body.index('id="root-session-show"')).to be < body.index('id="auth-second"')
187
+ assert_fragments(body)
188
+ File.write(File.join(ENV['HAVEAPI_REFERENCE_FIXTURES'], 'authentication.html'), body) if ENV['HAVEAPI_REFERENCE_FIXTURES']
189
+ end
190
+ end
@@ -0,0 +1,202 @@
1
+ require 'spec_helper'
2
+ require 'rack/mock'
3
+
4
+ module DocumentationSearchIndexSpec
5
+ User = Struct.new(:login)
6
+ class << self
7
+ attr_accessor :private_visible
8
+ end
9
+
10
+ class Provider < HaveAPI::Authentication::Basic::Provider
11
+ protected
12
+
13
+ def find_user(_request, username, password)
14
+ User.new(username) if password == 'pass'
15
+ end
16
+ end
17
+
18
+ module API
19
+ class Widget < HaveAPI::Resource
20
+ version 1
21
+ auth false
22
+ class Show < HaveAPI::Actions::Default::Show
23
+ authorize do |user|
24
+ input blacklist: [:private_ticket] unless user.login == 'admin' || DocumentationSearchIndexSpec.private_visible
25
+ allow
26
+ end
27
+ input(:hash) do
28
+ string :name, label: 'Name </script> & <markup>'
29
+ string :private_ticket
30
+ end
31
+ output(:hash) { string :label }
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ RSpec.describe HaveAPI::DocumentationSearch::Helpers do
38
+ before { DocumentationSearchIndexSpec.private_visible = false }
39
+
40
+ def server
41
+ @server ||= HaveAPI::Server.new(DocumentationSearchIndexSpec::API).tap do |api|
42
+ api.use_version(1)
43
+ api.auth_chain << DocumentationSearchIndexSpec::Provider
44
+ api.locale_header = 'X-Language'
45
+ api.connect_hook(:pre_mount) do |ret, _server, sinatra|
46
+ sinatra.before { response.set_cookie('fixture', 'changed') if request.env['HTTP_X_SET_COOKIE'] }
47
+ ret
48
+ end
49
+ api.mount('/api/')
50
+ end
51
+ end
52
+
53
+ def request(path, **headers)
54
+ environment = { 'HTTP_HOST' => 'example.test' }.merge(headers)
55
+ Rack::MockRequest.new(server.app).get("/api/v1/#{path}", environment)
56
+ end
57
+
58
+ def page_key(**headers)
59
+ response = request('', **headers)
60
+ expect(response.status).to eq(200)
61
+ response.body.match(/data-search-view-key="([0-9a-f]{64})"/)[1]
62
+ end
63
+
64
+ def envelope(response)
65
+ body = response.body.match(%r{\A<script type="application/json" data-documentation-search-index>(.*?)</script>\s*\z}m)[1]
66
+ JSON.parse(body)
67
+ end
68
+
69
+ it 'builds one projection from one description for the version sidebar form' do
70
+ allow(server).to receive(:describe_version).and_call_original
71
+ allow(HaveAPI::DocumentationSearch::Index).to receive(:new).and_call_original
72
+ response = request('?page=resources')
73
+ keys = response.body.scan(/data-search-view-key="([0-9a-f]{64})"/).flatten
74
+ expect(keys.length).to eq(1)
75
+ expect(keys.uniq.length).to eq(1)
76
+ expect(server).to have_received(:describe_version).once
77
+ expect(HaveAPI::DocumentationSearch::Index).to have_received(:new).once
78
+ expect(response['cache-control']).to eq('private, no-store')
79
+ expect(response.body).not_to include('data-documentation-search-index')
80
+ end
81
+
82
+ it 'serves only an escaped inert envelope for matching keys with private cache headers and merged Vary' do
83
+ key = page_key
84
+ response = request("?page=resources&search_index=#{key}")
85
+ expect(response.status).to eq(200)
86
+ expect(response.content_type).to match(%r{\Atext/html;\s*charset=utf-8\z})
87
+ expect(response['cache-control']).to eq('private, max-age=86400, immutable')
88
+ expect(response['etag']).to eq(%("#{key}"))
89
+ expect(response['vary'].split(/,\s*/)).to contain_exactly('X-Language', 'Cookie', 'Authorization')
90
+ expect(envelope(response)).to include('key' => key)
91
+ expect(response.body).to include('\\u003c/script\\u003e', '\\u0026')
92
+ expect(response.body).not_to include('<!DOCTYPE', 'docs-sidebar', '<script src=', 'fixture')
93
+ end
94
+
95
+ it 'keeps bootstrap output, mismatches and Set-Cookie changes no-store without conditional success' do
96
+ key = page_key
97
+ bootstrap = request('?page=resources&search_index=current', 'HTTP_IF_NONE_MATCH' => '*')
98
+ expect(bootstrap.status).to eq(200)
99
+ expect(bootstrap['cache-control']).to eq('private, no-store')
100
+ expect(bootstrap['etag']).to be_nil
101
+ expect(envelope(bootstrap)['key']).to eq(key)
102
+ mismatch = request("?page=resources&search_index=#{'0' * 64}", 'HTTP_IF_NONE_MATCH' => '*')
103
+ expect(mismatch.status).to eq(409)
104
+ expect(mismatch['cache-control']).to eq('private, no-store')
105
+ expect(mismatch.body).not_to include('data-documentation-search-index', 'private_ticket')
106
+ cookie = request("?page=resources&search_index=#{key}", 'HTTP_X_SET_COOKIE' => '1', 'HTTP_IF_NONE_MATCH' => '*')
107
+ expect(cookie.status).to eq(200)
108
+ expect(cookie['set-cookie']).not_to be_nil
109
+ expect(cookie['cache-control']).to eq('private, no-store')
110
+ expect(cookie['etag']).to be_nil
111
+ end
112
+
113
+ it 'rejects malformed selectors and conflicting query/page fields before describing while OPTIONS stays unchanged' do
114
+ allow(server).to receive(:describe_version).and_call_original
115
+ ["search_index=#{'A' * 64}", 'search_index=short', 'search_index[]=current',
116
+ 'search_index=current&search_index=current', 'page=overview&search_index=current',
117
+ 'page=resources&q=&search_index=current', 'page=resources&resource=widget&search_index=current'].each do |query|
118
+ expect(request("?#{query}").status).to eq(400), query
119
+ end
120
+ expect(server).not_to have_received(:describe_version)
121
+ response = Rack::MockRequest.new(server.app).options('/api/v1/?search_index[]=current', 'HTTP_HOST' => 'example.test')
122
+ expect(response.status).to eq(200)
123
+ end
124
+
125
+ it 'keeps neutral pages index-free and validates root default-version representations' do
126
+ allow(HaveAPI::DocumentationSearch::Index).to receive(:new).and_call_original
127
+ client = Rack::MockRequest.new(server.app)
128
+ %w[/api/ /api/doc/protocol].each do |path|
129
+ page = client.get(path, 'HTTP_HOST' => 'example.test')
130
+ expect(page.status).to eq(200)
131
+ expect(page.body).to include('data-search-view-key=""', 'search_index=current')
132
+ end
133
+ expect(HaveAPI::DocumentationSearch::Index).not_to have_received(:new)
134
+ invalid = client.get('/api/?search_index=current', 'HTTP_HOST' => 'example.test')
135
+ expect(invalid.status).to eq(400)
136
+ expect(invalid['cache-control']).to eq('private, no-store')
137
+ current = client.get('/api/?page=resources&search_index=current', 'HTTP_HOST' => 'example.test')
138
+ expect(current.status).to eq(200)
139
+ expect(envelope(current)['index']).to include('version' => '1', 'base_path' => '/api/v1/')
140
+ end
141
+
142
+ it 'binds the projection to resolved locale and canonical Rack/API mounts' do
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'))
146
+ expect(english['key']).not_to eq(czech['key'])
147
+ expect(czech['index']['locale']).to eq('cs')
148
+ mounted = envelope(request('?page=resources&search_index=current', 'SCRIPT_NAME' => '/rack'))
149
+ expect(mounted['index']['base_path']).to eq('/rack/api/v1/')
150
+ expect(mounted['key']).not_to eq(english['key'])
151
+ end
152
+
153
+ context 'with OAuth documentation sessions' do
154
+ let(:documentation_server) do
155
+ HaveAPI::Server.new(DocumentationSearchIndexSpec::API).tap do |api|
156
+ api.use_version(1)
157
+ api.auth_chain << DocumentationSearchIndexSpec::Provider
158
+ end
159
+ end
160
+ let(:documentation_fixture) do
161
+ require_relative '../../test_support/documentation_oauth_fixture'
162
+ DocumentationOAuthFixture.configure(documentation_server, users: {
163
+ 1 => { 'member-token' => DocumentationSearchIndexSpec::User.new('member'),
164
+ 'admin-token' => DocumentationSearchIndexSpec::User.new('admin') }
165
+ }, mount_path: '/api/')
166
+ end
167
+
168
+ before do
169
+ documentation_fixture
170
+ server.mount('/api/')
171
+ end
172
+
173
+ def server
174
+ documentation_server
175
+ end
176
+
177
+ def request(path, user: nil, **headers)
178
+ environment = { 'HTTP_HOST' => URI(documentation_fixture.origin).host }.merge(headers)
179
+ environment['HTTP_COOKIE'] = documentation_fixture.header_for(server, version: 1, token: "#{user}-token") if user
180
+ Rack::MockRequest.new(server.app).get("#{documentation_fixture.origin}/api/v1/#{path}", environment)
181
+ end
182
+
183
+ it 'rechecks cookie authentication and permission changes before any 304 response' do
184
+ member = page_key(user: 'member')
185
+ admin = page_key(user: 'admin')
186
+ anonymous = page_key
187
+ expect([member, admin, anonymous].uniq.length).to eq(3)
188
+ original = request("?page=resources&search_index=#{member}", user: 'member')
189
+ expect(envelope(original)['index']['parameters'].map { |parameter| parameter['name'] }).not_to include('private_ticket')
190
+ expect(request("?page=resources&search_index=#{admin}", user: 'member', 'HTTP_IF_NONE_MATCH' => '*').status).to eq(409)
191
+ documentation_fixture.lookups.clear
192
+ expect(request("?page=resources&search_index=#{member}", user: 'member', 'HTTP_IF_NONE_MATCH' => %("#{member}")).status).to eq(304)
193
+ expect(documentation_fixture.lookups).to eq([[1, 'member-token']])
194
+ DocumentationSearchIndexSpec.private_visible = true
195
+ changed = page_key(user: 'member')
196
+ expect(changed).not_to eq(member)
197
+ stale = request("?page=resources&search_index=#{member}", user: 'member', 'HTTP_IF_NONE_MATCH' => '*')
198
+ expect(stale.status).to eq(409)
199
+ expect(stale.body).not_to include('data-documentation-search-index')
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,85 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe HaveAPI::DocumentationSearch::Index do
4
+ let(:description) do
5
+ { resources: { widget: { label: 'Widget label', resources: {}, actions: {
6
+ show: { method: 'GET', path: '/v1/widgets/{id}', aliases: ['find'], label: 'Show widget',
7
+ input: { parameters: { name: { type: 'String', label: 'Name' } } },
8
+ output: { parameters: { id: { type: 'Integer', label: 'Identifier' } } } }
9
+ } } }, authentication: {} }
10
+ end
11
+ let(:options) { { base_path: '/v1/', version: '1', locale: 'en', visibility: 'anonymous' } }
12
+
13
+ def index(source = description, **changes)
14
+ described_class.new(description: source, **options, **changes)
15
+ end
16
+
17
+ it 'fingerprints deterministic owned payload bytes and preserves parent references without source mutation' do
18
+ before = Marshal.dump(description)
19
+ first = index
20
+ serialized = JSON.parse(JSON.generate(description))
21
+ expect(index(serialized).payload).to eq(first.payload)
22
+ expect(index(serialized).key).to eq(first.key)
23
+ expect(first.key).to eq(Digest::SHA256.hexdigest(JSON.generate(first.payload)))
24
+ expect(first.payload).to be_frozen
25
+ expect(first.payload[:parameters]).to all(be_frozen)
26
+ expect(first.payload[:actions].first[:resource]).to eq(0)
27
+ expect(first.payload[:parameters].map { |parameter| parameter[:action] }).to eq([0, 0])
28
+ expect(Marshal.dump(description)).to eq(before)
29
+ expect(description[:resources][:widget][:label]).not_to be_frozen
30
+ end
31
+
32
+ it 'sorts each metadata collection independently of the caller hash order' do
33
+ source = Marshal.load(Marshal.dump(description))
34
+ source[:resources][:another] = { actions: {}, resources: {} }
35
+ reversed = source.merge(resources: source[:resources].to_a.reverse.to_h)
36
+ expect(index(source).key).to eq(index(reversed).key)
37
+ end
38
+
39
+ it 'changes keys for every supported visible input and version, mount, locale or visibility boundary' do
40
+ original = index.key
41
+ mutations = [
42
+ ->(source) { source[:resources][:extra] = { actions: {} } },
43
+ ->(source) { source[:resources][:widget][:label] = 'Changed label' },
44
+ ->(source) { source[:resources][:widget][:actions][:extra] = { method: 'POST' } },
45
+ ->(source) { source[:resources][:widget][:actions][:show][:aliases] = ['lookup'] },
46
+ ->(source) { source[:resources][:widget][:actions][:show][:path] = '/changed' },
47
+ ->(source) { source[:resources][:widget][:actions][:show][:input][:parameters][:extra] = { type: 'Boolean' } },
48
+ ->(source) { source[:resources][:widget][:actions][:show][:output][:parameters][:id][:label] = 'Changed identifier' }
49
+ ]
50
+ mutations.each do |mutate|
51
+ source = Marshal.load(Marshal.dump(description))
52
+ mutate.call(source)
53
+ expect(index(source).key).not_to eq(original)
54
+ end
55
+ [{ version: '2' }, { base_path: '/mounted/v1/' }, { locale: 'cs' }, { visibility: 'authenticated' }].each do |change|
56
+ expect(index(**change).key).not_to eq(original)
57
+ end
58
+ end
59
+
60
+ it 'projects only whitelisted strings and no prose, defaults, examples, validator values, identities or private fields' do
61
+ source = Marshal.load(Marshal.dump(description))
62
+ source[:private_generator_model] = 'PRIVATE'
63
+ source[:current_user] = { login: 'ACCOUNT' }
64
+ source[:csrf] = 'CSRF'
65
+ source[:resources][:widget][:description] = 'PROSE'
66
+ action = source[:resources][:widget][:actions][:show]
67
+ action[:examples] = [{ request: 'EXAMPLE' }]
68
+ action[:input][:parameters][:name].merge!(default: 'DEFAULT', validators: { include: { values: ['VALIDATOR'] } })
69
+ expect(index(source).key).to eq(index.key)
70
+ expect(JSON.generate(index(source).payload)).not_to include('PRIVATE', 'ACCOUNT', 'CSRF', 'PROSE', 'EXAMPLE', 'DEFAULT', 'VALIDATOR')
71
+ end
72
+
73
+ it 'keeps the full index beyond the result cap and produces the shared Ruby/browser ordering fixture' do
74
+ fixture = JSON.parse(File.read(File.join(__dir__, 'fixtures/search_index.json')))
75
+ expect(fixture.fetch('index').fetch('parameters').length).to be > 20
76
+ fixture.fetch('queries').each do |query|
77
+ result = HaveAPI::DocumentationSearch.new(index: fixture.fetch('index'), query: query.fetch('query')).call
78
+ identities = result[:results].map do |record|
79
+ [record[:kind], record[:authentication], record[:resource_path].join('.'), record[:action_name], record[:direction], record[:name]].join(':')
80
+ end
81
+ expect(identities).to eq(query.fetch('expected'))
82
+ expect(result[:more]).to eq(query.fetch('more'))
83
+ end
84
+ end
85
+ end