haveapi 0.30.0 → 0.30.2
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 +1 -0
- data/Gemfile +1 -0
- data/benchmarks/documentation.rb +277 -0
- data/benchmarks/endpoints.rb +730 -0
- data/benchmarks/performance_budgets.json +1354 -0
- data/benchmarks/performance_compare.rb +579 -0
- data/doc/client-examples.md +233 -24
- data/doc/performance.md +272 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/documentation_clients.rb +158 -35
- data/lib/haveapi/documentation_context.rb +42 -12
- data/lib/haveapi/documentation_schema.rb +73 -0
- data/lib/haveapi/server.rb +5 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/spec/client_examples/context_performance_spec.rb +72 -0
- data/spec/client_examples/context_spec.rb +206 -0
- data/spec/client_examples/dynamic_js_spec.rb +1 -0
- data/spec/client_examples/profile_mount_spec.rb +215 -0
- data/spec/client_examples/profiles_spec.rb +202 -32
- data/spec/client_examples/structural_schema_spec.rb +48 -0
- data/spec/documentation/client_examples_spec.rb +10 -0
- data/spec/documentation/performance_spec.rb +79 -0
- data/spec/performance/comparator_spec.rb +342 -0
- data/spec/performance/endpoint_contract_spec.rb +226 -0
- data/spec/performance/endpoint_scaling_spec.rb +82 -0
- data/spec/performance/event_baseline_spec.rb +279 -0
- data/spec/performance/json_activation_spec.rb +151 -0
- data/spec/performance/release_lineage_spec.rb +431 -0
- data/spec/performance/runner_spec.rb +476 -0
- data/spec/performance/sqlite_spec.rb +194 -0
- data/spec/performance/support/live_worker.rb +44 -0
- data/spec/performance/support/samples.rb +72 -0
- data/test_support/documentation_performance_fixture.rb +106 -0
- data/test_support/performance_endpoint_fixture.rb +350 -0
- data/test_support/performance_sqlite_fixture.rb +384 -0
- metadata +26 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require_relative '../../test_support/performance_sqlite_fixture'
|
|
3
|
+
require_relative '../../benchmarks/endpoints'
|
|
4
|
+
|
|
5
|
+
describe HaveAPI::PerformanceFixtures::SQLite do
|
|
6
|
+
def with_scenario(id: 'sqlite.includes', **dimensions)
|
|
7
|
+
scenario = described_class.build(id:, dimensions:)
|
|
8
|
+
yield scenario
|
|
9
|
+
ensure
|
|
10
|
+
scenario&.close
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run_operations(scenario, count = 2)
|
|
14
|
+
scenario.reset!(iterations: count)
|
|
15
|
+
responses = Array.new(count) { scenario.call }
|
|
16
|
+
responses.each_with_index { |response, index| scenario.validate!(response, index:) }
|
|
17
|
+
[responses, scenario.counters.fetch(:operations)]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'reuses only SQLite body semantics while checking every query, authorization and called index' do
|
|
21
|
+
with_scenario(rows: 32, include_depth: 2, user: 'member') do |scenario|
|
|
22
|
+
allow(scenario).to receive(:validate_body!).and_call_original
|
|
23
|
+
worker = HaveAPI::Performance::Worker.new({})
|
|
24
|
+
result = worker.measure(scenario, 4)
|
|
25
|
+
expect(result[:bodies].length).to eq(1)
|
|
26
|
+
expect(scenario).to have_received(:validate_body!).once
|
|
27
|
+
%i[queries authorizations executions].each do |counter|
|
|
28
|
+
calls = 0
|
|
29
|
+
allow(scenario).to receive(:call).and_wrap_original do |call|
|
|
30
|
+
response = call.call
|
|
31
|
+
calls += 1
|
|
32
|
+
scenario.instance_variable_get(:@operations)[1][counter] += 1 if calls == 2
|
|
33
|
+
response
|
|
34
|
+
end
|
|
35
|
+
expect { worker.measure(scenario, 3) }.to raise_error(/operation counters/)
|
|
36
|
+
end
|
|
37
|
+
scenario.reset!(iterations: 2)
|
|
38
|
+
response = scenario.call
|
|
39
|
+
expect { scenario.validate!(response, index: 1) { raise 'body validator must not run' } }.to raise_error(ArgumentError, /uncalled/)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'exposes unique stable quick/weekly cases and rejects unsupported shapes' do
|
|
44
|
+
quick = described_class.cases
|
|
45
|
+
expect(quick).to eq([{ id: 'sqlite.page', dimensions: { rows: 128, page_size: 32 } },
|
|
46
|
+
{ id: 'sqlite.includes', dimensions: { rows: 32, include_depth: 2 } }])
|
|
47
|
+
weekly = described_class.cases(profile: 'weekly')
|
|
48
|
+
expect(weekly.uniq).to eq(weekly)
|
|
49
|
+
expect(weekly).to include(id: 'sqlite.includes', dimensions: { rows: 1024, include_depth: 2, user: 'member' })
|
|
50
|
+
expect { described_class.cases(profile: 'missing') }.to raise_error(ArgumentError)
|
|
51
|
+
expect { described_class.build(id: 'missing', dimensions: { rows: 1 }) }.to raise_error(ArgumentError)
|
|
52
|
+
expect { described_class.build(id: 'sqlite.page', dimensions: { rows: 1024, page_size: 1024 }) }.to raise_error(ArgumentError)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
[32, 128, 1024].each do |rows|
|
|
56
|
+
it "keeps a fixed 32-row page at one real uncached query over #{rows} rows" do
|
|
57
|
+
with_scenario(id: 'sqlite.page', rows:, page_size: 32) do |scenario|
|
|
58
|
+
responses, counts = run_operations(scenario)
|
|
59
|
+
expect(counts).to eq(Array.new(2) { { queries: 1, authorizations: 1, executions: 1 } })
|
|
60
|
+
values = JSON.parse(responses.first.body).fetch('response').fetch('items')
|
|
61
|
+
expect(values.map { |item| item.fetch('id') }).to eq((1..32).to_a)
|
|
62
|
+
expect(values.first).to include('label' => '', 'enabled' => false, 'count' => 0, 'note' => nil)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
[1, 32, 128].product([1, 2]).each do |rows, depth|
|
|
68
|
+
it "bounds explicit depth #{depth} includes to #{depth + 1} queries for #{rows} rows while retaining row authorization" do
|
|
69
|
+
with_scenario(rows:, include_depth: depth) do |scenario|
|
|
70
|
+
responses, counts = run_operations(scenario)
|
|
71
|
+
expected = { queries: depth + 1, authorizations: 1 + (rows * depth), executions: 1 }
|
|
72
|
+
expect(counts).to eq([expected, expected])
|
|
73
|
+
groups = JSON.parse(responses.first.body).fetch('response').fetch('items').map { |item| item.fetch('group') }
|
|
74
|
+
expect(groups).to all(include('_meta' => include('resolved' => true)))
|
|
75
|
+
expect(groups.first).to have_key('account') if depth == 2
|
|
76
|
+
expect(groups.first).not_to have_key('account') if depth == 1
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'applies the real ascending cursor before the fixed page limit' do
|
|
82
|
+
with_scenario(id: 'sqlite.page', rows: 128, page_size: 32) do |scenario|
|
|
83
|
+
path = scenario.instance_variable_get(:@path)
|
|
84
|
+
scenario.instance_variable_set(:@path, path.sub('from_id%5D=0', 'from_id%5D=32'))
|
|
85
|
+
response = scenario.call
|
|
86
|
+
expect(response.status).to eq(200)
|
|
87
|
+
body = JSON.parse(response.body)
|
|
88
|
+
expect(body.fetch('status')).to be(true)
|
|
89
|
+
expect(body.fetch('response').fetch('items').map { |item| item.fetch('id') }).to eq((33..64).to_a)
|
|
90
|
+
expect(scenario.counters.fetch(:operations)).to eq([{ queries: 1, authorizations: 1, executions: 1 }])
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'keeps fixed-page allocation work independent of unreturned table rows' do
|
|
95
|
+
allocations = [32, 1024].map do |rows|
|
|
96
|
+
with_scenario(id: 'sqlite.page', rows:, page_size: 32) do |scenario|
|
|
97
|
+
2.times { run_operations(scenario, 1) }
|
|
98
|
+
3.times.map do
|
|
99
|
+
scenario.reset!(iterations: 3)
|
|
100
|
+
responses = Array.new(3)
|
|
101
|
+
GC.start
|
|
102
|
+
before = GC.stat(:total_allocated_objects)
|
|
103
|
+
3.times { |index| responses[index] = scenario.call }
|
|
104
|
+
count = GC.stat(:total_allocated_objects) - before
|
|
105
|
+
responses.each_with_index { |response, index| scenario.validate!(response, index:) }
|
|
106
|
+
count
|
|
107
|
+
end.min
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
# Ruby 3.4.11 calibration observed 2620 objects/request at every table size.
|
|
111
|
+
# Fixed slack accommodates VM bookkeeping while rejecting table-sized work.
|
|
112
|
+
expect(allocations.last - allocations.first).to be <= (128 * 3)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'serializes 1024 rows without passing an invalid pagination limit' do
|
|
116
|
+
with_scenario(rows: 1024, include_depth: 2) do |scenario|
|
|
117
|
+
responses, counts = run_operations(scenario, 1)
|
|
118
|
+
expect(JSON.parse(responses.first.body).fetch('response').fetch('items').length).to eq(1024)
|
|
119
|
+
expect(counts).to eq([{ queries: 3, authorizations: 2049, executions: 1 }])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'keeps member association decisions and hidden values isolated between administrator requests' do
|
|
124
|
+
with_scenario(rows: 32, include_depth: 2, user: 'admin') do |admin|
|
|
125
|
+
with_scenario(rows: 32, include_depth: 2, user: 'member') do |member|
|
|
126
|
+
first, = run_operations(admin, 1)
|
|
127
|
+
restricted, counts = run_operations(member, 1)
|
|
128
|
+
restored, = run_operations(admin, 1)
|
|
129
|
+
expect(restored.first.body).to eq(first.first.body)
|
|
130
|
+
expect(first.first.body).to include('GROUP_SECRET_2', 'ACCOUNT_SECRET_3')
|
|
131
|
+
expect(restricted.first.body).not_to include('GROUP_SECRET', 'ACCOUNT_SECRET', '"label":"group2"', '"label":"account3"')
|
|
132
|
+
items = JSON.parse(restricted.first.body).fetch('response').fetch('items')
|
|
133
|
+
denied = { '_meta' => { 'resolved' => false, 'authorized' => false } }
|
|
134
|
+
expect(items[1].fetch('group')).to eq(denied)
|
|
135
|
+
expect(items[2].fetch('group').fetch('account')).to eq(denied)
|
|
136
|
+
expect(counts).to eq([{ queries: 3, authorizations: 49, executions: 1 }])
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'rechecks administrator/member permissions on one app and pool across repeated requests' do
|
|
142
|
+
with_scenario(rows: 32, include_depth: 2) do |scenario|
|
|
143
|
+
results = %w[admin member admin].map do |user|
|
|
144
|
+
scenario.instance_variable_set(:@user, user)
|
|
145
|
+
scenario.instance_variable_get(:@headers)['HTTP_AUTHORIZATION'] =
|
|
146
|
+
"Basic #{Base64.strict_encode64("#{user}:fixture-only")}"
|
|
147
|
+
run_operations(scenario, 1).first.first
|
|
148
|
+
end
|
|
149
|
+
expect(results.last.body).to eq(results.first.body)
|
|
150
|
+
expect(results[1].body).not_to include('GROUP_SECRET', 'ACCOUNT_SECRET')
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'resets operation records, validates every response and rejects incomplete/extra calls' do
|
|
155
|
+
with_scenario(rows: 1, include_depth: 2) do |scenario|
|
|
156
|
+
scenario.reset!(iterations: 2)
|
|
157
|
+
first = scenario.call
|
|
158
|
+
expect { scenario.counters }.to raise_error(/incomplete/)
|
|
159
|
+
second = scenario.call
|
|
160
|
+
expect(scenario.validate!(first, index: 0)[:body_sha256]).to eq(Digest::SHA256.hexdigest(first.body))
|
|
161
|
+
expect(scenario.validate!(second, index: 1)[:canonical_body]).to eq(JSON.parse(second.body))
|
|
162
|
+
expect { scenario.validate!(first, index: -1) }.to raise_error(ArgumentError)
|
|
163
|
+
expect { scenario.call }.to raise_error(/beyond/)
|
|
164
|
+
scenario.reset!(iterations: 1)
|
|
165
|
+
expect { scenario.validate!(first, index: 0) }.to raise_error(ArgumentError, /uncalled/)
|
|
166
|
+
response = scenario.call
|
|
167
|
+
scenario.validate!(response, index: 0)
|
|
168
|
+
wrong = response.dup
|
|
169
|
+
allow(wrong).to receive(:body).and_return(response.body.sub('group1', 'wrong!'))
|
|
170
|
+
expect { scenario.validate!(wrong, index: 0) }.to raise_error(/rows or values/)
|
|
171
|
+
expect { scenario.reset!(iterations: 0) }.to raise_error(ArgumentError)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it 'owns a separate connection without changing the global ActiveRecord base or another scenario' do
|
|
176
|
+
original_connected = ActiveRecord::Base.connected?
|
|
177
|
+
original_pool = ActiveRecord::Base.connection_pool if original_connected
|
|
178
|
+
first = described_class.build(id: 'sqlite.includes', dimensions: { rows: 1, include_depth: 2 })
|
|
179
|
+
second = described_class.build(id: 'sqlite.includes', dimensions: { rows: 32, include_depth: 2 })
|
|
180
|
+
first_pool = first.instance_variable_get(:@base).connection_pool
|
|
181
|
+
second_pool = second.instance_variable_get(:@base).connection_pool
|
|
182
|
+
expect(first_pool).not_to equal(second_pool)
|
|
183
|
+
expect(ActiveRecord::Base.connected?).to eq(original_connected)
|
|
184
|
+
expect(ActiveRecord::Base.connection_pool).to equal(original_pool) if original_pool
|
|
185
|
+
first.close
|
|
186
|
+
expect(first_pool.connected?).to be(false)
|
|
187
|
+
expect { first.call }.to raise_error(/closed/)
|
|
188
|
+
_responses, counts = run_operations(second, 1)
|
|
189
|
+
expect(counts.first).to eq(queries: 3, authorizations: 65, executions: 1)
|
|
190
|
+
ensure
|
|
191
|
+
first&.close
|
|
192
|
+
second&.close
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Isolated positive controls. No defect switch enters the benchmark CLI/runtime.
|
|
2
|
+
require_relative '../../../benchmarks/endpoints'
|
|
3
|
+
|
|
4
|
+
control = ARGV.fetch(0)
|
|
5
|
+
spec = if %w[query sqlite-clean].include?(control)
|
|
6
|
+
{ id: 'sqlite.page', dimensions: { rows: 128, page_size: 32 } }
|
|
7
|
+
else
|
|
8
|
+
{ id: 'api.public_get', dimensions: {} }
|
|
9
|
+
end
|
|
10
|
+
HaveAPI::Performance::Worker.define_singleton_method(:inventory) { |_profile| [spec] }
|
|
11
|
+
# These test-only fault controls use the actual resolved server bundle from
|
|
12
|
+
# ordinary RSpec CI. The production worker's root/component input contract is
|
|
13
|
+
# unchanged and is tested separately; it has no optional lock fallback.
|
|
14
|
+
HaveAPI::Performance::Worker.define_singleton_method(:inputs) do |source, _profile|
|
|
15
|
+
{ manifests: { 'servers/ruby/Gemfile' => Digest::SHA256.file(File.join(source, 'servers/ruby/Gemfile')).hexdigest },
|
|
16
|
+
locks: { 'servers/ruby/Gemfile.lock' => Digest::SHA256.file(File.join(HaveAPI::Performance::ROOT, 'servers/ruby/Gemfile.lock')).hexdigest } }
|
|
17
|
+
end
|
|
18
|
+
HaveAPI::Performance::Worker.singleton_class.prepend(Module.new do
|
|
19
|
+
define_method(:build) do |selected|
|
|
20
|
+
super(selected).tap do |scenario|
|
|
21
|
+
if control == 'query'
|
|
22
|
+
scenario.singleton_class.prepend(Module.new do
|
|
23
|
+
def execute_record
|
|
24
|
+
super
|
|
25
|
+
@base.connection.select_value('SELECT 1')
|
|
26
|
+
end
|
|
27
|
+
end)
|
|
28
|
+
elsif %w[allocation wrong-output].include?(control)
|
|
29
|
+
scenario.singleton_class.prepend(Module.new do
|
|
30
|
+
define_method(:call) do
|
|
31
|
+
Array.new(4000) { Object.new } if control == 'allocation'
|
|
32
|
+
response = super()
|
|
33
|
+
if control == 'wrong-output'
|
|
34
|
+
Rack::MockResponse.new(200, response.headers, response.body.sub('item-1', 'wrong-item'))
|
|
35
|
+
else
|
|
36
|
+
response
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end)
|
|
44
|
+
exit HaveAPI::Performance::CLI.new(['worker']).execute
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'digest'
|
|
3
|
+
require_relative '../../../benchmarks/performance_compare'
|
|
4
|
+
|
|
5
|
+
module PerformanceSamples
|
|
6
|
+
def self.fixture(profile: 'quick', zero: 0)
|
|
7
|
+
identity = HaveAPI::Performance::Identity
|
|
8
|
+
policy = HaveAPI::Performance::PROFILES.fetch(profile)
|
|
9
|
+
baseline_sha = profile == 'weekly' ? HaveAPI::Performance::REFERENCE : 'a' * 40
|
|
10
|
+
body = JSON.generate(status: true, response: { zero:, no: false, empty: '', null: nil })
|
|
11
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
12
|
+
output = { 'http_status' => 200, 'envelope_status' => true, 'body_bytes' => body.bytesize,
|
|
13
|
+
'body_sha256' => digest, 'canonical_sha256' => identity.digest(JSON.parse(body)), 'count' => 1 }
|
|
14
|
+
sample = { 'operations' => 1, 'wall_seconds' => 2.0, 'cpu_seconds' => 2.0, 'allocated_objects' => 100,
|
|
15
|
+
'outputs' => [output], 'bodies' => { digest => { 'body' => body, 'canonical_body' => JSON.parse(body) } },
|
|
16
|
+
'counters' => { 'operations' => 1, 'record' => { 'queries' => 0, 'authorizations' => 1, 'executions' => 1 } } }
|
|
17
|
+
spec = { 'id' => 'api.public_get', 'dimensions' => {} }
|
|
18
|
+
environment = { 'ruby' => { 'version' => 'controlled-detector-trace' }, 'gems' => { 'test' => 'controlled' } }
|
|
19
|
+
fixtures = { 'controlled' => 'f' * 64 }
|
|
20
|
+
preparation = HaveAPI::Performance::Release.json_preparation(HaveAPI::Performance::Release::JSON_GEMFILES.to_h { |path| [path, "source 'https://rubygems.org'\n"] })
|
|
21
|
+
inputs = { 'locks' => { 'Gemfile.lock' => 'a' * 64 }, 'manifests' => preparation.fetch('normalized_sha256'), 'json_preparation' => preparation }
|
|
22
|
+
sources = %w[baseline candidate].each_with_index.to_h do |role, index|
|
|
23
|
+
[role, { 'root' => "/controlled/#{role}", 'sha' => index == 0 ? baseline_sha : 'b' * 40,
|
|
24
|
+
'tree' => 'c' * 40, 'tracked_files' => 1, 'files_sha256' => { 'server.rb' => 'd' * 64 }.merge(preparation.fetch('raw_sha256')) }]
|
|
25
|
+
end
|
|
26
|
+
observations = sources.flat_map do |role, source|
|
|
27
|
+
%w[forward reverse].map do |order|
|
|
28
|
+
[order == 'forward' ? role : "#{role}-reverse",
|
|
29
|
+
{ 'source' => source.merge('loaded_sources' => { 'server.rb' => 'd' * 64 }),
|
|
30
|
+
'environment' => environment, 'fixture_sha256' => fixtures, 'inputs' => inputs,
|
|
31
|
+
'runtime_policy' => HaveAPI::Performance::RUNTIME_POLICY, 'workload_order' => order,
|
|
32
|
+
'host' => { 'hostname' => 'controlled', 'os' => 'controlled', 'cpu' => 'controlled' } }]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
observations = observations.to_h
|
|
36
|
+
pair = lambda do |index, self_pair|
|
|
37
|
+
{ 'order' => if self_pair
|
|
38
|
+
%w[baseline baseline]
|
|
39
|
+
else
|
|
40
|
+
(index.even? ? %w[baseline candidate] : %w[candidate baseline])
|
|
41
|
+
end,
|
|
42
|
+
'workload_order' => index.even? ? 'forward' : 'reverse',
|
|
43
|
+
'baseline' => sample.merge('observation' => index.even? ? 'baseline' : 'baseline-reverse'),
|
|
44
|
+
'candidate' => sample.merge('observation' => "#{self_pair ? 'baseline' : 'candidate'}#{'-reverse' unless index.even?}") }
|
|
45
|
+
end
|
|
46
|
+
budget = { 'schema_version' => 1, 'reference_sha' => HaveAPI::Performance::REFERENCE, 'environment' => { profile => environment, 'weekly' => environment },
|
|
47
|
+
'calibration' => { profile => { 'source_sha' => HaveAPI::Performance::REFERENCE, 'raw_sha256' => 'f' * 64,
|
|
48
|
+
'warmup_batches' => 2, 'iterations' => 1, 'fixture_sha256' => fixtures, 'inputs' => inputs } },
|
|
49
|
+
'profiles' => { profile => [spec.merge('reference_allocations' => 100, 'allocation_ceiling' => 157,
|
|
50
|
+
'counters' => sample['counters']['record'])] } }
|
|
51
|
+
budget_json = JSON.generate(budget)
|
|
52
|
+
samples = { 'schema_version' => HaveAPI::Performance::SAMPLE_SCHEMA, 'profile' => profile, 'baseline_sha' => baseline_sha, 'candidate_sha' => 'b' * 40,
|
|
53
|
+
'harness_sha256' => 'e' * 64, 'fixture_sha256' => fixtures,
|
|
54
|
+
'budget_sha256' => Digest::SHA256.hexdigest(budget_json), 'environment' => environment,
|
|
55
|
+
'inputs' => HaveAPI::Performance::Release.graph(inputs), 'sources' => sources, 'observations' => observations, 'inventory' => [spec],
|
|
56
|
+
'cases' => [spec.merge('iterations' => 1, 'self_pairs' => Array.new(policy[:self_pairs]) { |i| pair.call(i, true) },
|
|
57
|
+
'pairs' => Array.new(policy[:pairs]) { |i| pair.call(i, false) })] }
|
|
58
|
+
HaveAPI::Performance.complex_cases(profile).each do |complex|
|
|
59
|
+
samples['inventory'] << complex
|
|
60
|
+
entry = Marshal.load(Marshal.dump(samples['cases'].first.merge(complex)))
|
|
61
|
+
(entry['self_pairs'] + entry['pairs']).each do |pair|
|
|
62
|
+
%w[baseline candidate].each do |role|
|
|
63
|
+
record = pair[role]['counters']['record']
|
|
64
|
+
record['executions'] = 0
|
|
65
|
+
record['authorizations'] = (complex['dimensions']['resources'] * complex['dimensions']['actions']) + 1
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
samples['cases'] << entry
|
|
69
|
+
end
|
|
70
|
+
[JSON.parse(JSON.generate(samples)), budget_json]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
require 'haveapi'
|
|
2
|
+
|
|
3
|
+
module HaveAPI
|
|
4
|
+
# Shared by deterministic specs and the opt-in documentation benchmark.
|
|
5
|
+
module DocumentationPerformanceFixture
|
|
6
|
+
User = Struct.new(:login)
|
|
7
|
+
|
|
8
|
+
class BasicProvider < HaveAPI::Authentication::Basic::Provider
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
def find_user(_request, username, password)
|
|
12
|
+
User.new(username) if %w[admin member].include?(username) && password == 'fixture-only'
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.build(resource_count: 4, actions_per_resource: 2, samples_per_action: 1,
|
|
17
|
+
parameters_per_action: 2, rust: false)
|
|
18
|
+
dimensions = [resource_count, actions_per_resource, samples_per_action, parameters_per_action]
|
|
19
|
+
unless dimensions.all? { |value| value.is_a?(Integer) && value > 0 }
|
|
20
|
+
raise ArgumentError, 'Fixture dimensions must be positive integers'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
resources = Module.new
|
|
24
|
+
resource_count.times do |index|
|
|
25
|
+
resource = Class.new(HaveAPI::Resource)
|
|
26
|
+
resources.const_set("Resource#{index}", resource)
|
|
27
|
+
resource.resource_name = "Resource#{index}"
|
|
28
|
+
resource.version 1
|
|
29
|
+
resource.route "resources#{index}"
|
|
30
|
+
resource.desc "Synthetic resource #{index}"
|
|
31
|
+
actions_per_resource.times do |action_index|
|
|
32
|
+
add_action(resource, action_index, samples_per_action, parameters_per_action)
|
|
33
|
+
end
|
|
34
|
+
add_admin_action(resource) if index == 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
server = HaveAPI::Server.new(resources)
|
|
38
|
+
server.use_version(1)
|
|
39
|
+
server.default_version = 1
|
|
40
|
+
server.auth_chain << BasicProvider
|
|
41
|
+
if rust
|
|
42
|
+
server.documentation_clients(1, rust: { crate_name: 'documentation_performance_client' })
|
|
43
|
+
end
|
|
44
|
+
server.mount('/benchmark/api/')
|
|
45
|
+
server
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.add_action(resource, index, samples, parameters)
|
|
49
|
+
resource.define_action("Action#{index}") do
|
|
50
|
+
route "actions#{index}"
|
|
51
|
+
http_method :post
|
|
52
|
+
auth true
|
|
53
|
+
desc "Synthetic action #{index}"
|
|
54
|
+
input(:hash) do
|
|
55
|
+
parameters.times { |i| string :"value#{i}" }
|
|
56
|
+
custom :payload
|
|
57
|
+
bool :enabled
|
|
58
|
+
integer :count
|
|
59
|
+
string :nullable, nullable: true
|
|
60
|
+
string :admin_field, default: 'ADMIN_ONLY_DEFAULT'
|
|
61
|
+
end
|
|
62
|
+
output(:hash) do
|
|
63
|
+
string :marker
|
|
64
|
+
custom :payload
|
|
65
|
+
string :admin_field
|
|
66
|
+
end
|
|
67
|
+
authorize do |user|
|
|
68
|
+
if user && user.login != 'admin'
|
|
69
|
+
input blacklist: [:admin_field]
|
|
70
|
+
output blacklist: [:admin_field]
|
|
71
|
+
end
|
|
72
|
+
allow
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
samples.times do |sample_index|
|
|
76
|
+
example "Public sample #{sample_index}" do
|
|
77
|
+
request({ value0: '', enabled: false, count: 0, nullable: nil,
|
|
78
|
+
payload: { 'nested' => [{ 'values' => [0, false, '', nil] }] } })
|
|
79
|
+
response({ marker: "PUBLIC_SAMPLE_#{sample_index}", payload: { 'items' => [false, 0, nil, ''] } })
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
example 'Admin sample' do
|
|
83
|
+
authorize { |user| user.login == 'admin' }
|
|
84
|
+
request({ admin_field: 'ADMIN_ONLY_REQUEST' })
|
|
85
|
+
response({ admin_field: 'ADMIN_ONLY_RESPONSE' })
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
private_class_method :add_action
|
|
90
|
+
|
|
91
|
+
def self.add_admin_action(resource)
|
|
92
|
+
resource.define_action(:AdminOnly) do
|
|
93
|
+
route 'admin-only'
|
|
94
|
+
http_method :get
|
|
95
|
+
auth true
|
|
96
|
+
desc 'ADMIN_ONLY_ACTION'
|
|
97
|
+
output(:hash) { string :admin_result }
|
|
98
|
+
authorize { |user| user && user.login == 'admin' ? allow : deny }
|
|
99
|
+
example 'Admin action sample' do
|
|
100
|
+
response({ admin_result: 'ADMIN_ONLY_ACTION_SAMPLE' })
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
private_class_method :add_admin_action
|
|
105
|
+
end
|
|
106
|
+
end
|