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,384 @@
1
+ require 'haveapi'
2
+ require 'active_record'
3
+ require 'sqlite3'
4
+ require 'haveapi/model_adapters/active_record'
5
+ require 'rack/mock'
6
+ require 'base64'
7
+ require 'digest'
8
+ require 'json'
9
+ require_relative '../benchmarks/performance_compare'
10
+
11
+ module HaveAPI
12
+ module PerformanceFixtures
13
+ # Neutral Rack scenarios; database setup and validation stay outside timing.
14
+ module SQLite
15
+ User = Struct.new(:login)
16
+
17
+ class BasicProvider < HaveAPI::Authentication::Basic::Provider
18
+ protected
19
+
20
+ def find_user(_request, username, password)
21
+ User.new(username) if %w[admin member].include?(username) && password == 'fixture-only'
22
+ end
23
+ end
24
+
25
+ def self.cases(profile: 'quick')
26
+ case profile
27
+ when 'quick'
28
+ [{ id: 'sqlite.page', dimensions: { rows: 128, page_size: 32 } },
29
+ { id: 'sqlite.includes', dimensions: { rows: 32, include_depth: 2 } }]
30
+ when 'weekly'
31
+ cases + [{ id: 'sqlite.page', dimensions: { rows: 10_000, page_size: 32 } }] +
32
+ [32, 128, 1024].product(%w[admin member]).map do |rows, user|
33
+ { id: 'sqlite.includes', dimensions: { rows:, include_depth: 2, user: } }
34
+ end
35
+ else raise ArgumentError, 'Unknown SQLite performance profile'
36
+ end
37
+ end
38
+
39
+ def self.build(case_spec)
40
+ Scenario.new(case_spec)
41
+ end
42
+
43
+ def self.database_namespace
44
+ @database_sequence = (@database_sequence || 0) + 1
45
+ const_set("Database#{@database_sequence}", Module.new)
46
+ end
47
+
48
+ class Scenario
49
+ attr_reader :case_spec
50
+
51
+ def initialize(case_spec)
52
+ @case_spec = case_spec.transform_keys(&:to_sym)
53
+ @dimensions = @case_spec.fetch(:dimensions).transform_keys(&:to_sym)
54
+ @rows = @dimensions.fetch(:rows)
55
+ @user = @dimensions.fetch(:user, 'admin')
56
+ @depth = @dimensions.fetch(:include_depth, 0)
57
+ validate_case!
58
+ @namespace = SQLite.database_namespace
59
+ build_database
60
+ build_api
61
+ @request = Rack::MockRequest.new(@server.app)
62
+ @path = request_path
63
+ @headers = { 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT' => 'application/json',
64
+ 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{@user}:fixture-only")}" }
65
+ @subscription = ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
66
+ record_query(args.last)
67
+ end
68
+ reset!(iterations: 1)
69
+ rescue StandardError
70
+ close
71
+ raise
72
+ end
73
+
74
+ def reset!(iterations:)
75
+ raise ArgumentError, 'Iterations must be positive' unless iterations.is_a?(Integer) && iterations > 0
76
+ raise 'SQLite scenario is closed' if @closed
77
+
78
+ @operations = Array.new(iterations) { { queries: 0, authorizations: 0, executions: 0 } }
79
+ @index = 0
80
+ @active_operation = nil
81
+ self
82
+ end
83
+
84
+ def call
85
+ raise 'SQLite scenario is closed' if @closed
86
+ raise 'SQLite scenario called beyond reset iterations' if @index >= @operations.length
87
+
88
+ @active_operation = @operations.fetch(@index)
89
+ @index += 1
90
+ @base.uncached { @request.get(@path, @headers) }
91
+ ensure
92
+ @active_operation = nil
93
+ end
94
+
95
+ def validate!(response, index:)
96
+ unless index.is_a?(Integer) && index >= 0 && index < @index
97
+ raise ArgumentError, 'Invalid or uncalled SQLite operation index'
98
+ end
99
+ unless response.status == 200 && response['content-type']&.start_with?('application/json')
100
+ raise 'Unexpected SQLite HTTP/envelope status'
101
+ end
102
+ raise 'Unexpected SQLite operation counters' unless @operations.fetch(index) == expected_counters
103
+
104
+ return yield(-> { validate_body!(response) }) if block_given?
105
+
106
+ validate_body!(response)
107
+ end
108
+
109
+ def validate_body!(response)
110
+ identity = HaveAPI::Performance::Identity.json(response)
111
+ body = identity.fetch(:canonical_body)
112
+ unless body['status'] == true && body['message'].nil? && body['errors'].nil? &&
113
+ body.keys.sort == %w[errors message response status]
114
+ raise 'Unexpected SQLite HTTP/envelope status'
115
+ end
116
+ raise 'Unexpected SQLite response rows or values' unless body['response'] == expected_response
117
+
118
+ identity
119
+ end
120
+
121
+ def counters
122
+ raise 'SQLite operation records are incomplete' unless @index == @operations.length
123
+
124
+ unless @operations.all? { |operation| operation == expected_counters }
125
+ raise 'Unexpected SQLite operation counters'
126
+ end
127
+
128
+ { operations: @operations.map(&:dup) }
129
+ end
130
+
131
+ def close
132
+ return if @closed
133
+
134
+ ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
135
+ @base.remove_connection if @base
136
+ if @namespace
137
+ name = @namespace.name.split('::').last
138
+ SQLite.send(:remove_const, name) if SQLite.const_defined?(name, false) && SQLite.const_get(name).equal?(@namespace)
139
+ end
140
+ @closed = true
141
+ end
142
+
143
+ def authorize_record
144
+ @active_operation[:authorizations] += 1 if @active_operation
145
+ end
146
+
147
+ def execute_record
148
+ @active_operation[:executions] += 1 if @active_operation
149
+ end
150
+
151
+ private
152
+
153
+ def validate_case!
154
+ unless %w[sqlite.page sqlite.includes].include?(@case_spec.fetch(:id)) &&
155
+ @rows.is_a?(Integer) && @rows > 0 && %w[admin member].include?(@user)
156
+ raise ArgumentError, 'Invalid SQLite case'
157
+ end
158
+
159
+ allowed = @case_spec[:id] == 'sqlite.page' ? %i[rows page_size user] : %i[rows include_depth user]
160
+ raise ArgumentError, 'Unknown SQLite dimensions' unless (@dimensions.keys - allowed).empty?
161
+
162
+ if @case_spec[:id] == 'sqlite.page'
163
+ unless @dimensions.fetch(:page_size) == 32 && @rows >= 32
164
+ raise ArgumentError, 'SQLite pagination requires a fixed page of 32'
165
+ end
166
+ elsif ![1, 2].include?(@depth)
167
+ raise ArgumentError, 'SQLite include depth must be 1 or 2'
168
+ end
169
+ end
170
+
171
+ def build_database
172
+ @base = @namespace.const_set(:Base, Class.new(ActiveRecord::Base))
173
+ @base.abstract_class = true
174
+ @base.establish_connection(adapter: 'sqlite3', database: ':memory:')
175
+ connection = @base.connection
176
+ connection.create_table(:performance_accounts) do |table|
177
+ table.string :label
178
+ table.string :secret
179
+ end
180
+ connection.create_table(:performance_groups) do |table|
181
+ table.string :label
182
+ table.string :secret
183
+ table.integer :account_id
184
+ end
185
+ connection.create_table(:performance_items) do |table|
186
+ table.string :label
187
+ table.boolean :enabled
188
+ table.integer :count
189
+ table.string :note
190
+ table.integer :group_id
191
+ end
192
+ @account = model(:Account, 'performance_accounts')
193
+ @group = model(:Group, 'performance_groups')
194
+ @item = model(:Item, 'performance_items')
195
+ @group.belongs_to(:account, class_name: @account.name)
196
+ @item.belongs_to(:group, class_name: @group.name)
197
+ @account.insert_all!((1..@rows).map { |id| { id:, label: "account#{id}", secret: "ACCOUNT_SECRET_#{id}" } })
198
+ @group.insert_all!((1..@rows).map { |id| { id:, label: "group#{id}", secret: "GROUP_SECRET_#{id}", account_id: id } })
199
+ @item.insert_all!((1..@rows).map { |id| { id:, label: '', enabled: false, count: 0, note: nil, group_id: id } })
200
+ end
201
+
202
+ def model(name, table)
203
+ klass = @namespace.const_set(name, Class.new(@base))
204
+ klass.table_name = table
205
+ klass.columns
206
+ klass
207
+ end
208
+
209
+ def build_api
210
+ scenario = self
211
+ depth = @depth
212
+ resources = @namespace.const_set(:API, Module.new)
213
+ account_resource = resource(resources, :Account, @account)
214
+ account_resource.define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
215
+ authorize do |user, params|
216
+ scenario.authorize_record
217
+ deny if user.login == 'member' && (params['account_id'].to_i % 3) == 0
218
+ output blacklist: [:secret] if user.login == 'member'
219
+ allow
220
+ end
221
+ output(:object) do
222
+ integer :id
223
+ string :label
224
+ string :secret
225
+ end
226
+ define_method(:exec) do
227
+ scenario.execute_record
228
+ raise 'Associated Show must not execute'
229
+ end
230
+ end
231
+ group_resource = resource(resources, :Group, @group)
232
+ group_resource.define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
233
+ authorize do |user, params|
234
+ scenario.authorize_record
235
+ deny if user.login == 'member' && params['group_id'].to_i.even?
236
+ output blacklist: [:secret] if user.login == 'member'
237
+ allow
238
+ end
239
+ output(:object) do
240
+ integer :id
241
+ string :label
242
+ string :secret
243
+ # The direct lane's selected output is scalar; it does not rely on
244
+ # the adapter's known missing default preload for nested resources.
245
+ resource account_resource if depth == 2
246
+ end
247
+ define_method(:exec) do
248
+ scenario.execute_record
249
+ raise 'Associated Show must not execute'
250
+ end
251
+ end
252
+ item_resource = resource(resources, :Item, @item)
253
+ add_index(item_resource, scenario)
254
+ add_includes(item_resource, group_resource, scenario)
255
+ @server = HaveAPI::Server.new(resources)
256
+ @server.use_version(1)
257
+ @server.default_version = 1
258
+ @server.auth_chain << BasicProvider
259
+ @server.mount('/performance/api/')
260
+ end
261
+
262
+ def resource(resources, name, model)
263
+ klass = resources.const_set(name, Class.new(HaveAPI::Resource))
264
+ klass.resource_name = name.to_s
265
+ klass.version(1)
266
+ klass.auth(true)
267
+ klass.model(model)
268
+ unless name == :Item
269
+ klass.define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
270
+ authorize { deny }
271
+ output(:object_list) do
272
+ integer :id
273
+ string :label
274
+ end
275
+ end
276
+ end
277
+ klass
278
+ end
279
+
280
+ def add_index(resource, scenario)
281
+ resource.define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
282
+ authorize do
283
+ scenario.authorize_record
284
+ allow
285
+ end
286
+ output(:object_list) do
287
+ integer :id
288
+ string :label
289
+ bool :enabled
290
+ integer :count
291
+ string :note, nullable: true
292
+ end
293
+ define_method(:exec) do
294
+ scenario.execute_record
295
+ with_pagination(self.class.model.order(id: :asc)).to_a
296
+ end
297
+ end
298
+ end
299
+
300
+ def add_includes(resource, group_resource, scenario)
301
+ resource.define_action(:Included, superclass: HaveAPI::Actions::Default::Index) do
302
+ route 'included'
303
+ authorize do
304
+ scenario.authorize_record
305
+ allow
306
+ end
307
+ output(:object_list) do
308
+ integer :id
309
+ string :label
310
+ resource group_resource
311
+ end
312
+ define_method(:exec) do
313
+ scenario.execute_record
314
+ with_includes(self.class.model.order(id: :asc)).to_a
315
+ end
316
+ end
317
+ end
318
+
319
+ def request_path
320
+ query = if @case_spec[:id] == 'sqlite.page'
321
+ { item: { from_id: 0, limit: 32 } }
322
+ else
323
+ { _meta: { includes: @depth == 1 ? 'group' : 'group__account' } }
324
+ end
325
+ route = @case_spec[:id] == 'sqlite.page' ? 'items' : 'items/included'
326
+ "/performance/api/v1/#{route}?#{Rack::Utils.build_nested_query(query)}"
327
+ end
328
+
329
+ def record_query(payload)
330
+ return unless @active_operation && payload[:connection]&.pool.equal?(@base.connection_pool)
331
+ return if %w[SCHEMA TRANSACTION].include?(payload[:name])
332
+ return unless payload[:sql].match?(/\A\s*(?:SELECT|INSERT|UPDATE|DELETE)\b/i)
333
+ raise 'SQLite request unexpectedly used query cache' if payload[:cached]
334
+
335
+ @active_operation[:queries] += 1
336
+ end
337
+
338
+ def expected_counters
339
+ return { queries: 1, authorizations: 1, executions: 1 } if @case_spec[:id] == 'sqlite.page'
340
+
341
+ nested = if @depth == 2
342
+ @user == 'admin' ? @rows : (@rows + 1) / 2
343
+ else
344
+ 0
345
+ end
346
+ { queries: @depth + 1, authorizations: 1 + @rows + nested, executions: 1 }
347
+ end
348
+
349
+ def expected_response
350
+ length = @case_spec[:id] == 'sqlite.page' ? 32 : @rows
351
+ items = (1..length).map do |id|
352
+ value = if @case_spec[:id] == 'sqlite.page'
353
+ { 'id' => id, 'label' => '', 'enabled' => false, 'count' => 0, 'note' => nil }
354
+ else
355
+ { 'id' => id, 'label' => '', 'group' => expected_group(id) }
356
+ end
357
+ value['_meta'] = { 'path_params' => [id], 'resolved' => true }
358
+ value
359
+ end
360
+ { 'items' => items, '_meta' => {} }
361
+ end
362
+
363
+ def expected_group(id)
364
+ denied = { '_meta' => { 'resolved' => false, 'authorized' => false } }
365
+ return denied if @user == 'member' && id.even?
366
+
367
+ result = { 'id' => id, 'label' => "group#{id}", '_meta' => { 'path_params' => [id], 'resolved' => true } }
368
+ result['secret'] = "GROUP_SECRET_#{id}" if @user == 'admin'
369
+ return result if @depth == 1
370
+
371
+ result['account'] = if @user == 'member' && (id % 3) == 0
372
+ denied
373
+ else
374
+ account = { 'id' => id, 'label' => "account#{id}",
375
+ '_meta' => { 'path_params' => [id], 'resolved' => @depth == 2 } }
376
+ account['secret'] = "ACCOUNT_SECRET_#{id}" if @user == 'admin' && @depth == 2
377
+ account
378
+ end
379
+ result
380
+ end
381
+ end
382
+ end
383
+ end
384
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: haveapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.1
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Skokan
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: 0.30.1
32
+ version: 0.31.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: 0.30.1
39
+ version: 0.31.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: i18n
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -210,11 +210,19 @@ files:
210
210
  - LICENSE.txt
