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
@@ -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.0
4
+ version: 0.30.2
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.0
32
+ version: 0.30.2
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.0
39
+ version: 0.30.2
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: i18n
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -210,11 +210,16 @@ files:
210
210
  - LICENSE.txt
211
211
  - README.md
212
212
  - Rakefile
213
+ - benchmarks/documentation.rb
214
+ - benchmarks/endpoints.rb
215
+ - benchmarks/performance_budgets.json
216
+ - benchmarks/performance_compare.rb
213
217
  - doc/client-examples.md
214
218
  - doc/create-client.md
215
219
  - doc/hooks.erb
216
220
  - doc/index.md
217
221
  - doc/json-schema.html
222
+ - doc/performance.md
218
223
  - doc/protocol.md
219
224
  - doc/protocol.png
220
225
  - doc/typed-input-validation.md
@@ -258,6 +263,7 @@ files:
258
263
  - lib/haveapi/context.rb
259
264
  - lib/haveapi/documentation_clients.rb
260
265
  - lib/haveapi/documentation_context.rb
266
+ - lib/haveapi/documentation_schema.rb
261
267
  - lib/haveapi/example.rb
262
268
  - lib/haveapi/example_list.rb
263
269
  - lib/haveapi/exceptions.rb
@@ -344,6 +350,7 @@ files:
344
350
  - spec/authorization_spec.rb
345
351
  - spec/client_examples/assets/highlighter_test.cjs
346
352
  - spec/client_examples/assets/tabs_test.cjs
353
+ - spec/client_examples/context_performance_spec.rb
347
354
  - spec/client_examples/context_spec.rb
348
355
  - spec/client_examples/dynamic_acceptance.md
349
356
  - spec/client_examples/dynamic_acceptance.rb
@@ -354,8 +361,10 @@ files:
354
361
  - spec/client_examples/dynamic_mounted_paths_spec.rb
355
362
  - spec/client_examples/dynamic_python_spec.rb
356
363
  - spec/client_examples/go_spec.rb
364
+ - spec/client_examples/profile_mount_spec.rb
357
365
  - spec/client_examples/profiles_spec.rb
358
366
  - spec/client_examples/rust_client_spec.rb
367
+ - spec/client_examples/structural_schema_spec.rb
359
368
  - spec/client_examples/syntax_spec.rb
360
369
  - spec/common_spec.rb
361
370
  - spec/documentation/auth_filtering_spec.rb
@@ -363,6 +372,7 @@ files:
363
372
  - spec/documentation/current_user_html_escaping_spec.rb
364
373
  - spec/documentation/examples_spec.rb
365
374
  - spec/documentation/host_html_escaping_spec.rb
375
+ - spec/documentation/performance_spec.rb
366
376
  - spec/documentation_spec.rb
367
377
  - spec/envelope_spec.rb
368
378
  - spec/extensions/action_exceptions_spec.rb
@@ -373,6 +383,16 @@ files:
373
383
  - spec/packaging_spec.rb
374
384
  - spec/parameters/typed_spec.rb
375
385
  - spec/params_spec.rb
386
+ - spec/performance/comparator_spec.rb
387
+ - spec/performance/endpoint_contract_spec.rb
388
+ - spec/performance/endpoint_scaling_spec.rb
389
+ - spec/performance/event_baseline_spec.rb
390
+ - spec/performance/json_activation_spec.rb
391
+ - spec/performance/release_lineage_spec.rb
392
+ - spec/performance/runner_spec.rb
393
+ - spec/performance/sqlite_spec.rb
394
+ - spec/performance/support/live_worker.rb
395
+ - spec/performance/support/samples.rb
376
396
  - spec/resource_spec.rb
377
397
  - spec/server/integration_spec.rb
378
398
  - spec/spec_helper.rb
@@ -388,9 +408,12 @@ files:
388
408
  - spec/validators/presence_spec.rb
389
409
  - test_support/client_test_api.rb
390
410
  - test_support/client_test_server.rb
411
+ - test_support/documentation_performance_fixture.rb
391
412
  - test_support/dynamic_documentation_api.rb
392
413
  - test_support/dynamic_protocol_spec.rb
393
414
  - test_support/dynamic_python_login_spec.rb
415
+ - test_support/performance_endpoint_fixture.rb
416
+ - test_support/performance_sqlite_fixture.rb
394
417
  licenses:
395
418
  - MIT
396
419
  metadata: {}