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
@@ -0,0 +1,123 @@
1
+ require 'spec_helper'
2
+ require 'haveapi/documentation_validators'
3
+
4
+ describe HaveAPI::DocumentationValidators do
5
+ def render(name, options, type: nil)
6
+ described_class.render({ name => options }, type:)
7
+ end
8
+
9
+ it 'keeps every rule in a semantic subtable with scoped row headers' do
10
+ html = described_class.render({ present: { empty: false }, include: { values: [false, 0, ''] } })
11
+ expect(html).to include('<div class="docs-validators"><table class="docs-validator-table"><caption>Validation rules</caption>',
12
+ '<th scope="col">Rule</th><th scope="col">Details</th>',
13
+ '<tr class="docs-validator"><th scope="row">Present</th><td>',
14
+ '<tr class="docs-validator"><th scope="row">Include</th><td>')
15
+ expect(html.scan('<table').length).to eq(1)
16
+ expect(html.scan('<tr class="docs-validator">').length).to eq(2)
17
+ expect(html).not_to include('<h5>')
18
+ expect(described_class.render({})).to eq('')
19
+ expect(described_class.render(nil)).to eq('')
20
+ end
21
+
22
+ it 'explains required presence and its distinct empty-value modes' do
23
+ strict = render(:present, { empty: false, message: 'Required value' })
24
+ expect(strict).to include('Must be supplied and cannot be null.', 'Must not be empty;', 'non-whitespace', 'Required value')
25
+ expect(render(:present, { empty: true })).to include('Empty values are allowed.')
26
+ end
27
+
28
+ it 'renders inclusion choices as typed machine values with separate localized labels' do
29
+ values = { 'active' => 'Aktivní', 0 => 'Zero', false => 'Disabled', nil => 'Unset' }
30
+ html = render(:include, { values:, message: 'Select one' })
31
+ expect(html).to include('Allowed values:', '<code>active</code>: Aktivní', '<code>0</code>: Zero',
32
+ '<code>false</code>: Disabled', '<code>null</code>: Unset', 'Select one')
33
+ expect(html).not_to include('=&gt;', '{&quot;active&quot;')
34
+ expect(html.index('Aktivní')).to be < html.index('Zero')
35
+ end
36
+
37
+ it 'lists array alternatives, reserved exclusions and empty sets honestly' do
38
+ expect(render(:include, { values: ['', false, 0] })).to include('<code></code>', '<code>false</code>', '<code>0</code>')
39
+ expect(render(:exclude, { values: ['root'] })).to include('Disallowed values:', '<code>root</code>')
40
+ expect(render(:exclude, { values: ['root'] })).not_to include('Allowed values:')
41
+ expect(render(:include, { values: [] })).to include('The set is empty.')
42
+ expect(render(:include, { values: {} })).to include('The set is empty.')
43
+ end
44
+
45
+ %i[include exclude].each do |rule|
46
+ it "keeps #{rule} string contents including quotes and Unicode without adding presentation quotes" do
47
+ html = render(rule, { values: ['<tag>&"Žluťoučký"', "'quoted'", '', 'false', '0', 'null'] })
48
+ expect(html).to include('<ul class="docs-validator-values">', '<li><code>&lt;tag&gt;&amp;&quot;Žluťoučký&quot;</code></li>',
49
+ '<li><code>&#39;quoted&#39;</code></li>', '<li><code></code></li>',
50
+ '<li><code>false</code></li>', '<li><code>0</code></li>', '<li><code>null</code></li>')
51
+ expect(html).not_to include('<tag>', '&amp;lt;', 'The set is empty.', '<code>&quot;')
52
+ end
53
+
54
+ it "retains #{rule} nonstring types and nested JSON quoting" do
55
+ html = render(rule, { values: [true, false, 0, 1.5, nil, ['a"b', ''], { 'nested' => ['<tag>', nil] }] })
56
+ expect(html).to include('<li><code>true</code></li>', '<li><code>false</code></li>', '<li><code>0</code></li>',
57
+ '<li><code>1.5</code></li>', '<li><code>null</code></li>',
58
+ '<li><code>[&quot;a\&quot;b&quot;,&quot;&quot;]</code></li>',
59
+ '<li><code>{&quot;nested&quot;:[&quot;&lt;tag&gt;&quot;,null]}</code></li>')
60
+ end
61
+ end
62
+
63
+ it 'keeps inclusion hash labels and empty string keys separate from empty sets' do
64
+ html = render(:include, { values: { '' => 'Empty label <&>', '"quoted"' => false, 'žluťoučký' => ['nested'] } })
65
+ expect(html).to include('<li><code></code>: Empty label &lt;&amp;&gt;</li>',
66
+ '<li><code>&quot;quoted&quot;</code>: <code>false</code></li>',
67
+ '<li><code>žluťoučký</code>: <code>[&quot;nested&quot;]</code></li>')
68
+ expect(html).not_to include('The set is empty.', 'Empty label <&>', '&amp;lt;')
69
+ end
70
+
71
+ it 'keeps acceptance and confirmation values distinct without coercion' do
72
+ [nil, false, 0, '0'].each do |value|
73
+ expect(render(:accept, { value: })).to include("Must equal <code>#{CGI.escapeHTML(JSON.generate(value))}</code>")
74
+ end
75
+ expect(render(:confirm, { parameter: :password, equal: true })).to include('Must equal parameter <code>password</code>')
76
+ expect(render(:confirm, { parameter: 'password', equal: false })).to include('Must differ from parameter <code>password</code>')
77
+ end
78
+
79
+ it 'explains inclusive bounds, step origin, modulo and parity separately' do
80
+ html = render(:number, { min: 3, max: 10, step: 2, mod: 5, odd: true })
81
+ expect(html).to include('Minimum: <code>3</code> (inclusive)', 'Maximum: <code>10</code> (inclusive)',
82
+ 'Step: <code>2</code>, measured from <code>3</code>',
83
+ 'multiple of <code>5</code>, measured from zero', 'Must be odd.', 'Must be a number after parameter type conversion.')
84
+ expect(render(:number, { step: 2, even: true, odd: false })).to include('measured from <code>0</code>', 'Must be even.', 'Odd-only restriction: disabled.')
85
+ end
86
+
87
+ it 'describes character length for text and neutral length for unknown types' do
88
+ expect(render(:length, { min: 0, max: 12 }, type: 'String')).to include('Minimum length: <code>0</code> characters', 'Maximum length: <code>12</code> characters')
89
+ expect(render(:length, { equals: 2 }, type: 'Custom')).to include('Length must equal <code>2</code>.')
90
+ expect(render(:length, { equals: 2 }, type: 'Array')).to include('<code>2</code> items')
91
+ expect(render(:length, { equals: 2 }, type: 'Text')).not_to include('bytes')
92
+ end
93
+
94
+ it 'shows regex source, negation and descriptions without inventing flags' do
95
+ html = render(:format, { rx: '^[a-z]+$', match: false, description: 'Reserved spelling', message: 'Invalid spelling' })
96
+ expect(html).to include('Must not match this regular expression:', '<code>^[a-z]+$</code>', 'Reserved spelling', 'Validation error message:', 'Invalid spelling')
97
+ end
98
+
99
+ it 'labels application-specific rules without claiming generic enforcement' do
100
+ expect(render(:custom, 'Verified by the application')).to include('Application-specific rule: Verified by the application')
101
+ expect(render(:custom, 'Verified by the application')).not_to include('Must pass', 'automatically')
102
+ end
103
+
104
+ it 'preserves unknown fields and unexpected shapes with a labelled lossless fallback' do
105
+ html = render('X-Extension', { 'nested' => { 'zero' => 0, 'false' => false, 'empty' => '', 'null' => nil } })
106
+ expect(html).to include('X-Extension', 'JSON:', '&quot;zero&quot;: 0', '&quot;false&quot;: false', '&quot;empty&quot;: &quot;&quot;', '&quot;null&quot;: null')
107
+ expect(render(:include, { values: 'unexpected', message: 'Kept' })).to include('Supplied validator metadata:', 'unexpected', 'Kept')
108
+ expect(render(:number, { min: 'not numeric', message: 'Kept' })).to include('Supplied validator metadata:', 'not numeric', 'Kept')
109
+ expect(render(:include, { values: [], extra: false })).to include('Additional details', CGI.escapeHTML({ extra: false }.inspect), 'Ruby value:')
110
+ expect(render(:include, { values: [:symbol] })).to include('Ruby value:', ':symbol')
111
+ end
112
+
113
+ it 'escapes source labels messages and structured options once without mutation' do
114
+ payload = '<script>&"'
115
+ options = { values: { payload => payload }, message: payload, extra: { 'nested' => payload } }
116
+ before = Marshal.dump(options)
117
+ html = render(:include, options)
118
+ expect(html).not_to include('<script>', '&amp;lt;')
119
+ expect(html).to include('&lt;script&gt;&amp;', 'Validation error message:', 'Additional details')
120
+ expect(render(payload, { 'extra' => payload })).to include('<th scope="row">&lt;script&gt;&amp;&quot;</th>')
121
+ expect(Marshal.dump(options)).to eq(before)
122
+ end
123
+ end
@@ -174,7 +174,7 @@ describe 'Documentation' do
174
174
  expect(last_response.headers['Content-Type']).to include('text/html')
