haveapi 0.31.1 → 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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61839695eb2b83a98b003c909eeee8de4dd571cdf4b17a519f3ae4519dfcd4cf
4
- data.tar.gz: 98f3dbf9483db43feba421bd33a24a3efe1951228dd3cad41b1219e9e881661a
3
+ metadata.gz: 543db42b7a86f64548fb4b06cc9b7aec1fedaad147de9814a43860f4a2333e14
4
+ data.tar.gz: 4cdf30eb588b0ab495391fe5bc93f630e272b5f3a1f73187c16a293030e5de00
5
5
  SHA512:
6
- metadata.gz: d01b884188cbed69ce4078ba790912ec7de535d30fc9647515288f17447375fb46778c142b0d3546ebd078d9c169a17a352668dec0a021e399c46cefecd0e3b7
7
- data.tar.gz: 9587c018e899a4d0fd3a320f38b1fddf02f2cdde37c845bf16e6e6052ad88f67e0d4020c5267f9a940f92f25bf941ac9225579c327d509b24e768336a4ba868a
6
+ metadata.gz: 835dda07aaea82261a29912c7d03270811053dcb16113b2d80b388b8a4c24b848438a9cd7c7ac6866867940501e5bfe35abf62f39eb34f6a66a47fc14c4376a2
7
+ data.tar.gz: 359f066b2dae8e27c3a2918606cd7aafe5b671e9098d379ee98fb8761df069a49335cd0966cfad715d4a94267e6c01b300e5e213e20f297fd2bcb22735926096
data/.gitignore CHANGED
@@ -2,5 +2,7 @@ doc/protocol.*
2
2
  doc/create-client.md
3
3
  doc/typed-input-validation.md
4
4
  doc/client-examples.md
5
+ doc/documentation-ui.md
6
+ doc/documentation-oauth.md
5
7
  doc/performance.md
6
8
  doc/json-schema.html
@@ -152,11 +152,22 @@ module HaveAPI
152
152
  end
153
153
  end
154
154
  if @options[:lanes].include?('html')
155
- lanes << trial_lane('html', 1, method(:html_output)) { request.get(URL, **headers) }
155
+ if Server.method_defined?(:documentation_oauth)
156
+ html_server, html_setup = measure { DocumentationPerformanceFixture.build(**dimensions, rust: @options[:rust], documentation_oauth: true) }
157
+ html_request = Rack::MockRequest.new(html_server.app)
158
+ html_url = "#{html_server.fixture_documentation_oauth.origin}/benchmark/api/v1/"
159
+ html_headers = headers.except('HTTP_AUTHORIZATION')
160
+ html_headers['HTTP_COOKIE'] = html_server.fixture_documentation_cookie(@options[:user])
161
+ else
162
+ html_request = request
163
+ html_url = URL
164
+ html_headers = headers
165
+ end
166
+ lanes << trial_lane('html', 1, ->(result) { html_output(result, dimensions) }) { html_request.get(html_url, **html_headers) }
156
167
  end
157
168
  lanes.concat(rust_lanes(parent, profile)) if @options[:lanes].include?('rust')
158
169
  { shape:, dimensions:, profile_mode: @options[:rust] ? 'rust' : 'dynamic', renderers:,
159
- description: body_identity(JSON.generate(description)), setup:, lanes: }
170
+ description: body_identity(JSON.generate(description)), setup:, html_setup:, lanes: }
160
171
  end
161
172
 
162
173
  def rust_lanes(parent, profile)
@@ -218,12 +229,24 @@ module HaveAPI
218
229
  **body_identity(JSON.generate(description: child.description, sample: child.sample)) }
219
230
  end
220
231
 
221
- def html_output(response)
232
+ def html_output(response, dimensions)
222
233
  output = { status: response.status, **body_identity(response.body) }
223
- unless response.status == 200 && response.body.include?('PUBLIC_SAMPLE_0') && response.body.include?('init-examples')
224
- raise InvalidOutput.new('HTML request failed or missing sample panels', output)
225
- end
234
+ raise InvalidOutput.new('HTML request failed', output) unless response.status == 200
226
235
 
