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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/Gemfile +30 -0
  5. data/benchmarks/Gemfile.lock +296 -0
  6. data/benchmarks/documentation.rb +277 -0
  7. data/benchmarks/endpoints.rb +786 -0
  8. data/benchmarks/performance_budgets.json +1354 -0
  9. data/benchmarks/performance_compare.rb +678 -0
  10. data/benchmarks/shell.nix +21 -0
  11. data/doc/client-examples.md +179 -26
  12. data/doc/performance.md +310 -0
  13. data/haveapi.gemspec +1 -1
  14. data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
  15. data/lib/haveapi/documentation_clients.rb +49 -6
  16. data/lib/haveapi/documentation_context.rb +42 -12
  17. data/lib/haveapi/version.rb +1 -1
  18. data/lib/haveapi/views/version_page.erb +2 -2
  19. data/spec/client_examples/context_performance_spec.rb +72 -0
  20. data/spec/client_examples/context_spec.rb +206 -0
  21. data/spec/client_examples/profile_mount_spec.rb +45 -0
  22. data/spec/client_examples/ts_generated_client_spec.rb +53 -0
  23. data/spec/client_examples/typescript_profiles_spec.rb +113 -0
  24. data/spec/documentation/client_examples_spec.rb +2 -2
  25. data/spec/documentation/performance_spec.rb +79 -0
  26. data/spec/performance/comparator_spec.rb +472 -0
  27. data/spec/performance/endpoint_contract_spec.rb +226 -0
  28. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  29. data/spec/performance/event_baseline_spec.rb +407 -0
  30. data/spec/performance/json_activation_spec.rb +151 -0
  31. data/spec/performance/release_lineage_spec.rb +470 -0
  32. data/spec/performance/runner_spec.rb +610 -0
  33. data/spec/performance/sqlite_spec.rb +194 -0
  34. data/spec/performance/support/live_worker.rb +44 -0
  35. data/spec/performance/support/samples.rb +72 -0
  36. data/test_support/documentation_performance_fixture.rb +106 -0
  37. data/test_support/performance_endpoint_fixture.rb +350 -0
  38. data/test_support/performance_sqlite_fixture.rb +384 -0
  39. 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