haveapi 0.30.1 → 0.31.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 +1 -0
- data/Gemfile +1 -0
- data/benchmarks/Gemfile +30 -0
- data/benchmarks/Gemfile.lock +296 -0
- data/benchmarks/documentation.rb +277 -0
- data/benchmarks/endpoints.rb +786 -0
- data/benchmarks/performance_budgets.json +1354 -0
- data/benchmarks/performance_compare.rb +678 -0
- data/benchmarks/shell.nix +21 -0
- data/doc/client-examples.md +179 -26
- data/doc/performance.md +310 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
- data/lib/haveapi/documentation_clients.rb +49 -6
- data/lib/haveapi/documentation_context.rb +42 -12
- 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/profile_mount_spec.rb +45 -0
- data/spec/client_examples/ts_generated_client_spec.rb +53 -0
- data/spec/client_examples/typescript_profiles_spec.rb +113 -0
- data/spec/documentation/client_examples_spec.rb +2 -2
- data/spec/documentation/performance_spec.rb +79 -0
- data/spec/performance/comparator_spec.rb +472 -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 +407 -0
- data/spec/performance/json_activation_spec.rb +151 -0
- data/spec/performance/release_lineage_spec.rb +470 -0
- data/spec/performance/runner_spec.rb +610 -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 +29 -3
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require_relative '../../test_support/performance_endpoint_fixture'
|
|
3
|
+
|
|
4
|
+
RSpec.describe HaveAPI::PerformanceFixtures::Endpoints do
|
|
5
|
+
let(:scenarios) { [] }
|
|
6
|
+
|
|
7
|
+
def build(id = 'api.basic_get', **dimensions)
|
|
8
|
+
scenario = described_class.build(id:, dimensions:)
|
|
9
|
+
scenarios << scenario
|
|
10
|
+
scenario
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def headers(user = 'member')
|
|
14
|
+
{ 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT' => 'application/json',
|
|
15
|
+
'HTTP_AUTHORIZATION' => "Basic #{["#{user}:#{described_class::PASSWORD}"].pack('m0')}",
|
|
16
|
+
'CONTENT_TYPE' => 'application/json' }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def json_request(request, method, path, body = nil, user: 'member')
|
|
20
|
+
response = request.request(method, "/performance/v1/#{path}",
|
|
21
|
+
headers(user).merge(input: body.nil? ? '' : JSON.generate(body)))
|
|
22
|
+
[response, JSON.parse(response.body)]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
after do
|
|
26
|
+
scenarios.each(&:close)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'retains the seven quick cases and adds only the accepted weekly shapes' do
|
|
30
|
+
quick = described_class.cases
|
|
31
|
+
expect(quick.map { |item| item[:id] }).to eq(described_class::CASE_IDS)
|
|
32
|
+
expect(quick.last).to eq(id: 'api.list', dimensions: { rows: 32 })
|
|
33
|
+
expect(quick[0...-1].map { |item| item[:dimensions] }).to all(eq({}))
|
|
34
|
+
weekly = described_class.cases(profile: 'weekly')
|
|
35
|
+
expect(weekly.first(quick.length)).to eq(quick)
|
|
36
|
+
additions = [
|
|
37
|
+
{ id: 'api.list', dimensions: { rows: 1024, user: 'member' } },
|
|
38
|
+
{ id: 'api.list', dimensions: { rows: 1024, user: 'admin' } },
|
|
39
|
+
{ id: 'api.basic_get', dimensions: { user: 'admin' } }
|
|
40
|
+
]
|
|
41
|
+
expect(weekly.drop(quick.length)).to eq(additions)
|
|
42
|
+
expect(weekly.uniq).to eq(weekly)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'executes and validates every real quick-case response and counter record' do
|
|
46
|
+
described_class.cases.each do |item|
|
|
47
|
+
scenario = build(item[:id], **item[:dimensions])
|
|
48
|
+
scenario.reset!(iterations: 3)
|
|
49
|
+
responses = Array.new(3) { scenario.call }
|
|
50
|
+
responses.each_with_index do |response, index|
|
|
51
|
+
identity = scenario.validate!(response, index:)
|
|
52
|
+
expect(identity).to include(http_status: 200, body_bytes: response.body.bytesize,
|
|
53
|
+
body_sha256: Digest::SHA256.hexdigest(response.body))
|
|
54
|
+
expect(identity[:envelope_status]).to eq(item[:id] != 'api.invalid_post')
|
|
55
|
+
expect(identity[:canonical_body]).to eq(JSON.parse(response.body))
|
|
56
|
+
expect(identity[:canonical_sha256]).to eq(HaveAPI::Performance::Identity.digest(JSON.parse(response.body)))
|
|
57
|
+
end
|
|
58
|
+
executions = item[:id] == 'api.invalid_post' ? 0 : 1
|
|
59
|
+
expect(scenario.counters).to eq(operations: Array.new(3) { { queries: 0, authorizations: 1, executions: } })
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'validates the weekly large-list and administrator shapes' do
|
|
64
|
+
described_class.cases(profile: 'weekly').drop(described_class::CASE_IDS.length).each do |item|
|
|
65
|
+
scenario = build(item[:id], **item[:dimensions])
|
|
66
|
+
scenario.reset!(iterations: 1)
|
|
67
|
+
identity = scenario.validate!(scenario.call, index: 0)
|
|
68
|
+
output = identity.fetch(:canonical_body).fetch('response').fetch('probe')
|
|
69
|
+
records = item[:id] == 'api.list' ? output : [output]
|
|
70
|
+
expect(records.length).to eq(item[:dimensions].fetch(:rows, 1))
|
|
71
|
+
records.each do |record|
|
|
72
|
+
expect(record.has_key?('secret')).to eq(item[:dimensions][:user] == 'admin')
|
|
73
|
+
end
|
|
74
|
+
expect(scenario.counters).to eq(operations: [{ queries: 0, authorizations: 1, executions: 1 }])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'rejects a corrupted early response even when the final response is valid' do
|
|
79
|
+
scenario = build
|
|
80
|
+
scenario.reset!(iterations: 2)
|
|
81
|
+
first = scenario.call
|
|
82
|
+
last = scenario.call
|
|
83
|
+
body = JSON.parse(first.body)
|
|
84
|
+
body['response']['probe']['count'] = '0'
|
|
85
|
+
broken = Rack::MockResponse.new(200, first.headers, JSON.generate(body))
|
|
86
|
+
|
|
87
|
+
expect { scenario.validate!(last, index: 1) }.not_to raise_error
|
|
88
|
+
expect { scenario.validate!(broken, index: 0) }.to raise_error(/Unexpected output/)
|
|
89
|
+
expect { scenario.validate!(Rack::MockResponse.new(403, {}, '{}'), index: 0) }
|
|
90
|
+
.to raise_error(/HTTP status/)
|
|
91
|
+
duplicate = Rack::MockResponse.new(200, first.headers, first.body.sub('"status":true', '"status":false,"status":true'))
|
|
92
|
+
expect { scenario.validate!(duplicate, index: 0) }.to raise_error(HaveAPI::Performance::ContractError, /duplicate/i)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'preserves typed zero, false, empty, null, nested Custom data and omission' do
|
|
96
|
+
scenario = build('api.typed_post')
|
|
97
|
+
scenario.reset!(iterations: 1)
|
|
98
|
+
response = scenario.call
|
|
99
|
+
scenario.validate!(response, index: 0)
|
|
100
|
+
output = JSON.parse(response.body).fetch('response').fetch('probe')
|
|
101
|
+
|
|
102
|
+
expect(output).to include('count' => 0, 'ratio' => 1.25, 'enabled' => false, 'label' => '', 'nullable' => nil,
|
|
103
|
+
'at' => '2020-01-01T00:00:00+00:00', 'payload' => { 'nested' => [0, false, '', nil] })
|
|
104
|
+
expect(output).not_to have_key('omitted')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'uses distinct Basic and token providers with real credential failures' do
|
|
108
|
+
basic = build('api.basic_get')
|
|
109
|
+
token = build('api.token_get')
|
|
110
|
+
[basic, token].each { |scenario| scenario.reset!(iterations: 1) }
|
|
111
|
+
expect(basic.validate!(basic.call, index: 0)).to eq(token.validate!(token.call, index: 0))
|
|
112
|
+
request = Rack::MockRequest.new(basic.app)
|
|
113
|
+
missing = request.get('/performance/v1/probes/secure', 'HTTP_HOST' => 'example.test')
|
|
114
|
+
bad_basic = request.get('/performance/v1/probes/secure', headers.merge('HTTP_AUTHORIZATION' => 'Basic invalid'))
|
|
115
|
+
bad_token = request.get('/performance/v1/probes/secure', headers.merge('HTTP_AUTHORIZATION' => '',
|
|
116
|
+
'HTTP_X_HAVEAPI_AUTH_TOKEN' => 'invalid'))
|
|
117
|
+
expect([missing.status, bad_basic.status, bad_token.status]).to eq([401, 401, 401])
|
|
118
|
+
[missing, bad_basic, bad_token].each { |response| expect(JSON.parse(response.body)['status']).to be(false) }
|
|
119
|
+
denied, body = json_request(request, 'GET', 'probes/admin')
|
|
120
|
+
expect(denied.status).to eq(403)
|
|
121
|
+
expect(body.fetch('status')).to be(false)
|
|
122
|
+
allowed, body = json_request(request, 'GET', 'probes/admin', user: 'admin')
|
|
123
|
+
expect(allowed.status).to eq(200)
|
|
124
|
+
expect(body.dig('response', 'probe', 'secret')).to eq('ADMIN_ONLY_VALUE')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'executes real CRUD, isolates stores and resets writes outside measured requests' do
|
|
128
|
+
scenario = build('api.list', rows: 1)
|
|
129
|
+
other = build('api.list', rows: 1)
|
|
130
|
+
scenario.reset!(iterations: 1)
|
|
131
|
+
other.reset!(iterations: 1)
|
|
132
|
+
request = Rack::MockRequest.new(scenario.app)
|
|
133
|
+
created, body = json_request(request, 'POST', 'items', { probe: { label: 'created' } })
|
|
134
|
+
expect(created.status).to eq(200)
|
|
135
|
+
expect(body.fetch('status')).to be(true)
|
|
136
|
+
expect(body.dig('response', 'probe')).to include('id' => 2, 'label' => 'created')
|
|
137
|
+
_, body = json_request(request, 'GET', 'items/2')
|
|
138
|
+
expect(body.dig('response', 'probe', 'label')).to eq('created')
|
|
139
|
+
_, body = json_request(request, 'PUT', 'items/2', { probe: { label: 'updated' } })
|
|
140
|
+
expect(body.dig('response', 'probe', 'label')).to eq('updated')
|
|
141
|
+
_, body = json_request(request, 'DELETE', 'items/2')
|
|
142
|
+
expect(body.dig('response', 'probe', 'label')).to eq('updated')
|
|
143
|
+
missing, body = json_request(request, 'GET', 'items/2')
|
|
144
|
+
expect(missing.status).to eq(404)
|
|
145
|
+
expect(body.fetch('status')).to be(false)
|
|
146
|
+
json_request(request, 'POST', 'items', { probe: { label: 'discarded by reset' } })
|
|
147
|
+
expect(other.validate!(other.call, index: 0)[:envelope_status]).to be(true)
|
|
148
|
+
scenario.reset!(iterations: 1)
|
|
149
|
+
expect(scenario.validate!(scenario.call, index: 0)[:envelope_status]).to be(true)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it 'does not mutate timed counter records through direct app requests' do
|
|
153
|
+
scenario = build
|
|
154
|
+
scenario.reset!(iterations: 1)
|
|
155
|
+
response = scenario.call
|
|
156
|
+
before = Marshal.dump(scenario.counters)
|
|
157
|
+
request = Rack::MockRequest.new(scenario.app)
|
|
158
|
+
json_request(request, 'GET', 'probes/secure')
|
|
159
|
+
json_request(request, 'POST', 'items', { probe: { label: 'untimed' } })
|
|
160
|
+
|
|
161
|
+
expect(Marshal.dump(scenario.counters)).to eq(before)
|
|
162
|
+
expect { scenario.validate!(response, index: 0) }.not_to raise_error
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'rejects malformed bodies, missing routes and invalid typed input through real middleware' do
|
|
166
|
+
scenario = build
|
|
167
|
+
request = Rack::MockRequest.new(scenario.app)
|
|
168
|
+
['{', '[]', 'null'].each do |input|
|
|
169
|
+
response = request.post('/performance/v1/probes/typed', headers.merge(input:))
|
|
170
|
+
expect(response.status).to eq(400)
|
|
171
|
+
expect(JSON.parse(response.body)['status']).to be(false)
|
|
172
|
+
end
|
|
173
|
+
response = request.get('/performance/v1/not-a-route', headers)
|
|
174
|
+
expect(response.status).to eq(404)
|
|
175
|
+
expect(JSON.parse(response.body)['status']).to be(false)
|
|
176
|
+
response = request.post('/performance/v1/probes/typed', headers.merge(input: '{"probe":{"count":"wrong"}}'))
|
|
177
|
+
expect(response.status).to eq(200)
|
|
178
|
+
expect(JSON.parse(response.body).fetch('errors')).to have_key('count')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'alternates privileged and restricted users without exposing hidden fields' do
|
|
182
|
+
scenario = build
|
|
183
|
+
request = Rack::MockRequest.new(scenario.app)
|
|
184
|
+
%w[admin member admin].each do |user|
|
|
185
|
+
_, body = json_request(request, 'GET', 'probes/secure', user:)
|
|
186
|
+
output = body.fetch('response').fetch('probe')
|
|
187
|
+
if user == 'admin'
|
|
188
|
+
expect(output.fetch('secret')).to eq('ADMIN_ONLY_VALUE')
|
|
189
|
+
else
|
|
190
|
+
expect(output).not_to have_key('secret')
|
|
191
|
+
expect(JSON.generate(body)).not_to include('ADMIN_ONLY_VALUE')
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'requires reset and every operation record, and rejects overflow and damaged counters' do
|
|
197
|
+
scenario = build
|
|
198
|
+
expect { scenario.call }.to raise_error(/reset!/)
|
|
199
|
+
expect { scenario.counters }.to raise_error(/Missing operation/)
|
|
200
|
+
scenario.reset!(iterations: 2)
|
|
201
|
+
first = scenario.call
|
|
202
|
+
expect { scenario.counters }.to raise_error(/Missing operation/)
|
|
203
|
+
scenario.call
|
|
204
|
+
expect { scenario.call }.to raise_error(/More calls/)
|
|
205
|
+
expect { scenario.validate!(first, index: -1) }.to raise_error(ArgumentError)
|
|
206
|
+
expect { scenario.validate!(first, index: 2) }.to raise_error(/not called/)
|
|
207
|
+
scenario.counters[:operations].first[:queries] = 1
|
|
208
|
+
expect { scenario.validate!(first, index: 0) }.to raise_error(/counts/)
|
|
209
|
+
expect { scenario.counters }.to raise_error(/counts/)
|
|
210
|
+
scenario.reset!(iterations: 1)
|
|
211
|
+
scenario.validate!(scenario.call, index: 0)
|
|
212
|
+
scenario.close
|
|
213
|
+
expect { scenario.call }.to raise_error(/closed/)
|
|
214
|
+
expect { scenario.reset!(iterations: 1) }.to raise_error(/closed/)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'fails closed on unknown cases, profiles, dimensions, users and invalid batch sizes' do
|
|
218
|
+
expect { described_class.cases(profile: 'other') }.to raise_error(ArgumentError)
|
|
219
|
+
expect { build('api.unknown') }.to raise_error(ArgumentError)
|
|
220
|
+
expect { build('api.list', rows: 0) }.to raise_error(ArgumentError)
|
|
221
|
+
expect { build('api.list', rows: '32') }.to raise_error(ArgumentError)
|
|
222
|
+
expect { build('api.list', user: 'unknown') }.to raise_error(ArgumentError)
|
|
223
|
+
expect { build('api.list', accidental: true) }.to raise_error(ArgumentError)
|
|
224
|
+
expect { build.reset!(iterations: 0) }.to raise_error(ArgumentError)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require_relative '../../test_support/performance_endpoint_fixture'
|
|
3
|
+
|
|
4
|
+
RSpec.describe HaveAPI::PerformanceFixtures::Endpoints, '.build' do
|
|
5
|
+
def run_list(rows)
|
|
6
|
+
scenario = described_class.build(id: 'api.list', dimensions: { rows: })
|
|
7
|
+
scenario.reset!(iterations: 2)
|
|
8
|
+
responses = Array.new(2) { scenario.call }
|
|
9
|
+
identities = responses.each_with_index.map { |response, index| scenario.validate!(response, index:) }
|
|
10
|
+
[identities, scenario.counters]
|
|
11
|
+
ensure
|
|
12
|
+
scenario&.close
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def allocated_per_request(rows)
|
|
16
|
+
scenario = described_class.build(id: 'api.list', dimensions: { rows: })
|
|
17
|
+
2.times do
|
|
18
|
+
scenario.reset!(iterations: 10)
|
|
19
|
+
10.times { scenario.call }
|
|
20
|
+
end
|
|
21
|
+
Array.new(3) do
|
|
22
|
+
scenario.reset!(iterations: 10)
|
|
23
|
+
responses = Array.new(10)
|
|
24
|
+
GC.start
|
|
25
|
+
allocated = GC.stat(:total_allocated_objects)
|
|
26
|
+
index = 0
|
|
27
|
+
while index < responses.length
|
|
28
|
+
responses[index] = scenario.call
|
|
29
|
+
index += 1
|
|
30
|
+
end
|
|
31
|
+
delta = GC.stat(:total_allocated_objects) - allocated
|
|
32
|
+
responses.each_with_index { |response, operation| scenario.validate!(response, index: operation) }
|
|
33
|
+
scenario.counters
|
|
34
|
+
delta / 10.0
|
|
35
|
+
end.min
|
|
36
|
+
ensure
|
|
37
|
+
scenario&.close
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'serializes complete ordered lists at sizes 1, 32 and 128 with fixed action work' do
|
|
41
|
+
bytes = [1, 32, 128].map do |rows|
|
|
42
|
+
identities, counters = run_list(rows)
|
|
43
|
+
expect(identities.first).to eq(identities.last)
|
|
44
|
+
output = identities.first[:canonical_body].fetch('response').fetch('probe')
|
|
45
|
+
expect(output.length).to eq(rows)
|
|
46
|
+
expect(output.map { |row| row.fetch('id') }).to eq((1..rows).to_a)
|
|
47
|
+
expect(output.last.fetch('label')).to eq("item-#{rows}")
|
|
48
|
+
output.each { |row| expect(row).not_to have_key('secret') }
|
|
49
|
+
expect(counters[:operations]).to eq(Array.new(2) { { queries: 0, authorizations: 1, executions: 1 } })
|
|
50
|
+
identities.first[:body_bytes]
|
|
51
|
+
end
|
|
52
|
+
expect(bytes.each_cons(2)).to all(satisfy { |small, large| small < large })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'keeps output and counters independent across reset batch sizes' do
|
|
56
|
+
scenario = described_class.build(id: 'api.list', dimensions: { rows: 32 })
|
|
57
|
+
scenario.reset!(iterations: 1)
|
|
58
|
+
first = scenario.validate!(scenario.call, index: 0)
|
|
59
|
+
records = scenario.counters[:operations]
|
|
60
|
+
scenario.reset!(iterations: 3)
|
|
61
|
+
responses = Array.new(3) { scenario.call }
|
|
62
|
+
responses.each_with_index do |response, index|
|
|
63
|
+
expect(scenario.validate!(response, index:)).to eq(first)
|
|
64
|
+
end
|
|
65
|
+
expect(scenario.counters[:operations].length).to eq(3)
|
|
66
|
+
expect(scenario.counters[:operations].first).not_to equal(records.first)
|
|
67
|
+
expect(scenario.counters[:operations].map(&:object_id).uniq.length).to eq(3)
|
|
68
|
+
ensure
|
|
69
|
+
scenario&.close
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'bounds allocation growth by serialized rows rather than their square' do
|
|
73
|
+
small, medium, large = [1, 32, 128].map { |rows| allocated_per_request(rows) }
|
|
74
|
+
|
|
75
|
+
# Reference Ruby 3.4.11: 640/795/1275 objects per request. Output work
|
|
76
|
+
# grows with rows; the 96-row increment has a 4x allowance relative to
|
|
77
|
+
# the 31-row increment, plus 32 objects of fixed VM bookkeeping slack.
|
|
78
|
+
expect(medium).to be >= small
|
|
79
|
+
expect(large).to be > medium
|
|
80
|
+
expect(large - medium).to be <= (4 * (medium - small)) + 32
|
|
81
|
+
end
|
|
82
|
+
end
|