236
+ body = response.body
237
+ if body.match?(/\bdata-docs(?:\s|=|>)/)
238
+ roots = body.scan(/<[^>]+\bdata-docs(?:\s|=|>)[^>]*>/)
239
+ links = body.scan(/<a\b([^>]*\bdata-resource-directory-link[^>]*)>/).map do |attributes|
240
+ attributes.first[/\bhref="([^"]*)"/, 1]
241
+ end
242
+ initialization = body.match(%r{<details\b[^>]*\bid="init-python-sync"[^>]*>(.*?)</details>}m)
243
+ valid = roots.length == 1 && roots.first.scan(/\bdata-page="([^"]*)"/).flatten == ['overview'] && !links.empty? && links.all? { |href| href == '/benchmark/api/v1/?page=resources' } &&
244
+ initialization && initialization[1].include?('<pre><code') &&
245
+ !body.match?(/id="example-|PUBLIC_SAMPLE_|ADMIN_ONLY_(?:REQUEST|RESPONSE|DEFAULT)|admin_field/)
246
+ raise InvalidOutput.new('HTML overview is incomplete or contains action data', output) unless valid
247
+ elsif !body.include?('PUBLIC_SAMPLE_0') || !body.include?('init-examples')
248
+ raise InvalidOutput.new('HTML request is missing sample panels', output)
249
+ end
227
250
  output
228
251
  end
229
252
 
@@ -1,5 +1,6 @@
1
1
  require_relative 'performance_compare'
2
2
  require 'base64'
3
+ require 'cgi'
3
4
  require 'fileutils'
4
5
  require 'etc'
5
6
  require 'open3'
@@ -11,7 +12,7 @@ require 'io/wait'
11
12
  module HaveAPI
12
13
  module Performance
13
14
  ROOT = File.expand_path('../../..', __dir__)
14
- FIXTURE_PATHS = %w[performance_endpoint_fixture.rb performance_sqlite_fixture.rb documentation_performance_fixture.rb].freeze
15
+ FIXTURE_PATHS = %w[performance_endpoint_fixture.rb performance_sqlite_fixture.rb documentation_performance_fixture.rb documentation_oauth_fixture.rb].freeze
15
16
 
16
17
  class WorkerTimeout < ContractError
17
18
  attr_reader :details
@@ -23,6 +24,8 @@ module HaveAPI
23
24
  end
24
25
 
25
26
  class DocumentationScenario
27
+ attr_reader :documentation_identity
28
+
26
29
  # Only benchmark processes install this observer. It does not change the
27
30
  # authorization result or retain a request beyond its timed call.
28
31
  module AuthorizationObserver
@@ -51,23 +54,36 @@ module HaveAPI
51
54
  size = dimensions.fetch(:size, 1)
52
55
  @user = dimensions.fetch(:user, 'admin')
53
56
  resources, actions = @id == 'docs.html_actions' ? [1, size] : [size, 1]
57
+ # Only HTML on a source owning the optional docs client uses a cookie.
58
+ # The historical f6 source keeps real Basic; API/OPTIONS/direct lanes
59
+ # retain their original provider path and calibrated fixture setup.
60
+ cookie_identity = @id.start_with?('docs.html_') && Server.method_defined?(:documentation_oauth)
54
61
  @server = DocumentationPerformanceFixture.build(resource_count: dimensions.fetch(:resources, resources),
55
62
  actions_per_resource: dimensions.fetch(:actions, actions),
56
63
  samples_per_action: dimensions.fetch(:samples, 1),
57
64
  parameters_per_action: dimensions.fetch(:parameters, 2),
58
- rust: @id.start_with?('rust.') || @id == 'docs.html_rust')
65
+ rust: @id.start_with?('rust.') || @id == 'docs.html_rust',
66
+ documentation_oauth: cookie_identity)
59
67
  @dimensions = dimensions
60
68
  Authorization.prepend(AuthorizationObserver) unless Authorization.ancestors.include?(AuthorizationObserver)
61
69
  @request = Rack::MockRequest.new(@server.app)
