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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/benchmarks/documentation.rb +29 -6
- data/benchmarks/endpoints.rb +153 -24
- data/benchmarks/spec/client_examples_spec.rb +109 -37
- data/doc/client-examples.md +218 -45
- data/doc/documentation-oauth.md +157 -0
- data/doc/documentation-ui.md +192 -0
- data/doc/index.md +3 -6
- data/doc/performance.md +39 -12
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/authentication/chain.rb +19 -0
- data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
- data/lib/haveapi/client_example.rb +143 -15
- data/lib/haveapi/client_examples/curl.rb +30 -20
- data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
- data/lib/haveapi/client_examples/go_client.rb +9 -2
- data/lib/haveapi/client_examples/http.rb +9 -4
- data/lib/haveapi/client_examples/js_async_client.rb +7 -0
- data/lib/haveapi/client_examples/js_client.rb +47 -36
- data/lib/haveapi/client_examples/php_client.rb +161 -75
- data/lib/haveapi/client_examples/python_async_client.rb +3 -0
- data/lib/haveapi/client_examples/python_client.rb +61 -47
- data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
- data/lib/haveapi/client_examples/ruby_client.rb +80 -31
- data/lib/haveapi/client_examples/rust_client.rb +20 -9
- data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
- data/lib/haveapi/client_examples/ts_client.rb +3 -0
- data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
- data/lib/haveapi/documentation_context.rb +7 -2
- data/lib/haveapi/documentation_helpers.rb +223 -0
- data/lib/haveapi/documentation_oauth.rb +399 -0
- data/lib/haveapi/documentation_search.rb +225 -0
- data/lib/haveapi/documentation_search_index.rb +79 -0
- data/lib/haveapi/documentation_validators.rb +179 -0
- data/lib/haveapi/public/css/documentation.css +343 -0
- data/lib/haveapi/public/js/documentation.js +992 -0
- data/lib/haveapi/server.rb +144 -86
- data/lib/haveapi/shared_documentation_helpers.rb +54 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/doc_layout.erb +16 -0
- data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
- data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
- data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
- data/lib/haveapi/views/documentation/search_form.erb +10 -0
- data/lib/haveapi/views/documentation/search_index.erb +1 -0
- data/lib/haveapi/views/documentation/search_results.erb +20 -0
- data/lib/haveapi/views/index.erb +2 -4
- data/lib/haveapi/views/main_layout.erb +12 -6
- data/lib/haveapi/views/shared_documentation.erb +12 -0
- data/lib/haveapi/views/shared_sidebar.erb +25 -0
- data/lib/haveapi/views/version_page/auth_body.erb +12 -33
- data/lib/haveapi/views/version_page/client_overview.erb +40 -0
- data/lib/haveapi/views/version_page/example_panels.erb +18 -0
- data/lib/haveapi/views/version_page/resource_body.erb +91 -132
- data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
- data/lib/haveapi/views/version_page/usage.erb +7 -0
- data/lib/haveapi/views/version_page.erb +36 -117
- data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
- data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
- data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
- data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
- data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
- data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
- data/lib/haveapi/views/version_sidebar.erb +59 -48
- data/spec/authentication/basic_spec.rb +10 -10
- data/spec/authentication/oauth2_chain_spec.rb +72 -0
- data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
- data/spec/client_examples/assets/documentation_test.cjs +817 -0
- data/spec/client_examples/context_performance_spec.rb +5 -4
- data/spec/client_examples/context_spec.rb +23 -2
- data/spec/client_examples/detail_spec.rb +175 -0
- data/spec/client_examples/dynamic_acceptance.md +59 -26
- data/spec/client_examples/dynamic_acceptance.rb +164 -44
- data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
- data/spec/client_examples/dynamic_fixture.rb +4 -3
- data/spec/client_examples/dynamic_js_spec.rb +20 -11
- data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
- data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
- data/spec/client_examples/dynamic_python_spec.rb +62 -11
- data/spec/client_examples/go_spec.rb +14 -1
- data/spec/client_examples/presentation_spec.rb +247 -0
- data/spec/client_examples/profile_mount_spec.rb +40 -13
- data/spec/client_examples/rust_client_spec.rb +35 -1
- data/spec/client_examples/shell_spec.rb +268 -0
- data/spec/client_examples/syntax_spec.rb +192 -0
- data/spec/client_examples/ts_generated_client_spec.rb +15 -2
- data/spec/client_examples/typescript_profiles_spec.rb +1 -1
- data/spec/documentation/auth_filtering_spec.rb +9 -4
- data/spec/documentation/canonical_routes_spec.rb +89 -0
- data/spec/documentation/client_examples_spec.rb +39 -5
- data/spec/documentation/client_overview_spec.rb +145 -0
- data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
- data/spec/documentation/example_variants_spec.rb +93 -0
- data/spec/documentation/fixtures/search_index.json +476 -0
- data/spec/documentation/identity_and_guides_spec.rb +130 -0
- data/spec/documentation/identity_context_spec.rb +180 -0
- data/spec/documentation/navigation_spec.rb +296 -0
- data/spec/documentation/oauth_fixture_spec.rb +39 -0
- data/spec/documentation/oauth_spec.rb +912 -0
- data/spec/documentation/oauth_transport_spec.rb +45 -0
- data/spec/documentation/performance_spec.rb +47 -6
- data/spec/documentation/reference_headings_spec.rb +190 -0
- data/spec/documentation/search_index_routes_spec.rb +202 -0
- data/spec/documentation/search_index_spec.rb +85 -0
- data/spec/documentation/search_routes_spec.rb +199 -0
- data/spec/documentation/search_spec.rb +134 -0
- data/spec/documentation/shared_pages_spec.rb +240 -0
- data/spec/documentation/validators_spec.rb +123 -0
- data/spec/documentation_spec.rb +3 -3
- data/spec/packaging_spec.rb +21 -2
- data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
- data/spec/performance/documentation_shapes_spec.rb +97 -0
- data/spec/performance/json_activation_spec.rb +78 -1
- data/spec/performance/release_lineage_spec.rb +4 -3
- data/spec/performance/runner_spec.rb +1 -0
- data/test_support/all_client_performance_fixture.rb +6 -10
- data/test_support/documentation_oauth_fixture.rb +54 -0
- data/test_support/documentation_performance_fixture.rb +19 -1
- data/test_support/dynamic_documentation_api.rb +35 -10
- data/test_support/dynamic_python_login_spec.rb +3 -11
- metadata +52 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 543db42b7a86f64548fb4b06cc9b7aec1fedaad147de9814a43860f4a2333e14
|
|
4
|
+
data.tar.gz: 4cdf30eb588b0ab495391fe5bc93f630e272b5f3a1f73187c16a293030e5de00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 835dda07aaea82261a29912c7d03270811053dcb16113b2d80b388b8a4c24b848438a9cd7c7ac6866867940501e5bfe35abf62f39eb34f6a66a47fc14c4376a2
|
|
7
|
+
data.tar.gz: 359f066b2dae8e27c3a2918606cd7aafe5b671e9098d379ee98fb8761df069a49335cd0966cfad715d4a94267e6c01b300e5e213e20f297fd2bcb22735926096
|
data/.gitignore
CHANGED
data/benchmarks/documentation.rb
CHANGED
|
@@ -152,11 +152,22 @@ module HaveAPI
|
|
|
152
152
|
end
|
|
153
153
|
end
|
|
154
154
|
if @options[:lanes].include?('html')
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
data/benchmarks/endpoints.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
167
|
+
if body.include?('ADMIN_ONLY_REQUEST') != (@user == 'admin')
|
|
133
168
|
raise ContractError, 'Documentation authorization mismatch'
|
|
134
169
|
end
|
|
135
170
|
|
|
136
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
'
|
|
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:
|
|
60
|
-
version_url:
|
|
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{<
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
168
|
-
desc.fetch(:authentication).
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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 *
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
245
|
+
correctness = expect_complete_page(server, 'admin', resources)
|
|
202
246
|
request = Rack::MockRequest.new(server.app)
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
#
|
|
212
|
-
#
|
|
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
|