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,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