62
70
  @headers = { 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT_LANGUAGE' => 'en',
63
71
  'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{@user}:fixture-only")}" }
64
72
  @url = 'http://example.test/benchmark/api/v1/'
73
+ if cookie_identity
74
+ @documentation_fixture = @server.fixture_documentation_oauth
75
+ @headers.delete('HTTP_AUTHORIZATION')
76
+ @headers['HTTP_COOKIE'] = @server.fixture_documentation_cookie(@user)
77
+ @url = "#{@documentation_fixture.origin}/benchmark/api/v1/"
78
+ end
79
+ @documentation_identity = { mode: cookie_identity ? 'documentation_oauth_cookie' : 'basic', role: @user }
65
80
  prepare_rust if @id.start_with?('rust.')
66
81
  end
67
82
 
68
83
  def reset!(iterations:)
69
84
  @operations = Array.new(iterations) { { queries: 0, authorizations: 0, executions: 0 } }
70
85
  @index = 0
86
+ @lookup_start = @documentation_fixture&.lookups&.length
71
87
  end
72
88
 
73
89
  def call
@@ -93,6 +109,12 @@ module HaveAPI
93
109
  end
94
110
  raise ContractError, 'Documentation response failed' unless response.status == 200
95
111
 
112
+ if @documentation_fixture
113
+ actual = @documentation_fixture.lookups.drop(@lookup_start)
114
+ expected = Array.new(@index) { [1, "documentation-#{@user}"] }
115
+ raise ContractError, 'Documentation cookie token validation count differs' unless actual == expected
116
+ end
117
+
96
118
  content_type = @id.start_with?('rust.') || @id == 'docs.options' ? 'application/json' : 'text/html'
97
119
  raise ContractError, 'Documentation content type differs' unless response['content-type']&.start_with?(content_type)
98
120
 
@@ -126,38 +148,134 @@ module HaveAPI
126
148
 
127
149
  return identity
128
150
  end
129
- unless response.body.include?('PUBLIC_SAMPLE_0') && response.body.include?('init-python-sync') && response.body.include?('init-examples')
151
+ if response.body.match?(/\bdata-docs(?:\s|=|>)/)
152
+ validate_overview!(response.body)
153
+ else
154
+ validate_monolith!(response.body)
155
+ end
156
+ { http_status: 200, envelope_status: nil, body_bytes: response.body.bytesize,
157
+ body_sha256: Digest::SHA256.hexdigest(response.body), canonical_sha256: Digest::SHA256.hexdigest(response.body) }.tap do |identity|
158
+ identity[:page] = @page if @page
159
+ end
160
+ end
161
+
162
+ def validate_monolith!(body)
163
+ @page = { shape: 'monolithic' }
164
+ unless body.include?('PUBLIC_SAMPLE_0') && body.include?('init-python-sync') && body.include?('init-examples')
130
165
  raise ContractError, 'Documentation panels missing'
131
166
  end
132
- if response.body.include?('ADMIN_ONLY_REQUEST') != (@user == 'admin')
167
+ if body.include?('ADMIN_ONLY_REQUEST') != (@user == 'admin')
133
168
  raise ContractError, 'Documentation authorization mismatch'
134
169
  end
135
170
 
136
- if @id == 'docs.html_rust'
137
- actions = @dimensions.fetch(:resources) * @dimensions.fetch(:actions)
138
- unless response.body.include?('init-rust-async') && response.body.include?('init-rust-blocking') &&
139
- response.body.include?("example-resource#{@dimensions.fetch(:resources) - 1}-action3-1")
140
- raise ContractError, 'Full Rust documentation page missing panels/actions/examples'
141
- end
171
+ return unless @id == 'docs.html_rust'
142
172
 
143
- markers = %w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only]
144
- unless markers.all? { |marker| response.body.include?(marker) == (@user == 'admin') }
145
- raise ContractError, 'Full Rust documentation authorization mismatch'
146
- end
173
+ actions = @dimensions.fetch(:resources) * @dimensions.fetch(:actions)
174
+ unless body.include?('init-rust-async') && body.include?('init-rust-blocking') &&
175
+ body.include?("example-resource#{@dimensions.fetch(:resources) - 1}-action3-1")
176
+ raise ContractError, 'Full Rust documentation page missing panels/actions/examples'
177
+ end
178
+
179
+ markers = %w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only]
180
+ unless markers.all? { |marker| body.include?(marker) == (@user == 'admin') }
181
+ raise ContractError, 'Full Rust documentation authorization mismatch'
182
+ end
183
+
184
+ examples = body.scan(/id="example-resource\d+-(?:action\d+|admin_only)-\d+-examples"/).uniq.length
185
+ expected_examples = (actions * (@dimensions.fetch(:samples) + (@user == 'admin' ? 1 : 0))) + (@user == 'admin' ? 1 : 0)
186
+ unless examples == expected_examples && body.include?('documentation_performance_client')
187
+ raise ContractError, 'Full Rust documentation example inventory mismatch'
188
+ end
189
+
190
+ @page = { shape: 'monolithic', actual_examples: examples, resources: @dimensions.fetch(:resources), actions: actions + (@user == 'admin' ? 1 : 0),
191
+ public_examples: actions * @dimensions.fetch(:samples), user: @user }
192
+ end
193
+
194
+ def validate_overview!(body)
195
+ roots = body.scan(/<[^>]+\bdata-docs(?:\s|=|>)[^>]*>/)
196
+ unless body.scan(/\sdata-docs(?=[\s=>])/).length == 1 && roots.length == 1 && roots.first.scan(/\bdata-page="([^"]*)"/).flatten == ['overview']
197
+ raise ContractError, 'Documentation overview root differs'
198
+ end
199
+
200
+ forbidden = /id="(?:example-|root-)|PUBLIC_SAMPLE_|ADMIN_ONLY_|admin_field|admin_result/
201
+ raise ContractError, 'Documentation overview contains action data' if body.match?(forbidden)
202
+
203
+ resources, actions = documentation_dimensions
204
+ expected = resources.times.map { |index| "resource#{index}" }
205
+ links = body.scan(/<a\b([^>]*\bdata-resource-directory-link[^>]*)>/).map do |attributes|
206
+ href = attributes.first[/\bhref="([^"]*)"/, 1]
207
+ href && CGI.unescapeHTML(href)
208
+ end
209
+ unless !links.empty? && links.all? { |href| href == '/benchmark/api/v1/?page=resources' } &&
210
+ !body.include?('data-resource-link=')
211
+ raise ContractError, 'Documentation overview directory navigation differs'
212
+ end
213
+
214
+ %w[0 2 3 5 6 javascript-callback javascript-async typescript-callback typescript-async python-sync python-async].tap do |clients|
215
+ clients.push('rust-async', 'rust-blocking') if @id == 'docs.html_rust'
216
+ group = body.match(/data-example-details="([^"]*)" id="init-examples"/)
217
+ declared = group && JSON.parse(CGI.unescapeHTML(group[1]))
218
+ clients.each do |client|
219
+ levels = declared ? declared[client] : %w[simple thorough]
220
+ unless [['simple'], %w[simple thorough]].include?(levels)
221
+ raise ContractError, 'Documentation overview initialization detail declaration invalid'
222
+ end
147
223
 