175
175
 
176
176
  # servers/ruby/doc/index.md contains this heading
177
- expect(last_response.body).to include('HaveAPI documentation')
177
+ expect(last_response.body).to include('HaveAPI internals')
178
178
  end
179
179
 
180
180
  it 'has online index doc page without a sidebar template' do
@@ -182,11 +182,11 @@ describe 'Documentation' do
182
182
 
183
183
  expect(last_response.status).to eq(200)
184
184
  expect(last_response.headers['Content-Type']).to include('text/html')
185
- expect(last_response.body).to include('HaveAPI documentation')
185
+ expect(last_response.body).to include('HaveAPI internals')
186
186
  end
187
187
 
188
188
  it 'has online doc for every version' do
189
- get '/v1/'
189
+ get '/v1/?resource=user'
190
190
  expect(last_response.status).to eq(200)
191
191
  expect(last_response.headers['Content-Type']).to include('text/html')
192
192
  expect(last_response.body).to include('verified by MFA backend')
@@ -55,6 +55,8 @@ describe HaveAPI::Server do
55
55
  end
56
56
  %w[
57
57
  lib/haveapi/documentation_context.rb lib/haveapi/documentation_clients.rb
58
+ lib/haveapi/shared_documentation_helpers.rb
59
+ lib/haveapi/views/shared_sidebar.erb lib/haveapi/views/shared_documentation.erb
58
60
  lib/haveapi/views/version_page/client_result.erb
59
61
  lib/haveapi/public/js/highlight.pack.js lib/haveapi/public/js/highlight-LICENSE
60
62
  ].each do |name|