211
211
  - README.md
212
212
  - Rakefile
213
+ - benchmarks/Gemfile
214
+ - benchmarks/Gemfile.lock
215
+ - benchmarks/documentation.rb
216
+ - benchmarks/endpoints.rb
217
+ - benchmarks/performance_budgets.json
218
+ - benchmarks/performance_compare.rb
219
+ - benchmarks/shell.nix
213
220
  - doc/client-examples.md
214
221
  - doc/create-client.md
215
222
  - doc/hooks.erb
216
223
  - doc/index.md
217
224
  - doc/json-schema.html
225
+ - doc/performance.md
218
226
  - doc/protocol.md
219
227
  - doc/protocol.png
220
228
  - doc/typed-input-validation.md
@@ -254,6 +262,7 @@ files:
254
262
  - lib/haveapi/client_examples/rust_client.rb
255
263
  - lib/haveapi/client_examples/ts_async_client.rb
256
264
  - lib/haveapi/client_examples/ts_client.rb
265
+ - lib/haveapi/client_examples/ts_generated_client.rb
257
266
  - lib/haveapi/common.rb
258
267
  - lib/haveapi/context.rb
259
268
  - lib/haveapi/documentation_clients.rb
@@ -345,6 +354,7 @@ files:
345
354
  - spec/authorization_spec.rb