148
- examples = response.body.scan(/id="example-resource\d+-(?:action\d+|admin_only)-\d+-examples"/).uniq.length
149
- expected_examples = (actions * (@dimensions.fetch(:samples) + (@user == 'admin' ? 1 : 0))) + (@user == 'admin' ? 1 : 0)
150
- unless examples == expected_examples && response.body.include?('documentation_performance_client')
151
- raise ContractError, 'Full Rust documentation example inventory mismatch'
224
+ levels.each do |detail|
225
+ suffix = detail == 'thorough' ? '-thorough' : ''
226
+ pattern = %r{<details\b(?=[^>]*\bdata-example-panel\b)(?=[^>]*\bdata-client="#{client}")(?=[^>]*\bdata-detail="#{detail}")(?=[^>]*\bid="init-#{client}#{suffix}")[^>]*>(.*?)</details>}m
227
+ panel = body.match(pattern)
228
+ code = panel && panel[1].match(%r{<pre><code[^>]*>(.*?)</code></pre>}m)
229
+ unless code && !code[1].strip.empty?
230
+ raise ContractError, 'Documentation overview initialization panel missing'
231
+ end
232
+ end
152
233
  end
234
+ end
235
+ if @id == 'docs.html_rust' && !body.include?('documentation_performance_client')
236
+ raise ContractError, 'Documentation overview Rust package missing'
237
+ end
238
+
239
+ maps = body.scan(%r{<script\b[^>]*\bdata-legacy-anchors\b[^>]*>(.*?)</script>}m)
240
+ raise ContractError, 'Documentation overview legacy map missing' unless maps.length == 1
241
+
242
+ anchors = JSON.parse(maps.first.first)
243
+ raise ContractError, 'Documentation overview legacy map invalid' unless anchors.is_a?(Hash)
153
244
 
154
- @page = { actual_examples: examples, resources: @dimensions.fetch(:resources), actions: actions + (@user == 'admin' ? 1 : 0),
155
- public_examples: actions * @dimensions.fetch(:samples), user: @user }
245
+ expected_anchors = {}
246
+ expected.each do |key|
247
+ destination = "/benchmark/api/v1/?resource=#{key}"
248
+ expected_anchors["root-#{key}"] = destination
249
+ actions.times do |index|
250
+ expected_anchors["root-#{key}-action#{index}"] = destination
251
+ expected_anchors["example-#{key}-action#{index}-"] = destination
252
+ end
156
253
  end
157
- { http_status: 200, envelope_status: nil, body_bytes: response.body.bytesize,
158
- body_sha256: Digest::SHA256.hexdigest(response.body), canonical_sha256: Digest::SHA256.hexdigest(response.body) }.tap do |identity|
159
- identity[:page] = @page if @page
254
+ if @user == 'admin'
255
+ expected_anchors['root-resource0-admin_only'] = '/benchmark/api/v1/?resource=resource0'
256
+ expected_anchors['example-resource0-admin_only-'] = '/benchmark/api/v1/?resource=resource0'
257
+ end
258
+ allowed_destinations = expected.map { |key| "/benchmark/api/v1/?resource=#{key}" } +
259
+ ['/benchmark/api/v1/', '/benchmark/api/v1/?page=resources', '/benchmark/api/v1/?page=authentication']
260
+ unless anchors.values.all? { |destination| allowed_destinations.include?(destination) }
261
+ raise ContractError, 'Documentation overview legacy targets differ'
262
+ end
263
+
264
+ actual = anchors.select { |key, _| key.start_with?('root-', 'example-') }
265
+ unless actual == expected_anchors
266
+ raise ContractError, 'Documentation overview authorization or legacy targets differ'
160
267
  end
