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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/documentation.rb +277 -0
  5. data/benchmarks/endpoints.rb +730 -0
  6. data/benchmarks/performance_budgets.json +1354 -0
  7. data/benchmarks/performance_compare.rb +579 -0
  8. data/doc/client-examples.md +233 -24
  9. data/doc/performance.md +272 -0
  10. data/haveapi.gemspec +1 -1
  11. data/lib/haveapi/documentation_clients.rb +158 -35
  12. data/lib/haveapi/documentation_context.rb +42 -12
  13. data/lib/haveapi/documentation_schema.rb +73 -0
  14. data/lib/haveapi/server.rb +5 -0
  15. data/lib/haveapi/version.rb +1 -1
  16. data/lib/haveapi/views/version_page.erb +2 -2
  17. data/spec/client_examples/context_performance_spec.rb +72 -0
  18. data/spec/client_examples/context_spec.rb +206 -0
  19. data/spec/client_examples/dynamic_js_spec.rb +1 -0
  20. data/spec/client_examples/profile_mount_spec.rb +215 -0
  21. data/spec/client_examples/profiles_spec.rb +202 -32
  22. data/spec/client_examples/structural_schema_spec.rb +48 -0
  23. data/spec/documentation/client_examples_spec.rb +10 -0
  24. data/spec/documentation/performance_spec.rb +79 -0
  25. data/spec/performance/comparator_spec.rb +342 -0
  26. data/spec/performance/endpoint_contract_spec.rb +226 -0
  27. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  28. data/spec/performance/event_baseline_spec.rb +279 -0
  29. data/spec/performance/json_activation_spec.rb +151 -0
  30. data/spec/performance/release_lineage_spec.rb +431 -0
  31. data/spec/performance/runner_spec.rb +476 -0
  32. data/spec/performance/sqlite_spec.rb +194 -0
  33. data/spec/performance/support/live_worker.rb +44 -0
  34. data/spec/performance/support/samples.rb +72 -0
  35. data/test_support/documentation_performance_fixture.rb +106 -0
  36. data/test_support/performance_endpoint_fixture.rb +350 -0
  37. data/test_support/performance_sqlite_fixture.rb +384 -0
  38. metadata +26 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d308a5a5e69e262ce771a7ec707783c63af02c38726df08c8d5c2f68bae18d3
4
- data.tar.gz: b70e5a101d631bae5e90d999f50d621643f511fef3a61a45b78f35369e0528c6
3
+ metadata.gz: c34e4857df0b82609b7338ba24a522c078d52e16dbe555b10bd32f645888de6a
4
+ data.tar.gz: e1bfd4862f8c2b2bf09dbd71ec280c72141585cc94453df8072d467b2172f27f
5
5
  SHA512:
6
- metadata.gz: f3081d61476f4ddeb15b44028ff40a9073587520c13bf776f6e675ebcb41439d236c2f463029c969f589e6342e8ca13c550b3d6ab6c1a6dfb639b1bc6a99a940
7
- data.tar.gz: 6523e8942f1fa7ef18c4fbdab85ea073b1e6df14249c948f270b573c3193de3c8d39046440ca132d9f4f30f9d94393392cedc9170310f419a2be8607bac07d0a
6
+ metadata.gz: 4b225959d6032698804ba18f6136c2a8710afaa562db827d56022a9eca6d98af1b55e5b723c2dfa280f04ae2b3ae228ac9dea7cd0e413ec3459d979fbcbcf245
7
+ data.tar.gz: 0a651d2ab26a7ae92f82666561a05e2b812719e37cfe55dcc86f96942c969073d24b59e3ca906be6cd1ed46c732763fddc85190090a630bf44b8f73d30eda928
data/.gitignore CHANGED
@@ -2,4 +2,5 @@ doc/protocol.*
2
2
  doc/create-client.md
3
3
  doc/typed-input-validation.md
4
4
  doc/client-examples.md
5
+ doc/performance.md
5
6
  doc/json-schema.html
data/Gemfile CHANGED
@@ -4,6 +4,7 @@ gemspec
4
4
  gem 'haveapi-client', path: '../../clients/ruby'
5
5
 
6
6
  group :test do
7
+ gem 'json', '= 2.21.2'
7
8
  gem 'rack-test'
8
9
  gem 'rackup'
9
10
  gem 'rspec'
@@ -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