@@ -83,9 +85,9 @@ describe HaveAPI::Server do
83
85
  server.use_version(1)
84
86
  server.mount('/')
85
87
  request = Rack::MockRequest.new(server.app)
86
- responses = %w[/doc/json-schema /doc/protocol.md /doc/create-client.md /doc/client-examples.md /js/highlight-LICENSE].to_h do |path|
88
+ responses = %w[/ /doc /doc/index /doc/index.md /doc/readme /doc/json-schema /doc/protocol.md /doc/create-client.md /doc/client-examples.md /js/highlight-LICENSE].to_h do |path|
87
89
  response = request.get(path, 'HTTP_HOST' => 'localhost')
88
- [path, { status: response.status, type: response.content_type, body: response.body }]
90
+ [path, { status: response.status, type: response.content_type, cache_control: response['Cache-Control'], body: response.body }]
89
91
  end
90
92
  puts JSON.generate({
91
93
  framework: HaveAPI::Server.instance_method(:mount).source_location.first,
@@ -117,6 +119,11 @@ describe HaveAPI::Server do
117
119
  expect(File.realpath(result.fetch('framework'))).to eq(File.join(dir, 'lib/haveapi/server.rb'))
118
120
 
119
121
  {
122
+ '/' => 'API versions',
123
+ '/doc' => 'Server-side implementation',
124
+ '/doc/index' => 'Server-side implementation',
125
+ '/doc/index.md' => 'Server-side implementation',
126
+ '/doc/readme' => 'Server-side implementation',
120
127
  '/doc/json-schema' => 'Envelope',
121
128
  '/doc/protocol.md' => 'Protocol definition',
122
129
  '/doc/create-client.md' => 'Client definition',
@@ -127,6 +134,18 @@ describe HaveAPI::Server do
127
134
  expect(response.fetch('status')).to eq(200), "Packaged route failed: #{path}"
128
135
  expect(response.fetch('type')).to include('text/html') unless path == '/js/highlight-LICENSE'
129
136
  expect(response.fetch('body')).to include(heading)
137
+ next if path == '/js/highlight-LICENSE'
138
+
139
+ expect(response.fetch('cache_control')).to eq('private, no-store')
140
+ expect(response.fetch('body')).to include('data-docs', '/js/documentation.js', '/css/documentation.css')
141
+ expect(response.fetch('body')).not_to include('/js/main.js', '/css/bootstrap')
142
+ end
143
+ %w[/doc /doc/index /doc/index.md /doc/readme].each do |path|
144
+ body = result.fetch('responses').fetch(path).fetch('body')
145
+ expect(body).to include('<title>HaveAPI internals · ', 'Server framework features',
146
+ '<pre><code class="ruby">', 'class User &lt; ActiveRecord::Base')
147
+ menu = body.match(%r{<nav aria-label="Shared documentation">(.*?)</nav>}m)[1]
148
+ expect(menu).not_to include('href="/doc/readme"')
130
149
  end
131
150
  end
132
151
  end
@@ -0,0 +1,47 @@
1
+ require 'spec_helper'
2
+ require_relative '../../benchmarks/endpoints'
3
+ require_relative '../../test_support/documentation_performance_fixture'
4
+
5
+ describe HaveAPI::Performance::DocumentationScenario, '#call' do
6
+ def scenario(id, user = 'admin')
7
+ HaveAPI::Performance::DocumentationScenario.new(id:, dimensions: { size: 1, user: })
8
+ end
9
+
10
+ it 'validates a real cookie token on every HTML page with stable role-specific bodies and counters' do
11
+ page = scenario('docs.html_resources')
12
+ page.reset!(iterations: 3)
13
+ responses = 3.times.map { page.call }
14
+ responses.each_with_index { |response, index| expect(page.validate!(response, index:)[:http_status]).to eq(200) }
15
+ expect(responses.map(&:body).uniq.length).to eq(1)
16
+ expect(responses.first.body).to include('<span class="docs-identity">admin</span>', 'name="csrf"')
17
+ fixture = page.instance_variable_get(:@server).fixture_documentation_oauth
18
+ expect(fixture.lookups).to eq(Array.new(3) { [1, 'documentation-admin'] })
19
+ expect(page.documentation_identity).to eq(mode: 'documentation_oauth_cookie', role: 'admin')
20
+ expect(page.counters[:operations]).to eq(Array.new(3) { { queries: 0, authorizations: 2, executions: 0 } })
21
+ end
22
+
23
+ it 'keeps member role filtering and independent CSRF without accepting an admin overview' do
24
+ admin = scenario('docs.html_resources')
25
+ member = scenario('docs.html_resources', 'member')
26
+ [admin, member].each { |page| page.reset!(iterations: 1) }
27
+ administrative = admin.call
28
+ restricted = member.call
29
+ expect(member.validate!(restricted, index: 0)[:http_status]).to eq(200)
30
+ expect(restricted.body).to include('<span class="docs-identity">member</span>')
31
+ expect(restricted.body).not_to include('root-resource0-admin_only')
32
+ expect { member.validate_body!(administrative) }.to raise_error(HaveAPI::Performance::ContractError, /authorization/)
33
+ nonce = ->(response) { response.body.match(/name="csrf" value="([^"]+)"/)[1] }
34
+ expect(nonce.call(restricted)).not_to eq(nonce.call(administrative))
35
+ end
36
+
37
+ it 'retains ordinary Basic setup and counter behavior for documentation OPTIONS' do
38
+ page = scenario('docs.options')
39
+ page.reset!(iterations: 1)
40
+ response = page.call
41
+ expect(page.validate!(response, index: 0)[:http_status]).to eq(200)
42
+ expect(page.documentation_identity).to eq(mode: 'basic', role: 'admin')
43
+ expect(page.instance_variable_get(:@headers)).to include('HTTP_AUTHORIZATION')
44
+ expect(page.instance_variable_get(:@headers)).not_to include('HTTP_COOKIE')
45
+ expect(page.instance_variable_get(:@server)).not_to respond_to(:fixture_documentation_oauth)
46
+ end
47
+ end
@@ -0,0 +1,97 @@
1
+ require 'spec_helper'
2
+ require_relative '../../benchmarks/endpoints'
3
+ require_relative '../../benchmarks/documentation'
4
+ require_relative '../../test_support/documentation_performance_fixture'
5
+
6
+ describe HaveAPI::Performance::DocumentationScenario do
7
+ let(:scenario) { described_class.new(id: 'docs.html_resources', dimensions: { size: 2, user: 'admin' }) }
8
+ let(:overview) do
9
+ scenario.reset!(iterations: 1)
10
+ scenario.call
11
+ end
12
+ let(:error) { HaveAPI::Performance::ContractError }
13
+
14
+ def response_with(body)
15
+ Rack::MockResponse.new(200, { 'content-type' => 'text/html' }, body)
16
+ end
17
+
18
+ def replace_map(body)
19
+ body.sub(%r{(<script\b[^>]*data-legacy-anchors[^>]*>)(.*?)(</script>)}m) do
20
+ before, json, after = Regexp.last_match.captures
21
+ values = JSON.parse(json)
22
+ yield(values)
23
+ "#{before}#{JSON.generate(values)}#{after}"
24
+ end
25
+ end
26
+
27
+ it 'accepts the actual overview and preserves authorization counters and raw identity' do
28
+ identity = scenario.validate!(overview, index: 0)
29
+ expect(identity[:page]).to include(shape: 'overview', actual_examples: 0, resources: 2, actions: 3)
30
+ expect(identity[:body_sha256]).to eq(Digest::SHA256.hexdigest(overview.body))
31
+ expect(overview.body).not_to include('PUBLIC_SAMPLE_', 'ADMIN_ONLY_REQUEST')
32
+ end
33
+
34
+ it 'accepts the strict legacy full-page contract without accepting an empty third shape' do
35
+ legacy = response_with('init-python-sync init-examples PUBLIC_SAMPLE_0 ADMIN_ONLY_REQUEST')
36
+ expect(scenario.validate_body!(legacy)[:page]).to include(shape: 'monolithic')
37
+ expect { scenario.validate_body!(response_with('<h1>Empty</h1>')) }.to raise_error(error, /panels/)
38
+ end
39
+
40
+ it 'requires directory navigation, canonical targets and real initialization code' do
41
+ bodies = [
42
+ overview.body.gsub('data-resource-directory-link', 'data-missing-directory-link'),
43
+ overview.body.sub('href="/benchmark/api/v1/?page=resources"', 'href="https://foreign.example/"'),
44
+ overview.body.sub('id="init-python-sync"', 'id="missing-python-sync"'),
45
+ overview.body.sub('id="init-python-sync-thorough"', 'id="missing-python-thorough"'),
46
+ overview.body.sub('id="init-javascript-async"', 'id="missing-javascript-async"')
47
+ ]
48
+ bodies.each do |body|
49
+ expect { scenario.validate_body!(response_with(body)) }.to raise_error(error, /navigation|initialization/)
50
+ end
51
+ end
52
+
53
+ it 'rejects duplicate, malformed, wrong and mixed overview representations' do
54
+ bodies = [
55
+ overview.body.sub('data-page="overview"', 'data-page="resource"'),
56
+ overview.body.sub('data-docs data-page=', 'data-docs data-docs data-page='),
57
+ "#{overview.body}<div data-docs data-page=\"overview\"></div>",
58
+ overview.body.sub('data-page="overview"', 'data-page="overview" data-page="resource"'),
59
+ "#{overview.body}<div id=\"example-resource0-action0-0-examples\">PUBLIC_SAMPLE_0</div>"
60
+ ]
61
+ bodies.each { |body| expect { scenario.validate_body!(response_with(body)) }.to raise_error(error, /overview/) }
62
+ end
63
+
64
+ it 'rejects missing, wrong or foreign legacy destinations and wrong authorization roles' do
65
+ maps = [
66
+ ->(values) { values.delete('root-resource1-action0') },
67
+ ->(values) { values['root-resource1'] = '/benchmark/api/v1/?resource=resource0' },
68
+ ->(values) { values['example-resource0-action0-'] = 'https://foreign.example/' },
69
+ ->(values) { values.delete('root-resource0-admin_only') },
70
+ ->(values) { values['auth-basic'] = 'https://foreign.example/' }
71
+ ]
72
+ maps.each do |mutate|
73
+ body = replace_map(overview.body, &mutate)
74
+ expect { scenario.validate_body!(response_with(body)) }.to raise_error(error, /authorization|targets/)
75
+ end
76
+ member = described_class.new(id: 'docs.html_resources', dimensions: { size: 2, user: 'member' })
77
+ expect { member.validate_body!(overview) }.to raise_error(error, /authorization/)
78
+ end
79
+
80
+ it 'rejects malformed legacy JSON and private parameter or sample data in either role' do
81
+ body = overview.body.sub(%r{(<script[^>]*data-legacy-anchors[^>]*>).*?(</script>)}m, '\1[\2')
82
+ expect { scenario.validate_body!(response_with(body)) }.to raise_error(error, /map/)
83
+ %w[PUBLIC_SAMPLE_0 ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE admin_field].each do |marker|
84
+ expect { scenario.validate_body!(response_with("#{overview.body}<p>#{marker}</p>")) }.to raise_error(error, /action data/)
85
+ end
86
+ end
87
+
88
+ it 'retains the optional benchmark root HTML lane for both explicit representations' do
89
+ benchmark = HaveAPI::DocumentationBenchmark.new([])
90
+ dimensions = { resource_count: 2 }
91
+ expect(benchmark.send(:html_output, overview, dimensions)[:status]).to eq(200)
92
+ legacy = response_with('init-examples PUBLIC_SAMPLE_0')
93
+ expect(benchmark.send(:html_output, legacy, dimensions)[:status]).to eq(200)
94
+ malformed = response_with(overview.body.sub('data-page="overview"', 'data-page="resource"'))
95
+ expect { benchmark.send(:html_output, malformed, dimensions) }.to raise_error(HaveAPI::DocumentationBenchmark::InvalidOutput)
96
+ end
97
+ end
@@ -29,6 +29,83 @@ describe HaveAPI::Performance do
29
29
  end
30
30
  end
31
31
 
32
+ def with_console_versions(pinned: true)
33
+ with_newer_json do |directory, environment|
34
+ (pinned ? %w[0.9.4 99.0.0] : ['99.0.0']).each do |version|
35
+ library = File.join(directory, "gems/io-console-#{version}/lib/io")
36
+ FileUtils.mkdir_p(library)
37
+ File.write(File.join(library, 'console.rb'), "CONSOLE_ACTIVATION_CONTROL = #{version.inspect}\n")
38
+ spec = Gem::Specification.new do |gem|
39
+ gem.name = 'io-console'
40
+ gem.version = version
41
+ gem.summary = 'Owned worker activation control'
42
+ gem.authors = ['Fixture']
43
+ end
44
+ File.write(File.join(directory, "specifications/io-console-#{version}.gemspec"), spec.to_ruby)
45
+ end
46
+ yield directory, environment
47
+ end
48
+ end
49
+
50
+ it 'activates the exact frozen console before source loading despite a newer visible version' do
51
+ with_console_versions do |_directory, environment|
52
+ program = <<~RUBY
53
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
54
+ visible = Gem::Specification.find_all_by_name('io-console').map { |spec| spec.version.to_s }
55
+ begin
56
+ HaveAPI::Performance::Worker.new('benchmark' => true, 'source_root' => '/missing-worker-source').run
57
+ rescue Errno::ENOENT
58
+ puts JSON.generate(visible: visible, loaded: Gem.loaded_specs.fetch('io-console').version.to_s,
59
+ required: CONSOLE_ACTIVATION_CONTROL, pp_loaded: $LOADED_FEATURES.any? { |path| path.end_with?('/pp.rb') })
60
+ end
61
+ RUBY
62
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
63
+ expect(status).to be_success, errors
64
+ result = JSON.parse(output)
65
+ expect(result.fetch('visible')).to include('99.0.0')
66
+ expect(result).to include('loaded' => '0.9.4', 'required' => '0.9.4', 'pp_loaded' => false)
67
+ budgets = JSON.parse(File.read(File.join(root, 'servers/ruby/benchmarks/performance_budgets.json')))
68
+ expect(budgets.fetch('environment').values.map { |value| value.fetch('gems').fetch('io-console').fetch('version') })
69
+ .to all(eq(result.fetch('loaded')))
70
+ lock = Bundler::LockfileParser.new(File.read(File.join(root, 'servers/ruby/benchmarks/Gemfile.lock')))
71
+ expect(lock.specs.find { |spec| spec.name == 'io-console' }.version.to_s).to eq(result.fetch('loaded'))
72
+ end
73
+ end
74
+
75
+ it 'leaves ordinary and non-Boolean worker console activation unchanged' do
76
+ with_console_versions(pinned: false) do |_directory, environment|
77
+ program = <<~RUBY
78
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
79
+ before = Gem.loaded_specs['io-console']&.version&.to_s
80
+ [nil, false, 'true', 1].each do |flag|
81
+ HaveAPI::Performance::Worker.new('benchmark' => flag).send(:activate_benchmark_console)
82
+ end
83
+ puts JSON.generate(before: before, after: Gem.loaded_specs['io-console']&.version&.to_s,
84
+ required: defined?(CONSOLE_ACTIVATION_CONTROL))
85
+ RUBY
86
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
87
+ expect(status).to be_success, errors
88
+ expect(JSON.parse(output)).to include('before' => nil, 'after' => nil, 'required' => nil)
89
+ end
90
+ end
91
+
92
+ it 'fails frozen workers clearly when the console version is missing or already conflicting' do
93
+ %w[missing conflicting].each do |control|
94
+ with_console_versions(pinned: control == 'conflicting') do |directory, environment|
95
+ program = <<~RUBY
96
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
97
+ Gem.use_paths(#{directory.inspect}, [#{directory.inspect}])
98
+ gem 'io-console', '= 99.0.0' if #{control.inspect} == 'conflicting'
99
+ HaveAPI::Performance::Worker.new('benchmark' => true).send(:activate_benchmark_console)
100
+ RUBY
101
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
102
+ expect(status).not_to be_success
103
+ expect(errors).to include('io-console', '0.9.4')
104
+ expect(errors).to include(control == 'missing' ? 'Could not find' : 'already activated io-console-99.0.0')
105
+ end
106
+ end
107
+ end
108
+
32
109
  it 'loads the native calibrated version in each public entry despite a newer visible gem' do
33
110
  with_newer_json do |_directory, environment|
34
111
  [comparison, File.join(root, 'utils/performance_ci.rb'), File.join(root, 'servers/ruby/benchmarks/endpoints.rb')].each do |entry|
@@ -76,7 +153,7 @@ describe HaveAPI::Performance do
76
153
  _output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
77
154
  expect(status).not_to be_success
78
155
  expect(errors).to include('json', '2.21.2', '3.1.0')
79
- expect(errors).to match(/Could not find/)
156
+ expect(errors).to include('Could not find')
80
157
  end
81
158
  end
82
159
 
@@ -236,6 +236,7 @@ describe HaveAPI::Performance::Release do
236
236
  File.write(File.join(harness, 'servers/ruby/Gemfile.lock'), "controlled server lock\n")
237
237
  FileUtils.mkdir_p(File.join(harness, 'clients/rust'))
238
238
  File.write(File.join(harness, 'clients/rust/Gemfile.lock'), "controlled Rust lock\n")
239
+ stub_const('HaveAPI::RustClient::VERSION', HaveAPI::VERSION) unless defined?(HaveAPI::RustClient::VERSION)
239
240
  stub_const('HaveAPI::Performance::ROOT', harness)
240
241
  ordinary = JSON.parse(JSON.generate(HaveAPI::Performance::Worker.inputs(root, 'quick')))
241
242
  preparation = ordinary.fetch('json_preparation')
@@ -278,9 +279,9 @@ describe HaveAPI::Performance::Release do
278
279
  end
279
280
 
280
281
  def served_html
281
- server = HaveAPI::DocumentationPerformanceFixture.build(resource_count: 1, actions_per_resource: 1)
282
- response = Rack::MockRequest.new(server.app).get('http://example.test/benchmark/api/v1/',
283
- 'HTTP_HOST' => 'example.test', 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64('admin:fixture-only')}")
282
+ server = HaveAPI::DocumentationPerformanceFixture.build(resource_count: 1, actions_per_resource: 1, documentation_oauth: true)
283
+ response = Rack::MockRequest.new(server.app).get("#{server.fixture_documentation_oauth.origin}/benchmark/api/v1/",
284
+ 'HTTP_HOST' => 'example.test', 'HTTP_COOKIE' => server.fixture_documentation_cookie('admin'))
284
285
  expect(response.status).to eq(200)
285
286
  response.body
286
287
  end
@@ -467,6 +467,7 @@ describe HaveAPI::Performance::Worker do
467
467
  expect(retained['cases'].map { |item| item['iterations'] }).to all(eq(expensive ? 1 : 4))
468
468
  expect(retained['cases'].map { |item| item['pilot']['baseline_batch_seconds'] }).to all(eq(expensive ? 2.0 : 1.0))
469
469
  requests.each do |label, request|
470
+ expect(request['benchmark']).to be(true)
470
471
  next if %w[pilot calibration].include?(label)
471
472
 
472
473
  expect(request['counts'].values).to all(eq(expensive ? 1 : 4))
@@ -9,13 +9,7 @@ module HaveAPI
9
9
  query_parameter '_auth_token'
10
10
  end
11
11
 
12
- class OAuthConfig < Authentication::OAuth2::Config
13
- def base_url
14
- 'http://example.test'
15
- end
16
- end
17
-
18
- def self.build(resources:)
12
+ def self.build(resources:, actions: 2)
19
13
  definitions = Module.new
20
14
  resources.times do |index|
21
15
  resource = Class.new(HaveAPI::Resource)
@@ -23,7 +17,8 @@ module HaveAPI
23
17
  resource.resource_name = "Resource#{index}"
24
18
  resource.version 1
25
19
  resource.route "resources#{index}"
26
- %w[Public Protected].each { |name| add_action(resource, name) }
20
+ names = %w[Public Protected] + (2...actions).map { |i| "Action#{i}" }
21
+ names.each { |name| add_action(resource, name) }
27
22
  DocumentationPerformanceFixture.send(:add_admin_action, resource) if index == 0
28
23
  end
29
24
  server = HaveAPI::Server.new(definitions)
@@ -31,12 +26,13 @@ module HaveAPI
31
26
  server.default_version = 1
32
27
  server.auth_chain << DocumentationPerformanceFixture::BasicProvider
33
28
  server.auth_chain << Authentication::Token.with_config(TokenConfig)
34
- server.auth_chain << Authentication::OAuth2.with_config(OAuthConfig)
29
+ fixture = DocumentationPerformanceFixture.configure_documentation_oauth(server)
35
30
  server.documentation_clients(1,
36
31
  go: { import_path: 'example.test/performance/client' },
37
32
  rust: { crate_name: 'performance_client' },
38
33
  typescript: { package_name: 'performance-client' })
39
34
  server.mount('/benchmark/api/')
35
+ DocumentationPerformanceFixture.attach_documentation_headers(server, fixture)
40
36
  server
41
37
  end
42
38
 
@@ -44,7 +40,7 @@ module HaveAPI
44
40
  resource.define_action(name) do
45
41
  route name.downcase
46
42
  http_method :post
47
- auth name == 'Protected'
43
+ auth name != 'Public'
48
44
  input(:hash) do
49
45
  string :value
50
46
  bool :enabled
@@ -0,0 +1,54 @@
1
+ require 'digest'
2
+ require 'rack/response'
3
+ require 'haveapi'
4
+
5
+ # Real documentation cookies and provider lookup for specs and benchmarks.
6
+ class DocumentationOAuthFixture
7
+ CookieHandler = Struct.new(:response)
8
+
9
+ attr_reader :origin, :lookups
10
+
11
+ def self.configure(server, users:, origin: 'https://example.org', mount_path: '/')
12
+ fixture = new(users, origin)
13
+ fixture.configure(server, mount_path)
14
+ fixture
15
+ end
16
+
17
+ def initialize(users, origin)
18
+ @users = users
19
+ @origin = origin
20
+ @lookups = []
21
+ @expires_at = Time.now.to_f + 3600
22
+ end
23
+
24
+ def configure(server, mount_path)
25
+ fixture = self
26
+ config = Class.new(HaveAPI::Authentication::OAuth2::Config) do
27
+ define_method(:base_url) { fixture.origin }
28
+ define_method(:find_user_by_access_token) do |request, token|
29
+ fixture.find_user(@version, request, token)
30
+ end
31
+ end
32
+ server.auth_chain << HaveAPI::Authentication::OAuth2.with_config(config)
33
+ server.documentation_oauth(
34
+ client_id: 'documentation-fixture', client_secret: 'documentation-fixture-client-secret',
35
+ session_secret: 'documentation-fixture-session-secret-' * 3,
36
+ redirect_uri: "#{origin}#{mount_path.chomp('/')}/_documentation/oauth/callback"
37
+ )
38
+ end
39
+
40
+ def find_user(version, _request, token)
41
+ @lookups << [version, token]
42
+ @users.fetch(version, {})[token]
43
+ end
44
+
45
+ def header_for(server, version:, token:)
46
+ @users.fetch(version).fetch(token)
47
+ handler = CookieHandler.new(Rack::Response.new)
48
+ server.documentation_oauth.send(:write_cookie, handler, 'session', {
49
+ 'token' => token, 'expires' => @expires_at,
50
+ 'csrf' => Digest::SHA256.hexdigest("documentation-fixture-csrf:#{token}")
51
+ })
52
+ Array(handler.response.headers['set-cookie']).fetch(0).split(';', 2).first
53
+ end
54
+ end
@@ -14,7 +14,7 @@ module HaveAPI
14
14
  end
15
15
 
16
16
  def self.build(resource_count: 4, actions_per_resource: 2, samples_per_action: 1,
17
- parameters_per_action: 2, rust: false)
17
+ parameters_per_action: 2, rust: false, documentation_oauth: false)
18
18
  dimensions = [resource_count, actions_per_resource, samples_per_action, parameters_per_action]
19
19
  unless dimensions.all? { |value| value.is_a?(Integer) && value > 0 }
20
20
  raise ArgumentError, 'Fixture dimensions must be positive integers'
@@ -41,10 +41,28 @@ module HaveAPI
41
41
  if rust
42
42
  server.documentation_clients(1, rust: { crate_name: 'documentation_performance_client' })
43
43
  end
44
+ fixture = configure_documentation_oauth(server) if documentation_oauth
44
45
  server.mount('/benchmark/api/')
46
+ attach_documentation_headers(server, fixture) if fixture
45
47
  server
46
48
  end
47
49
 
50
+ def self.configure_documentation_oauth(server)
51
+ require_relative 'documentation_oauth_fixture'
52
+ users = %w[admin member].to_h { |role| ["documentation-#{role}", User.new(role)] }
53
+ DocumentationOAuthFixture.configure(server, users: { 1 => users }, origin: 'https://example.test', mount_path: '/benchmark/api/')
54
+ end
55
+
56
+ def self.attach_documentation_headers(server, fixture)
57
+ # Mint once during setup. Each page still decrypts and validates the real
58
+ # token; deterministic per-role CSRF preserves repeated HTML body proof.
59
+ cookies = %w[admin member].to_h do |role|
60
+ [role, fixture.header_for(server, version: 1, token: "documentation-#{role}")]
61
+ end
62
+ server.define_singleton_method(:fixture_documentation_oauth) { fixture }
63
+ server.define_singleton_method(:fixture_documentation_cookie) { |role| cookies.fetch(role) }
64
+ end
65
+
48
66
  def self.add_action(resource, index, samples, parameters)
49
67
  resource.define_action("Action#{index}") do
50
68
  route "actions#{index}"