268
+
269
+ @page = { shape: 'overview', actual_examples: 0, resources:, actions: (resources * actions) + (@user == 'admin' ? 1 : 0),
270
+ public_examples: 0, user: @user }
271
+ rescue JSON::ParserError
272
+ raise ContractError, 'Documentation overview legacy map invalid'
273
+ end
274
+
275
+ def documentation_dimensions
276
+ size = @dimensions.fetch(:size, 1)
277
+ resources, actions = @id == 'docs.html_actions' ? [1, size] : [size, 1]
278
+ [@dimensions.fetch(:resources, resources), @dimensions.fetch(:actions, actions)]
161
279
  end
162
280
 
163
281
  def counters
@@ -199,6 +317,7 @@ module HaveAPI
199
317
  'rjit' => (defined?(RubyVM::RJIT) && RubyVM::RJIT.enabled?) || false)
200
318
  raise ContractError, 'JIT must be disabled before worker startup' unless policy == RUNTIME_POLICY
201
319
 
320
+ activate_benchmark_console
202
321
  source = File.realpath(@request.fetch('source_root'))
203
322
  %w[servers/ruby clients/ruby clients/rust].reverse_each { |component| $:.unshift(File.join(source, component, 'lib')) }
204
323
  require 'active_record'
@@ -236,7 +355,9 @@ module HaveAPI
236
355
  scenario.close
237
356
  stages[:close_seconds] = monotonic - close_started
238
357
  end
239
- result.merge(spec).merge(stages:)
358
+ result.merge(spec).merge(stages:).tap do |record|
359
+ record[:documentation_identity] = scenario.documentation_identity if scenario.respond_to?(:documentation_identity)
360
+ end
240
361
  end
241
362
  inputs = if @request['benchmark'] == true
242
363
  self.class.inputs(source, @request.fetch('profile'), release: @request['release_versions'] == true, benchmark: true)
@@ -252,6 +373,14 @@ module HaveAPI
252
373
  stages: { require_load_seconds: load_seconds, worker_seconds: monotonic - started } }
253
374
  end
254
375
 
376
+ def activate_benchmark_console
377
+ return unless @request['benchmark'] == true
378
+
379
+ gem 'io-console', '= 0.9.4'
380
+ require 'io/console'
381
+ end
382
+ private :activate_benchmark_console
383
+
255
384
  def monotonic
256
385
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
257
386
  end
@@ -26,6 +26,7 @@ RSpec.describe HaveAPI::ClientExample, '.render' do
26
26
  'typescript-generated' => :typescript
27
27
  }.freeze
28
28
  lanes = %i[init basic token oauth2 public protected failure].freeze
29
+ details = %i[simple thorough].freeze
29
30
  let(:matrix) { profiles }
30
31
 
31
32
  before(:all) do
@@ -39,12 +40,12 @@ RSpec.describe HaveAPI::ClientExample, '.render' do
39
40
  end
40
41
 
41
42
  after(:all) do
42
- RSpec.configuration.reporter.message("ALL_CLIENT_PERFORMANCE #{JSON.generate(registry: profiles.keys.sort, lanes:, measurements: @measurements)}")
43
+ RSpec.configuration.reporter.message("ALL_CLIENT_PERFORMANCE #{JSON.generate(registry: profiles.keys.sort, lanes:, details:, measurements: @measurements)}")
43
44
  end
44
45
 
45
- def headers(user)
46
+ def headers(server, user)
46
47
  { 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT_LANGUAGE' => 'en',
47
- 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:fixture-only")}" }
48
+ 'HTTP_COOKIE' => server.fixture_documentation_cookie(user) }
48
49
  end
49
50
 
50
51
  def description(server, user)
@@ -52,13 +53,13 @@ RSpec.describe HaveAPI::ClientExample, '.render' do
52
53
  user: HaveAPI::DocumentationPerformanceFixture::User.new(user), params: {}))
53
54
  end
54
55
 
