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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c34e4857df0b82609b7338ba24a522c078d52e16dbe555b10bd32f645888de6a
|
|
4
|
+
data.tar.gz: e1bfd4862f8c2b2bf09dbd71ec280c72141585cc94453df8072d467b2172f27f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b225959d6032698804ba18f6136c2a8710afaa562db827d56022a9eca6d98af1b55e5b723c2dfa280f04ae2b3ae228ac9dea7cd0e413ec3459d979fbcbcf245
|
|
7
|
+
data.tar.gz: 0a651d2ab26a7ae92f82666561a05e2b812719e37cfe55dcc86f96942c969073d24b59e3ca906be6cd1ed46c732763fddc85190090a630bf44b8f73d30eda928
|
data/.gitignore
CHANGED
data/Gemfile
CHANGED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
require 'base64'
|
|
2
|
+
require 'digest'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'optparse'
|
|
6
|
+
require 'rbconfig'
|
|
7
|
+
require 'socket'
|
|
8
|
+
|
|
9
|
+
module HaveAPI
|
|
10
|
+
# Opt-in measurements; deterministic performance assertions live in the specs.
|
|
11
|
+
class DocumentationBenchmark
|
|
12
|
+
ROOT = File.expand_path('../../..', __dir__)
|
|
13
|
+
FIXTURE = File.expand_path('../test_support/documentation_performance_fixture.rb', __dir__)
|
|
14
|
+
URL = 'http://example.test/benchmark/api/v1/'.freeze
|
|
15
|
+
METRICS = %i[wall_seconds cpu_seconds allocated_objects].freeze
|
|
16
|
+
|
|
17
|
+
class InvalidOutput < StandardError
|
|
18
|
+
attr_reader :output
|
|
19
|
+
|
|
20
|
+
def initialize(message, output)
|
|
21
|
+
@output = output
|
|
22
|
+
super(message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(argv)
|
|
27
|
+
@options = { shape: 'both', size: 8, resources: 8, actions: 1, samples: 1, parameters: 2,
|
|
28
|
+
warmup: 1, trials: 3, iterations: 100, lanes: %w[context html], rust: false,
|
|
29
|
+
user: 'admin', source_root: ROOT }
|
|
30
|
+
parser.parse!(argv)
|
|
31
|
+
raise OptionParser::InvalidArgument, argv.join(' ') unless argv.empty?
|
|
32
|
+
|
|
33
|
+
validate_options
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run
|
|
37
|
+
@git_head = git('rev-parse', 'HEAD')
|
|
38
|
+
unless @git_head || @options[:source_revision]
|
|
39
|
+
raise ArgumentError, 'Source has no Git HEAD; supply --source-revision for an exported tree'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
source_root = @options.fetch(:source_root)
|
|
43
|
+
%w[servers/ruby clients/ruby clients/rust].reverse_each do |component|
|
|
44
|
+
$:.unshift(File.join(source_root, component, 'lib'))
|
|
45
|
+
end
|
|
46
|
+
require 'haveapi'
|
|
47
|
+
require 'rack/mock'
|
|
48
|
+
require FIXTURE
|
|
49
|
+
expected = File.join(source_root, 'servers/ruby/lib/haveapi/documentation_context.rb')
|
|
50
|
+
actual = ClientExample::Context.instance_method(:with).source_location.first
|
|
51
|
+
raise "Context loaded from #{actual}, expected #{expected}" unless File.identical?(actual, expected)
|
|
52
|
+
|
|
53
|
+
if @options[:rust]
|
|
54
|
+
require 'haveapi/rust_client/documentation'
|
|
55
|
+
expected_rust = File.join(source_root, 'clients/rust/lib/haveapi/rust_client/documentation.rb')
|
|
56
|
+
actual_rust = RustClient::Documentation.instance_method(:render).source_location.first
|
|
57
|
+
unless File.identical?(actual_rust, expected_rust)
|
|
58
|
+
raise "Rust adapter loaded from #{actual_rust}, expected #{expected_rust}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
cases = shapes.map { |shape, dimensions| run_case(shape, dimensions) }
|
|
63
|
+
{
|
|
64
|
+
schema_version: 1, status: 'ok', runtime: { description: RUBY_DESCRIPTION, executable: RbConfig.ruby, engine: RUBY_ENGINE,
|
|
65
|
+
version: RUBY_VERSION, platform: RUBY_PLATFORM },
|
|
66
|
+
host: { hostname: Socket.gethostname, os: RbConfig::CONFIG['host_os'] },
|
|
67
|
+
source: source_identity, inputs: input_identity, options: @options, cases:
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def parser
|
|
74
|
+
OptionParser.new do |opts|
|
|
75
|
+
opts.banner = 'Usage: documentation.rb [options] (JSON on stdout)'
|
|
76
|
+
opts.on('--shape SHAPE', %w[resources actions both custom], 'Default: both') { |v| @options[:shape] = v }
|
|
77
|
+
opts.on('--size N', Integer, 'Resources or concentrated actions (default: 8)') { |v| @options[:size] = v }
|
|
78
|
+
opts.on('--resources N', Integer, 'Resource count for custom shape (default: 8)') { |v| @options[:resources] = v }
|
|
79
|
+
opts.on('--actions N', Integer, 'Actions per resource for custom shape (default: 1)') { |v| @options[:actions] = v }
|
|
80
|
+
opts.on('--samples N', Integer, 'Public samples per action (default: 1)') { |v| @options[:samples] = v }
|
|
81
|
+
opts.on('--parameters N', Integer, 'Additional string parameters per action (default: 2)') { |v| @options[:parameters] = v }
|
|
82
|
+
opts.on('--warmup N', Integer, 'Warmup batches per lane (default: 1)') { |v| @options[:warmup] = v }
|
|
83
|
+
opts.on('--trials N', Integer, 'Measured batches per lane (default: 3)') { |v| @options[:trials] = v }
|
|
84
|
+
opts.on('--iterations N', Integer, 'Derivations or adapter renders per batch (default: 100)') { |v| @options[:iterations] = v }
|
|
85
|
+
opts.on('--lanes LIST', Array, 'context,html,rust (default: context,html)') { |v| @options[:lanes] = v }
|
|
86
|
+
opts.on('--rust', 'Enable Rust profile and add the adapter lane; requires generator dependencies') do
|
|
87
|
+
@options[:rust] = true
|
|
88
|
+
@options[:lanes] |= ['rust']
|
|
89
|
+
end
|
|
90
|
+
opts.on('--user USER', %w[admin member], 'Synthetic documentation user (default: admin)') { |v| @options[:user] = v }
|
|
91
|
+
opts.on('--source-root PATH', 'Load implementation from another checkout using this harness/fixture') do |v|
|
|
92
|
+
@options[:source_root] = File.expand_path(v)
|
|
93
|
+
end
|
|
94
|
+
opts.on('--source-revision LABEL', 'Explicit source identity in addition to detected Git HEAD') { |v| @options[:source_revision] = v }
|
|
95
|
+
opts.on('-h', '--help', 'Show options without loading dependencies') do
|
|
96
|
+
puts opts
|
|
97
|
+
exit
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_options
|
|
103
|
+
%i[size resources actions samples parameters trials iterations].each do |name|
|
|
104
|
+
raise OptionParser::InvalidArgument, "#{name} must be positive" unless @options.fetch(name) > 0
|
|
105
|
+
end
|
|
106
|
+
raise OptionParser::InvalidArgument, 'warmup must be nonnegative' if @options.fetch(:warmup) < 0
|
|
107
|
+
unless @options[:lanes].any? && (@options[:lanes] - %w[context html rust]).empty?
|
|
108
|
+
raise OptionParser::InvalidArgument, 'lanes must select context,html,rust'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@options[:lanes].uniq!
|
|
112
|
+
@options[:rust] = true if @options[:lanes].include?('rust')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def shapes
|
|
116
|
+
selected = @options[:shape] == 'both' ? %w[resources actions] : [@options[:shape]]
|
|
117
|
+
selected.map do |shape|
|
|
118
|
+
resources, actions = case shape
|
|
119
|
+
when 'resources' then [@options[:size], 1]
|
|
120
|
+
when 'actions' then [1, @options[:size]]
|
|
121
|
+
else @options.values_at(:resources, :actions)
|
|
122
|
+
end
|
|
123
|
+
[shape, { resource_count: resources, actions_per_resource: actions,
|
|
124
|
+
samples_per_action: @options[:samples], parameters_per_action: @options[:parameters] }]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def run_case(shape, dimensions)
|
|
129
|
+
server, setup = measure { DocumentationPerformanceFixture.build(**dimensions, rust: @options[:rust]) }
|
|
130
|
+
request = Rack::MockRequest.new(server.app)
|
|
131
|
+
headers = { 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{@options[:user]}:fixture-only")}",
|
|
132
|
+
'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT_LANGUAGE' => 'en', 'HTTP_ACCEPT' => 'text/html' }
|
|
133
|
+
response = request.options(URL, **headers, 'HTTP_ACCEPT' => 'application/json')
|
|
134
|
+
raise "OPTIONS returned #{response.status}" unless response.status == 200
|
|
135
|
+
|
|
136
|
+
description = JSON.parse(response.body, symbolize_names: true).fetch(:response)
|
|
137
|
+
profile = server.documentation_client_profiles(1)[:rust]
|
|
138
|
+
parent = ClientExample::Context.new(version: 1, host: 'example.test',
|
|
139
|
+
base_url: 'http://example.test/benchmark/api/', version_url: URL,
|
|
140
|
+
description:, profile:)
|
|
141
|
+
renderers = ClientExample.clients.select { |client| client.available?(parent) }.map(&:id)
|
|
142
|
+
lanes = []
|
|
143
|
+
if @options[:lanes].include?('context')
|
|
144
|
+
replacement = Object.new.freeze
|
|
145
|
+
sample = { request: { value: 0, enabled: false }, response: { value: '' } }
|
|
146
|
+
{ profile: { profile: replacement }, sample: { sample: } }.each do |name, changes|
|
|
147
|
+
lanes << trial_lane("context_#{name}", @options[:iterations], ->(child) { context_output(child, parent) }) do
|
|
148
|
+
child = nil
|
|
149
|
+
@options[:iterations].times { child = parent.with(**changes) }
|
|
150
|
+
child
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
if @options[:lanes].include?('html')
|
|
155
|
+
lanes << trial_lane('html', 1, method(:html_output)) { request.get(URL, **headers) }
|
|
156
|
+
end
|
|
157
|
+
lanes.concat(rust_lanes(parent, profile)) if @options[:lanes].include?('rust')
|
|
158
|
+
{ shape:, dimensions:, profile_mode: @options[:rust] ? 'rust' : 'dynamic', renderers:,
|
|
159
|
+
description: body_identity(JSON.generate(description)), setup:, lanes: }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def rust_lanes(parent, profile)
|
|
163
|
+
raise 'Rust profile is unavailable' unless profile
|
|
164
|
+
|
|
165
|
+
resource = parent.description.fetch(:resources).fetch(:resource0)
|
|
166
|
+
action = resource.fetch(:actions).fetch(:action0)
|
|
167
|
+
context = parent.with(resource_path: ['resource0'], resource:, action_name: 'action0', action:,
|
|
168
|
+
sample: action.fetch(:examples).first)
|
|
169
|
+
auth = parent.description.fetch(:authentication).fetch(:basic)
|
|
170
|
+
%i[async blocking].flat_map do |mode|
|
|
171
|
+
%i[example auth].map do |kind|
|
|
172
|
+
trial_lane("rust_#{mode}_#{kind}", @options[:iterations], method(:rust_output)) do
|
|
173
|
+
result = nil
|
|
174
|
+
@options[:iterations].times do
|
|
175
|
+
result = profile.adapter.render(kind:, context:, mode:, auth_method: :basic, auth_description: auth)
|
|
176
|
+
end
|
|
177
|
+
result
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def measure
|
|
184
|
+
GC.start
|
|
185
|
+
wall = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
186
|
+
cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
|
|
187
|
+
allocated = GC.stat(:total_allocated_objects)
|
|
188
|
+
result = yield
|
|
189
|
+
allocations = GC.stat(:total_allocated_objects) - allocated
|
|
190
|
+
cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - cpu
|
|
191
|
+
wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall
|
|
192
|
+
[result, { wall_seconds: wall, cpu_seconds: cpu, allocated_objects: allocations }]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def trial_lane(name, operations, summarize, &work)
|
|
196
|
+
@options[:warmup].times { summarize.call(work.call) }
|
|
197
|
+
trials = Array.new(@options[:trials]) do |index|
|
|
198
|
+
result, metrics = measure(&work)
|
|
199
|
+
{ trial: index + 1, **metrics, output: summarize.call(result) }
|
|
200
|
+
end
|
|
201
|
+
outputs = trials.map { |trial| trial[:output] }.uniq
|
|
202
|
+
raise "#{name} output changed between trials" unless outputs.length == 1
|
|
203
|
+
|
|
204
|
+
median = METRICS.to_h do |metric|
|
|
205
|
+
sorted = trials.map { |trial| trial.fetch(metric) }.sort
|
|
206
|
+
middle = sorted.length / 2
|
|
207
|
+
[metric, sorted.length.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2.0]
|
|
208
|
+
end
|
|
209
|
+
{ name:, operations_per_trial: operations, trials:, median: }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def body_identity(body)
|
|
213
|
+
{ body_bytes: body.bytesize, body_sha256: Digest::SHA256.hexdigest(body) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def context_output(child, parent)
|
|
217
|
+
{ status: 'ok', description_shared: child.description.equal?(parent.description), frozen: child.frozen?,
|
|
218
|
+
**body_identity(JSON.generate(description: child.description, sample: child.sample)) }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def html_output(response)
|
|
222
|
+
output = { status: response.status, **body_identity(response.body) }
|
|
223
|
+
unless response.status == 200 && response.body.include?('PUBLIC_SAMPLE_0') && response.body.include?('init-examples')
|
|
224
|
+
raise InvalidOutput.new('HTML request failed or missing sample panels', output)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
output
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def rust_output(result)
|
|
231
|
+
unless result[:status] == :code && !result[:code].empty?
|
|
232
|
+
raise InvalidOutput.new('Rust render failed', result)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
{ status: result[:status], **body_identity(result[:code]) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def git(*)
|
|
239
|
+
output, status = Open3.capture2('git', '-C', @options[:source_root], *, err: File::NULL)
|
|
240
|
+
status.success? ? output.strip : nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def source_identity
|
|
244
|
+
{ root: @options[:source_root], supplied_revision: @options[:source_revision],
|
|
245
|
+
git_head: @git_head, git_status: git('status', '--porcelain', '--untracked-files=normal'),
|
|
246
|
+
git_diff_sha256: git('diff', 'HEAD', '--binary')&.then { |diff| Digest::SHA256.hexdigest(diff) },
|
|
247
|
+
loaded_source_sha256: $".grep(%r{/haveapi(?:/|\.rb)}).sort.to_h do |path|
|
|
248
|
+
[path, Digest::SHA256.file(path).hexdigest]
|
|
249
|
+
end }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def input_identity
|
|
253
|
+
paths = [__FILE__, FIXTURE, File.join(ROOT, 'flake.lock'),
|
|
254
|
+
ENV['BUNDLE_GEMFILE'] || File.join(ROOT, 'servers/ruby/Gemfile')]
|
|
255
|
+
paths << "#{paths.last}.lock"
|
|
256
|
+
paths << File.join(@options[:source_root], 'flake.lock')
|
|
257
|
+
if @options[:rust]
|
|
258
|
+
paths.concat(%w[clients/rust/Gemfile clients/rust/Gemfile.lock].map { |path| File.join(ROOT, path) })
|
|
259
|
+
end
|
|
260
|
+
{ files_sha256: paths.select { |path| File.file?(path) }.to_h { |path| [path, Digest::SHA256.file(path).hexdigest] },
|
|
261
|
+
gem_paths: Gem.path,
|
|
262
|
+
gems: Gem.loaded_specs.sort.to_h.transform_values { |spec| spec.version.to_s } }
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if $0 == __FILE__
|
|
268
|
+
begin
|
|
269
|
+
puts JSON.pretty_generate(HaveAPI::DocumentationBenchmark.new(ARGV).run)
|
|
270
|
+
rescue StandardError, LoadError => e
|
|
271
|
+
puts JSON.pretty_generate(schema_version: 1, status: 'failed',
|
|
272
|
+
error: { class: e.class.name, message: e.message,
|
|
273
|
+
output: e.respond_to?(:output) ? e.output : nil })
|
|
274
|
+
warn "#{e.class}: #{e.message}"
|
|
275
|
+
exit 1
|
|
276
|
+
end
|
|
277
|
+
end
|