346
355
  - spec/client_examples/assets/highlighter_test.cjs
347
356
  - spec/client_examples/assets/tabs_test.cjs
357
+ - spec/client_examples/context_performance_spec.rb
348
358
  - spec/client_examples/context_spec.rb
349
359
  - spec/client_examples/dynamic_acceptance.md
350
360
  - spec/client_examples/dynamic_acceptance.rb
@@ -360,12 +370,15 @@ files:
360
370
  - spec/client_examples/rust_client_spec.rb
361
371
  - spec/client_examples/structural_schema_spec.rb
362
372
  - spec/client_examples/syntax_spec.rb
373
+ - spec/client_examples/ts_generated_client_spec.rb
374
+ - spec/client_examples/typescript_profiles_spec.rb
363
375
  - spec/common_spec.rb
364
376
  - spec/documentation/auth_filtering_spec.rb
365
377
  - spec/documentation/client_examples_spec.rb
366
378
  - spec/documentation/current_user_html_escaping_spec.rb
367
379
  - spec/documentation/examples_spec.rb
368
380
  - spec/documentation/host_html_escaping_spec.rb
381
+ - spec/documentation/performance_spec.rb
369
382
  - spec/documentation_spec.rb
370
383
  - spec/envelope_spec.rb
371
384
  - spec/extensions/action_exceptions_spec.rb