55
- def context(description, profile, action_name: 'protected', sample_index: 0, resource_name: 'resource0')
56
+ def context(description, profile, action_name: 'protected', sample_index: 0, resource_name: 'resource0', detail: :simple, origin: 'http://example.test')
56
57
  resource = description.fetch(:resources).fetch(resource_name)
57
58
  action = resource.fetch(:actions).fetch(action_name)
58
59
  HaveAPI::ClientExample::Context.new(version: 1, host: 'example.test',
59
- base_url: 'http://example.test/benchmark/api/',
60
- version_url: 'http://example.test/benchmark/api/v1/',
61
- description:, profile:, resource_path: [resource_name.to_s],
60
+ base_url: "#{origin}/benchmark/api/",
61
+ version_url: "#{origin}/benchmark/api/v1/",
62
+ description:, profile:, detail:, resource_path: [resource_name.to_s],
62
63
  resource:, action_name:, action:, sample: action.fetch(:examples).fetch(sample_index))
63
64
  end
64
65
 
@@ -113,12 +114,12 @@ RSpec.describe HaveAPI::ClientExample, '.render' do
113
114
  end
114
115
 
115
116
  profiles.each do |id, language|
116
- lanes.each do |lane|
117
- it "bounds #{id}/#{lane} work and allocations with unrelated descendants" do
117
+ lanes.product(details).each do |lane, detail|
118
+ it "bounds #{id}/#{lane}/#{detail} work and allocations with unrelated descendants" do
118
119
  client = described_class.clients.find { |item| item.id == id }
119
120
  expect(client).not_to be_nil
120
121
  profile = language && @profiles.fetch(language)
121
- options = { action_name: lane == :public ? 'public' : 'protected', sample_index: lane == :failure ? 1 : 0 }
122
+ options = { action_name: lane == :public ? 'public' : 'protected', sample_index: lane == :failure ? 1 : 0, detail: }
122
123
  small = context(@description, profile, **options)
123
124
  large = context(@large_description, profile, **options)
124
125
  expect(client.available?(small)).to be(true)
@@ -131,7 +132,7 @@ RSpec.describe HaveAPI::ClientExample, '.render' do
131
132
  large_calls = calls { render(client, lane, large) }
132
133
  small_allocations = allocations { render(client, lane, small) }
133
134
  large_allocations = allocations { render(client, lane, large) }
134
- @measurements << { id:, lane:, allocations: [small_allocations, large_allocations], calls: [small_calls, large_calls] }
135
+ @measurements << { id:, lane:, detail:, allocations: [small_allocations, large_allocations], calls: [small_calls, large_calls] }
135
136
  # Selected action/provider/sample is unchanged. Allow fixed VM/lookup
136
137
  # slack, while recursive walks of the 128 unrelated subtrees must fail.
137
138
  expect(large_calls).to be <= small_calls + 32
@@ -142,74 +143,145 @@ RSpec.describe HaveAPI::ClientExample, '.render' do
142
143
  end
143
144
 
144
145
  def expect_panel(body, anchor, result)
145
- panel = body.match(%r{<div id="#{Regexp.escape(anchor)}">\s*(.*?)</div>}m)
146
+ panel = body.match(%r{<details\b[^>]*\bdata-example-panel\b[^>]*\bid="#{Regexp.escape(anchor)}"[^>]*>(.*?)</details>}m)
146
147
  expect(panel).not_to be_nil, "Missing #{anchor}"
147
148
  text = result.fetch(result[:status] == :code ? :code : :reason)
148
149
  expect(panel[1]).to include(CGI.escapeHTML(text))
149
150
  expect(panel[1]).to include(result[:status] == :code ? '<pre><code' : 'client-limitation')
150
151
  end
151
152
 
152
- def expect_complete_page(server, user, resources)
153
- desc = description(server, user)
154
- response = Rack::MockRequest.new(server.app).get('/benchmark/api/v1/', **headers(user))
153
+ def expect_meaningful_group(body, base, client, kind, ctx, auth_method: nil, auth_description: nil)
154
+ simple = client.render(kind, context: ctx, auth_method:, auth_description:)
155
+ expect(simple).not_to be_nil
156
+ levels = client.detail_levels_for(kind, context: ctx, result: simple, auth_method:, auth_description:)
157
+ group = body.match(%r{<div class="docs-example" data-example data-example-details="([^"]*)" id="#{Regexp.escape(base)}-examples">(.*?)</div>}m)
158
+ expect(group).not_to be_nil, base
159
+ declared = JSON.parse(CGI.unescapeHTML(group[1]))
160
+ expect(declared.fetch(client.id)).to eq(levels.map(&:to_s))
161
+ actual = group[2].scan(/data-example-panel[^>]*data-client="#{Regexp.escape(client.id)}"[^>]*data-detail="([^"]+)"/).flatten
162
+ expect(actual).to eq(levels.map(&:to_s))
163
+ levels.each do |detail|
164
+ result = detail == :simple ? simple : client.render(kind, context: ctx.with(detail:), auth_method:, auth_description:)
165
+ expect_result(result, client.id, kind == :auth ? auth_method : kind)
166
+ expect_panel(body, "#{base}-#{client.id}#{'-thorough' if detail == :thorough}", result)
167
+ end
168
+ return unless levels == [:simple]
169
+
170
+ expect(body).to include("id=\"#{base}-#{client.id}-thorough\"")
171
+ end
172
+
173
+ def request_page(server, user, query = '')
174
+ response = Rack::MockRequest.new(server.app).get("#{server.fixture_documentation_oauth.origin}/benchmark/api/v1/#{query}", **headers(server, user))
155
175
  expect(response.status).to eq(200)
