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,350 @@
|
|
|
1
|
+
require 'haveapi'
|
|
2
|
+
require 'rack/mock'
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../benchmarks/performance_compare'
|
|
5
|
+
|
|
6
|
+
module HaveAPI
|
|
7
|
+
module PerformanceFixtures
|
|
8
|
+
# This file must use the caller-selected HaveAPI, including in baseline workers.
|
|
9
|
+
module Endpoints
|
|
10
|
+
User = Struct.new(:login)
|
|
11
|
+
PASSWORD = 'performance-fixture-only'.freeze
|
|
12
|
+
CASE_IDS = %w[api.public_get api.basic_get api.token_get api.typed_post api.invalid_post api.nested_get api.list].freeze
|
|
13
|
+
|
|
14
|
+
def self.cases(profile: 'quick')
|
|
15
|
+
raise ArgumentError, "Unknown profile: #{profile}" unless %w[quick weekly].include?(profile)
|
|
16
|
+
|
|
17
|
+
quick = CASE_IDS.map { |id| { id:, dimensions: id == 'api.list' ? { rows: 32 } : {} } }
|
|
18
|
+
return quick if profile == 'quick'
|
|
19
|
+
|
|
20
|
+
quick + [
|
|
21
|
+
{ id: 'api.list', dimensions: { rows: 1024, user: 'member' } },
|
|
22
|
+
{ id: 'api.list', dimensions: { rows: 1024, user: 'admin' } },
|
|
23
|
+
{ id: 'api.basic_get', dimensions: { user: 'admin' } }
|
|
24
|
+
]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.build(case_spec)
|
|
28
|
+
Scenario.new(case_spec)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class State
|
|
32
|
+
attr_reader :items, :operations
|
|
33
|
+
attr_accessor :operation
|
|
34
|
+
|
|
35
|
+
def reset!(rows:, iterations:)
|
|
36
|
+
@items = Array.new(rows) { |index| record(index + 1) }
|
|
37
|
+
@operations = Array.new(iterations) { { queries: 0, authorizations: 0, executions: 0 } }
|
|
38
|
+
@operation = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def record(id)
|
|
42
|
+
{ id:, label: "item-#{id}", enabled: false, count: 0, nullable: nil,
|
|
43
|
+
payload: { 'nested' => [0, false, '', nil] }, secret: 'ADMIN_ONLY_VALUE' }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def increment(kind)
|
|
47
|
+
@operations.fetch(@operation)[kind] += 1 unless @operation.nil?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class Scenario
|
|
52
|
+
attr_reader :app
|
|
53
|
+
|
|
54
|
+
def initialize(case_spec)
|
|
55
|
+
@id = case_spec.fetch(:id)
|
|
56
|
+
@dimensions = case_spec.fetch(:dimensions)
|
|
57
|
+
raise ArgumentError, "Unknown endpoint case: #{@id}" unless CASE_IDS.include?(@id)
|
|
58
|
+
raise ArgumentError, 'Unknown endpoint dimension' unless (@dimensions.keys - %i[rows user]).empty?
|
|
59
|
+
|
|
60
|
+
@rows = @dimensions.fetch(:rows, 1)
|
|
61
|
+
@user = @dimensions.fetch(:user, 'member')
|
|
62
|
+
raise ArgumentError, 'rows must be a positive integer' unless @rows.is_a?(Integer) && @rows > 0
|
|
63
|
+
raise ArgumentError, 'Unknown user' unless %w[member admin].include?(@user)
|
|
64
|
+
|
|
65
|
+
@state = State.new
|
|
66
|
+
@state.reset!(rows: @rows, iterations: 0)
|
|
67
|
+
@app = build_server.app
|
|
68
|
+
@request = Rack::MockRequest.new(@app)
|
|
69
|
+
@method, @path, @options = request_arguments
|
|
70
|
+
@closed = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reset!(iterations:)
|
|
74
|
+
raise 'Scenario is closed' if @closed
|
|
75
|
+
unless iterations.is_a?(Integer) && iterations > 0
|
|
76
|
+
raise ArgumentError, 'iterations must be a positive integer'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@state.reset!(rows: @rows, iterations:)
|
|
80
|
+
@next_operation = 0
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def call
|
|
85
|
+
raise 'Scenario is closed' if @closed
|
|
86
|
+
raise 'reset! must precede call' if @next_operation.nil?
|
|
87
|
+
raise 'More calls than reset iterations' if @next_operation >= @state.operations.length
|
|
88
|
+
|
|
89
|
+
@state.operation = @next_operation
|
|
90
|
+
@next_operation += 1
|
|
91
|
+
@request.request(@method, @path, @options)
|
|
92
|
+
ensure
|
|
93
|
+
@state.operation = nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate!(response, index:)
|
|
97
|
+
raise ArgumentError, 'Invalid operation index' unless index.is_a?(Integer) && index >= 0
|
|
98
|
+
raise 'Operation was not called' if @next_operation.nil? || index >= @next_operation
|
|
99
|
+
raise 'Unexpected HTTP status' unless response.status == 200
|
|
100
|
+
raise 'Unexpected content type' unless response['content-type']&.start_with?('application/json')
|
|
101
|
+
|
|
102
|
+
validate_counts!(@state.operations.fetch(index))
|
|
103
|
+
# Only the owning measurement may reuse body-only work. Index,
|
|
104
|
+
# response metadata and execution/authorization checks always run.
|
|
105
|
+
return yield(-> { validate_body!(response) }) if block_given?
|
|
106
|
+
|
|
107
|
+
validate_body!(response)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_body!(response)
|
|
111
|
+
identity = HaveAPI::Performance::Identity.json(response)
|
|
112
|
+
body = identity.fetch(:canonical_body)
|
|
113
|
+
unless body.keys.sort == %w[errors message response status]
|
|
114
|
+
raise 'Unexpected envelope shape'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
valid = @id != 'api.invalid_post'
|
|
118
|
+
raise 'Unexpected envelope status' unless body.fetch('status') == valid
|
|
119
|
+
|
|
120
|
+
if valid
|
|
121
|
+
expected = expected_response
|
|
122
|
+
raise "Unexpected output for #{@id}: #{body.inspect}" unless body.fetch('response') == expected
|
|
123
|
+
raise 'Unexpected success errors' unless body['errors'].nil? && body['message'].nil?
|
|
124
|
+
else
|
|
125
|
+
raise 'Invalid request unexpectedly returned data' unless body.fetch('response').nil?
|
|
126
|
+
unless body.fetch('errors').keys == ['count'] && body.fetch('errors').fetch('count').any? &&
|
|
127
|
+
body.fetch('message').is_a?(String) && !body.fetch('message').empty?
|
|
128
|
+
raise 'Missing typed validation errors'
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
identity
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def counters
|
|
136
|
+
unless @next_operation && @next_operation == @state.operations.length
|
|
137
|
+
raise 'Missing operation counter records'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
@state.operations.each { |operation| validate_counts!(operation) }
|
|
141
|
+
{ operations: @state.operations }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def close
|
|
145
|
+
@closed = true
|
|
146
|
+
@state.operation = nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def validate_counts!(counts)
|
|
152
|
+
expected = { queries: 0, authorizations: 1, executions: @id == 'api.invalid_post' ? 0 : 1 }
|
|
153
|
+
raise "Unexpected operation counts: #{counts.inspect}" unless counts == expected
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def request_arguments
|
|
157
|
+
headers = { 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT' => 'application/json' }
|
|
158
|
+
unless @id == 'api.public_get'
|
|
159
|
+
headers['HTTP_AUTHORIZATION'] = "Basic #{["#{@user}:#{PASSWORD}"].pack('m0')}"
|
|
160
|
+
end
|
|
161
|
+
case @id
|
|
162
|
+
when 'api.public_get' then ['GET', '/performance/v1/probes/public', headers]
|
|
163
|
+
when 'api.basic_get' then ['GET', '/performance/v1/probes/secure', headers]
|
|
164
|
+
when 'api.token_get'
|
|
165
|
+
headers.delete('HTTP_AUTHORIZATION')
|
|
166
|
+
headers['HTTP_X_HAVEAPI_AUTH_TOKEN'] = "fixture-token-#{@user}"
|
|
167
|
+
['GET', '/performance/v1/probes/secure', headers]
|
|
168
|
+
when 'api.typed_post', 'api.invalid_post'
|
|
169
|
+
value = @id == 'api.invalid_post' ? 'not-an-integer' : '0'
|
|
170
|
+
body = { probe: { count: value, ratio: '1.25', enabled: false, label: '', nullable: nil,
|
|
171
|
+
at: '2020-01-01T00:00:00Z', payload: { 'nested' => [0, false, '', nil] } } }
|
|
172
|
+
['POST', '/performance/v1/probes/typed', headers.merge('CONTENT_TYPE' => 'application/json',
|
|
173
|
+
input: JSON.generate(body))]
|
|
174
|
+
when 'api.nested_get' then ['GET', '/performance/v1/parents/7/children/9', headers]
|
|
175
|
+
when 'api.list' then ['GET', '/performance/v1/items', headers]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def expected_response
|
|
180
|
+
value = case @id
|
|
181
|
+
when 'api.typed_post'
|
|
182
|
+
{ 'count' => 0, 'ratio' => 1.25, 'enabled' => false, 'label' => '', 'nullable' => nil,
|
|
183
|
+
'at' => '2020-01-01T00:00:00+00:00', 'payload' => { 'nested' => [0, false, '', nil] } }
|
|
184
|
+
when 'api.nested_get' then { 'parent_id' => 7, 'child_id' => 9 }
|
|
185
|
+
when 'api.list' then Array.new(@rows) { |index| visible_record(index + 1) }
|
|
186
|
+
else visible_record(1)
|
|
187
|
+
end
|
|
188
|
+
{ 'probe' => value, '_meta' => {} }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def visible_record(id)
|
|
192
|
+
value = @state.record(id).transform_keys(&:to_s)
|
|
193
|
+
value.delete('secret') unless @user == 'admin' && @id != 'api.public_get'
|
|
194
|
+
value
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def build_server
|
|
198
|
+
state = @state
|
|
199
|
+
resources = Module.new
|
|
200
|
+
probe = resources.const_set(:Probe, Class.new(HaveAPI::Resource))
|
|
201
|
+
probe.resource_name = 'Probe'
|
|
202
|
+
probe.version 1
|
|
203
|
+
%i[Public Secure].each do |name|
|
|
204
|
+
define_action(probe, name, state:, route: name == :Public ? 'public' : 'secure', auth: name != :Public) do
|
|
205
|
+
output(:hash, namespace: :probe) { Endpoints.record_parameters(self) }
|
|
206
|
+
define_method(:exec) do
|
|
207
|
+
state.increment(:executions)
|
|
208
|
+
state.record(1)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
define_action(probe, :AdminOnly, state:, route: 'admin') do
|
|
213
|
+
authorize do |user|
|
|
214
|
+
state.increment(:authorizations)
|
|
215
|
+
user && user.login == 'admin' ? allow : deny
|
|
216
|
+
end
|
|
217
|
+
output(:hash, namespace: :probe) { Endpoints.record_parameters(self) }
|
|
218
|
+
define_method(:exec) do
|
|
219
|
+
state.increment(:executions)
|
|
220
|
+
state.record(1)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
define_action(probe, :Typed, state:, route: 'typed', method: :post) do
|
|
224
|
+
input(:hash, namespace: :probe) { Endpoints.typed_parameters(self, required: true) }
|
|
225
|
+
output(:hash, namespace: :probe) { Endpoints.typed_parameters(self) }
|
|
226
|
+
define_method(:exec) do
|
|
227
|
+
state.increment(:executions)
|
|
228
|
+
input
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
add_items(resources, state)
|
|
232
|
+
parent = resources.const_set(:Parent, Class.new(HaveAPI::Resource))
|
|
233
|
+
parent.resource_name = 'Parent'
|
|
234
|
+
parent.version 1
|
|
235
|
+
child = parent.define_resource(:Child) { route '{parent_id}/children' }
|
|
236
|
+
define_action(child, :Show, state:, route: '{child_id}') do
|
|
237
|
+
output(:hash, namespace: :probe) do
|
|
238
|
+
integer :parent_id
|
|
239
|
+
integer :child_id
|
|
240
|
+
end
|
|
241
|
+
define_method(:exec) do
|
|
242
|
+
state.increment(:executions)
|
|
243
|
+
{ parent_id: Integer(path_params.fetch('parent_id')), child_id: Integer(path_params.fetch('child_id')) }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
server = HaveAPI::Server.new(resources)
|
|
247
|
+
server.use_version(1)
|
|
248
|
+
server.default_version = 1
|
|
249
|
+
add_authentication(server)
|
|
250
|
+
server.mount('/performance/')
|
|
251
|
+
server
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def define_action(resource, name, state:, route:, method: :get, auth: true, &block)
|
|
255
|
+
resource.define_action(name) do
|
|
256
|
+
route(route)
|
|
257
|
+
http_method method
|
|
258
|
+
auth(auth)
|
|
259
|
+
authorize do |user|
|
|
260
|
+
state.increment(:authorizations)
|
|
261
|
+
output blacklist: [:secret] unless user && user.login == 'admin'
|
|
262
|
+
allow
|
|
263
|
+
end
|
|
264
|
+
class_exec(&block)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def add_items(resources, state)
|
|
269
|
+
resource = resources.const_set(:Item, Class.new(HaveAPI::Resource))
|
|
270
|
+
resource.resource_name = 'Item'
|
|
271
|
+
resource.version 1
|
|
272
|
+
define_action(resource, :Index, state:, route: '') do
|
|
273
|
+
output(:hash_list, namespace: :probe) { Endpoints.record_parameters(self) }
|
|
274
|
+
define_method(:exec) do
|
|
275
|
+
state.increment(:executions)
|
|
276
|
+
state.items
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
define_action(resource, :Create, state:, route: '', method: :post) do
|
|
280
|
+
input(:hash, namespace: :probe) { string :label, required: true }
|
|
281
|
+
output(:hash, namespace: :probe) { Endpoints.record_parameters(self) }
|
|
282
|
+
define_method(:exec) do
|
|
283
|
+
state.increment(:executions)
|
|
284
|
+
item = state.record((state.items.map { |row| row[:id] }.max || 0) + 1).merge(label: input[:label])
|
|
285
|
+
state.items << item
|
|
286
|
+
item
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
%i[Show Update Delete].each do |name|
|
|
290
|
+
method = { Show: :get, Update: :put, Delete: :delete }.fetch(name)
|
|
291
|
+
define_action(resource, name, state:, route: '{item_id}', method:) do
|
|
292
|
+
input(:hash, namespace: :probe) { string :label, required: true } if name == :Update
|
|
293
|
+
output(:hash, namespace: :probe) { Endpoints.record_parameters(self) }
|
|
294
|
+
define_method(:exec) do
|
|
295
|
+
state.increment(:executions)
|
|
296
|
+
item = state.items.find { |row| row[:id] == Integer(path_params.fetch('item_id')) }
|
|
297
|
+
error!('Item not found', {}, http_status: 404) unless item
|
|
298
|
+
item[:label] = input[:label] if name == :Update
|
|
299
|
+
state.items.delete(item) if name == :Delete
|
|
300
|
+
item
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def add_authentication(server)
|
|
307
|
+
users = %w[member admin].to_h { |name| [name, User.new(name).freeze] }
|
|
308
|
+
basic = Class.new(HaveAPI::Authentication::Basic::Provider) do
|
|
309
|
+
define_method(:find_user) do |_request, username, password|
|
|
310
|
+
users[username] if password == PASSWORD
|
|
311
|
+
end
|
|
312
|
+
protected :find_user
|
|
313
|
+
end
|
|
314
|
+
token = Class.new(HaveAPI::Authentication::Token::Config) do
|
|
315
|
+
define_method(:find_user_by_token) do |_request, value|
|
|
316
|
+
users.find { |name, _user| value == "fixture-token-#{name}" }&.last
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
server.auth_chain << basic
|
|
320
|
+
server.auth_chain << HaveAPI::Authentication::Token.with_config(token)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def self.record_parameters(params)
|
|
325
|
+
params.instance_eval do
|
|
326
|
+
integer :id
|
|
327
|
+
string :label
|
|
328
|
+
bool :enabled
|
|
329
|
+
integer :count
|
|
330
|
+
string :nullable, nullable: true
|
|
331
|
+
custom :payload
|
|
332
|
+
string :secret
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def self.typed_parameters(params, required: false)
|
|
337
|
+
params.instance_eval do
|
|
338
|
+
integer :count, required: required
|
|
339
|
+
float :ratio, required: required
|
|
340
|
+
bool :enabled, required: required
|
|
341
|
+
string :label
|
|
342
|
+
string :nullable, nullable: true
|
|
343
|
+
datetime :at, required: required
|
|
344
|
+
custom :payload, required: required
|
|
345
|
+
string :omitted
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|