@@ -376,6 +389,16 @@ files:
376
389
  - spec/packaging_spec.rb
377
390
  - spec/parameters/typed_spec.rb
378
391
  - spec/params_spec.rb
392
+ - spec/performance/comparator_spec.rb
393
+ - spec/performance/endpoint_contract_spec.rb
394
+ - spec/performance/endpoint_scaling_spec.rb
395
+ - spec/performance/event_baseline_spec.rb
396
+ - spec/performance/json_activation_spec.rb
397
+ - spec/performance/release_lineage_spec.rb
398
+ - spec/performance/runner_spec.rb
399
+ - spec/performance/sqlite_spec.rb
400
+ - spec/performance/support/live_worker.rb
401
+ - spec/performance/support/samples.rb
379
402
  - spec/resource_spec.rb
380
403
  - spec/server/integration_spec.rb
381
404
  - spec/spec_helper.rb
@@ -391,9 +414,12 @@ files:
391
414
  - spec/validators/presence_spec.rb
392
415
  - test_support/client_test_api.rb
393
416
  - test_support/client_test_server.rb
417
+ - test_support/documentation_performance_fixture.rb
394
418
  - test_support/dynamic_documentation_api.rb
395
419
  - test_support/dynamic_protocol_spec.rb
396
420
  - test_support/dynamic_python_login_spec.rb
421
+ - test_support/performance_endpoint_fixture.rb
422
+ - test_support/performance_sqlite_fixture.rb
397
423
  licenses:
398
424
  - MIT
399
425
  metadata: {}