156
176
  expect(response['content-type']).to start_with('text/html')
157
- expect(response.body).to include('PUBLIC_SAMPLE')
177
+ response.body
178
+ end
179
+
180
+ def expect_complete_page(server, user, resources, actions: 2)
181
+ desc = description(server, user)
182
+ overview = request_page(server, user)
183
+ authentication = request_page(server, user, '?page=authentication')
184
+ directory = request_page(server, user, '?page=resources')
185
+ links = directory.scan(/data-resource-link="([^"]*)"/).flatten
186
+ expect(links.sort).to eq(resources.times.map { |index| "resource#{index}" }.sort)
187
+ expect(directory).not_to include('PUBLIC_SAMPLE', 'ADMIN_ONLY_REQUEST', 'ADMIN_ONLY_RESPONSE', 'id="example-')
188
+ expect(overview).to include('data-resource-directory-link')
189
+ expect(overview).not_to include('data-resource-link=')
190
+ pages = resources.times.to_h do |index|
191
+ name = "resource#{index}"
192
+ [name, request_page(server, user, "?resource=#{name}")]
193
+ end
194
+ expect(overview).not_to include('id="example-')
195
+ expect(overview).not_to include('PUBLIC_SAMPLE', 'ADMIN_ONLY_REQUEST', 'ADMIN_ONLY_RESPONSE')
196
+ pages.each do |name, body|
197
+ expect(body).to include('PUBLIC_SAMPLE')
198
+ expect(body).not_to include('id="init-examples"')
199
+ pages.each_key do |other|
200
+ next if other == name
201
+
202
+ expect(body).not_to include("id=\"example-#{other}-")
203
+ end
204
+ end
205
+ combined = [overview, authentication, directory, *pages.values].join
158
206
  %w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only].each do |marker|
159
- expect(response.body.include?(marker)).to eq(user == 'admin'), marker
207
+ expect(combined.include?(marker)).to eq(user == 'admin'), marker
160
208
  end
161
209
  example_count = 0
162
210
  HaveAPI::ClientExample.clients.each do |client|
163
211
  language = matrix.fetch(client.id)
164
212
  profile = language && server.documentation_client_profiles(1).fetch(language)
165
- ctx = context(desc, profile)
213
+ ctx = context(desc, profile, origin: server.fixture_documentation_oauth.origin)
166
214
  expect(client.available?(ctx)).to be(true)
167
- expect_panel(response.body, "init-#{client.id}", render(client, :init, ctx))
168
- desc.fetch(:authentication).each_key do |method|
169
- result = render(client, method, ctx)
170
- expect_result(result, client.id, method)
171
- expect_panel(response.body, "auth-#{method}-#{client.id}", result)
215
+ expect_meaningful_group(overview, 'init', client, :init, ctx)
216
+ desc.fetch(:authentication).each do |method, auth|
217
+ expect_meaningful_group(authentication, "auth-#{method}", client, :auth, ctx, auth_method: method, auth_description: auth)
172
218
  end
173
- resources.times do |index|
174
- resource_name = "resource#{index}"
219
+ pages.each do |resource_name, body|
175
220
  desc.fetch(:resources).fetch(resource_name).fetch(:actions).each do |action_name, action|
176
221
  action.fetch(:examples).each_index do |sample_index|
177
- selected = context(desc, profile, action_name: action_name.to_s, sample_index:, resource_name:)
178
- result = client.render(:example, context: selected)
179
- expect_result(result, client.id, :example)
180
- expect_panel(response.body, "example-#{resource_name}-#{action_name}-#{sample_index}-#{client.id}", result)
222
+ selected = context(desc, profile, action_name: action_name.to_s, sample_index:, resource_name:,
223
+ origin: server.fixture_documentation_oauth.origin)
224
+ expect_meaningful_group(body, "example-#{resource_name}-#{action_name}-#{sample_index}", client, :example, selected)
181
225
  example_count += 1
182
226
  end
183
227
  end
184
228
  end
185
229
  end
186
- expected_samples = (resources * 2 * (user == 'admin' ? 3 : 2)) + (user == 'admin' ? 1 : 0)
230
+ expected_samples = (resources * actions * (user == 'admin' ? 3 : 2)) + (user == 'admin' ? 1 : 0)
187
231
  expect(example_count).to eq(expected_samples * matrix.length)
188
- response
232
+ combined
189
233
  end
190
234
 
191
235
  it 'renders every real panel and isolates admin/member/admin pages through the same adapters' do
192
236
  first = expect_complete_page(@server, 'admin', 2)
193
237
  expect_complete_page(@server, 'member', 2)
194
238
  last = expect_complete_page(@server, 'admin', 2)
195
- expect(last.body).to eq(first.body)
239
+ expect(last).to eq(first)
196
240
  end
197
241
 
198
242
  it 'bounds complete all-profile page allocations by linear output growth' do
199
243
  measurements = [2, 4, 8].map do |resources|
200
244
  server = HaveAPI::AllClientPerformanceFixture.build(resources:)
201
- response = expect_complete_page(server, 'admin', resources)
245
+ correctness = expect_complete_page(server, 'admin', resources)
202
246
  request = Rack::MockRequest.new(server.app)
203
- allocated = allocations(count: 1) { request.get('/benchmark/api/v1/', **headers('admin')) }
204
- { resources:, bytes: response.body.bytesize, allocations: allocated }
247
+ # Measure the four page kinds with one selected resource. Correctness
248
+ # above still checks every resource and both details; adding unrelated
249
+ # resources must grow navigation, not action rendering on the selected page.
250
+ paths = ['', '?page=authentication', '?page=resources', '?resource=resource0']
251
+ bytes = paths.sum { |query| request.get("#{server.fixture_documentation_oauth.origin}/benchmark/api/v1/#{query}", **headers(server, 'admin')).body.bytesize }
252
+ allocated = allocations(count: 1) do
253
+ paths.each { |query| request.get("#{server.fixture_documentation_oauth.origin}/benchmark/api/v1/#{query}", **headers(server, 'admin')) }
254
+ end
255
+ { resources:, bytes:, correctness_bytes: correctness.bytesize, allocations: allocated }
205
256
  end
206
257
  @measurements << { pages: measurements }
207
258
  small, medium, large = measurements
208
259
  expect(large[:bytes]).to be > medium[:bytes]
209
260
  expect(medium[:bytes]).to be > small[:bytes]
210
261
  expect(medium[:allocations]).to be > small[:allocations]
211
- # Doubling the added resources doubles actual panels/output. A 2.5x
212
- # incremental allowance covers bookkeeping; quadratic copying approaches 4x.
262
+ # The same page kinds retain their panel count; added resources grow only
263
+ # authorized descriptions/navigation. Keep the existing 2.5x growth budget.
264
+ expect(large[:allocations] - medium[:allocations]).to be <= 2.5 * (medium[:allocations] - small[:allocations])
265
+ end
266
+
267
+ it 'bounds one resource page allocations as its own action panels grow' do
268
+ measurements = [2, 4, 8].map do |actions|
269
+ server = HaveAPI::AllClientPerformanceFixture.build(resources: 1, actions:)
270
+ expect_complete_page(server, 'admin', 1, actions:)
271
+ request = Rack::MockRequest.new(server.app)
272
+ response = request.get("#{server.fixture_documentation_oauth.origin}/benchmark/api/v1/?resource=resource0", **headers(server, 'admin'))
273
+ allocated = allocations(count: 1) do
274
+ request.get("#{server.fixture_documentation_oauth.origin}/benchmark/api/v1/?resource=resource0", **headers(server, 'admin'))
275
+ end
276
+ { actions:, bytes: response.body.bytesize, allocations: allocated }
277
+ end
278
+ @measurements << { action_pages: measurements }
279
+ small, medium, large = measurements
280
+ expect(large[:bytes]).to be > medium[:bytes]
281
+ expect(medium[:bytes]).to be > small[:bytes]
282
+ expect(medium[:allocations]).to be > small[:allocations]
283
+ # Both selected panels and description grow here. Repeated whole-description
284
+ # copying per panel approaches quadratic growth and violates the same budget.
213
285
  expect(large[:allocations] - medium[:allocations]).to be <= 2.5 * (medium[:allocations] - small[:allocations])
214
286
  end
215
287
  end