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,194 @@
1
+ require 'spec_helper'
2
+ require_relative '../../test_support/performance_sqlite_fixture'
3
+ require_relative '../../benchmarks/endpoints'
4
+
5
+ describe HaveAPI::PerformanceFixtures::SQLite do
6
+ def with_scenario(id: 'sqlite.includes', **dimensions)
7
+ scenario = described_class.build(id:, dimensions:)
8
+ yield scenario
9
+ ensure
10
+ scenario&.close
11
+ end
12
+
13
+ def run_operations(scenario, count = 2)
14
+ scenario.reset!(iterations: count)
15
+ responses = Array.new(count) { scenario.call }
16
+ responses.each_with_index { |response, index| scenario.validate!(response, index:) }
17
+ [responses, scenario.counters.fetch(:operations)]
18
+ end
19
+
20
+ it 'reuses only SQLite body semantics while checking every query, authorization and called index' do
21
+ with_scenario(rows: 32, include_depth: 2, user: 'member') do |scenario|
22
+ allow(scenario).to receive(:validate_body!).and_call_original
23
+ worker = HaveAPI::Performance::Worker.new({})
24
+ result = worker.measure(scenario, 4)
25
+ expect(result[:bodies].length).to eq(1)
26
+ expect(scenario).to have_received(:validate_body!).once
27
+ %i[queries authorizations executions].each do |counter|
28
+ calls = 0
29
+ allow(scenario).to receive(:call).and_wrap_original do |call|
30
+ response = call.call
31
+ calls += 1
32
+ scenario.instance_variable_get(:@operations)[1][counter] += 1 if calls == 2
33
+ response
34
+ end
35
+ expect { worker.measure(scenario, 3) }.to raise_error(/operation counters/)
36
+ end
37
+ scenario.reset!(iterations: 2)
38
+ response = scenario.call
39
+ expect { scenario.validate!(response, index: 1) { raise 'body validator must not run' } }.to raise_error(ArgumentError, /uncalled/)
40
+ end
41
+ end
42
+
43
+ it 'exposes unique stable quick/weekly cases and rejects unsupported shapes' do
44
+ quick = described_class.cases
45
+ expect(quick).to eq([{ id: 'sqlite.page', dimensions: { rows: 128, page_size: 32 } },
46
+ { id: 'sqlite.includes', dimensions: { rows: 32, include_depth: 2 } }])
47
+ weekly = described_class.cases(profile: 'weekly')
48
+ expect(weekly.uniq).to eq(weekly)
49
+ expect(weekly).to include(id: 'sqlite.includes', dimensions: { rows: 1024, include_depth: 2, user: 'member' })
50
+ expect { described_class.cases(profile: 'missing') }.to raise_error(ArgumentError)
51
+ expect { described_class.build(id: 'missing', dimensions: { rows: 1 }) }.to raise_error(ArgumentError)
52
+ expect { described_class.build(id: 'sqlite.page', dimensions: { rows: 1024, page_size: 1024 }) }.to raise_error(ArgumentError)
53
+ end
54
+
55
+ [32, 128, 1024].each do |rows|
56
+ it "keeps a fixed 32-row page at one real uncached query over #{rows} rows" do
57
+ with_scenario(id: 'sqlite.page', rows:, page_size: 32) do |scenario|
58
+ responses, counts = run_operations(scenario)
59
+ expect(counts).to eq(Array.new(2) { { queries: 1, authorizations: 1, executions: 1 } })
60
+ values = JSON.parse(responses.first.body).fetch('response').fetch('items')
61
+ expect(values.map { |item| item.fetch('id') }).to eq((1..32).to_a)
62
+ expect(values.first).to include('label' => '', 'enabled' => false, 'count' => 0, 'note' => nil)
63
+ end
64
+ end
65
+ end
66
+
67
+ [1, 32, 128].product([1, 2]).each do |rows, depth|
68
+ it "bounds explicit depth #{depth} includes to #{depth + 1} queries for #{rows} rows while retaining row authorization" do
69
+ with_scenario(rows:, include_depth: depth) do |scenario|
70
+ responses, counts = run_operations(scenario)
71
+ expected = { queries: depth + 1, authorizations: 1 + (rows * depth), executions: 1 }
72
+ expect(counts).to eq([expected, expected])
73
+ groups = JSON.parse(responses.first.body).fetch('response').fetch('items').map { |item| item.fetch('group') }
74
+ expect(groups).to all(include('_meta' => include('resolved' => true)))
75
+ expect(groups.first).to have_key('account') if depth == 2
76
+ expect(groups.first).not_to have_key('account') if depth == 1
77
+ end
78
+ end
79
+ end
80
+
81
+ it 'applies the real ascending cursor before the fixed page limit' do
82
+ with_scenario(id: 'sqlite.page', rows: 128, page_size: 32) do |scenario|
83
+ path = scenario.instance_variable_get(:@path)
84
+ scenario.instance_variable_set(:@path, path.sub('from_id%5D=0', 'from_id%5D=32'))
85
+ response = scenario.call
86
+ expect(response.status).to eq(200)
87
+ body = JSON.parse(response.body)
88
+ expect(body.fetch('status')).to be(true)
89
+ expect(body.fetch('response').fetch('items').map { |item| item.fetch('id') }).to eq((33..64).to_a)
90
+ expect(scenario.counters.fetch(:operations)).to eq([{ queries: 1, authorizations: 1, executions: 1 }])
91
+ end
92
+ end
93
+
94
+ it 'keeps fixed-page allocation work independent of unreturned table rows' do
95
+ allocations = [32, 1024].map do |rows|
96
+ with_scenario(id: 'sqlite.page', rows:, page_size: 32) do |scenario|
97
+ 2.times { run_operations(scenario, 1) }
98
+ 3.times.map do
99
+ scenario.reset!(iterations: 3)
100
+ responses = Array.new(3)
101
+ GC.start
102
+ before = GC.stat(:total_allocated_objects)
103
+ 3.times { |index| responses[index] = scenario.call }
104
+ count = GC.stat(:total_allocated_objects) - before
105
+ responses.each_with_index { |response, index| scenario.validate!(response, index:) }
106
+ count
107
+ end.min
108
+ end
109
+ end
110
+ # Ruby 3.4.11 calibration observed 2620 objects/request at every table size.
111
+ # Fixed slack accommodates VM bookkeeping while rejecting table-sized work.
112
+ expect(allocations.last - allocations.first).to be <= (128 * 3)
113
+ end
114
+
115
+ it 'serializes 1024 rows without passing an invalid pagination limit' do
116
+ with_scenario(rows: 1024, include_depth: 2) do |scenario|
117
+ responses, counts = run_operations(scenario, 1)
118
+ expect(JSON.parse(responses.first.body).fetch('response').fetch('items').length).to eq(1024)
119
+ expect(counts).to eq([{ queries: 3, authorizations: 2049, executions: 1 }])
120
+ end
121
+ end
122
+
123
+ it 'keeps member association decisions and hidden values isolated between administrator requests' do
124
+ with_scenario(rows: 32, include_depth: 2, user: 'admin') do |admin|
125
+ with_scenario(rows: 32, include_depth: 2, user: 'member') do |member|
126
+ first, = run_operations(admin, 1)
127
+ restricted, counts = run_operations(member, 1)
128
+ restored, = run_operations(admin, 1)
129
+ expect(restored.first.body).to eq(first.first.body)
130
+ expect(first.first.body).to include('GROUP_SECRET_2', 'ACCOUNT_SECRET_3')
131
+ expect(restricted.first.body).not_to include('GROUP_SECRET', 'ACCOUNT_SECRET', '"label":"group2"', '"label":"account3"')
132
+ items = JSON.parse(restricted.first.body).fetch('response').fetch('items')
133
+ denied = { '_meta' => { 'resolved' => false, 'authorized' => false } }
134
+ expect(items[1].fetch('group')).to eq(denied)
135
+ expect(items[2].fetch('group').fetch('account')).to eq(denied)
136
+ expect(counts).to eq([{ queries: 3, authorizations: 49, executions: 1 }])
137
+ end
138
+ end
139
+ end
140
+
141
+ it 'rechecks administrator/member permissions on one app and pool across repeated requests' do
142
+ with_scenario(rows: 32, include_depth: 2) do |scenario|
143
+ results = %w[admin member admin].map do |user|
144
+ scenario.instance_variable_set(:@user, user)
145
+ scenario.instance_variable_get(:@headers)['HTTP_AUTHORIZATION'] =
146
+ "Basic #{Base64.strict_encode64("#{user}:fixture-only")}"
147
+ run_operations(scenario, 1).first.first
148
+ end
149
+ expect(results.last.body).to eq(results.first.body)
150
+ expect(results[1].body).not_to include('GROUP_SECRET', 'ACCOUNT_SECRET')
151
+ end
152
+ end
153
+
154
+ it 'resets operation records, validates every response and rejects incomplete/extra calls' do
155
+ with_scenario(rows: 1, include_depth: 2) do |scenario|
156
+ scenario.reset!(iterations: 2)
157
+ first = scenario.call
158
+ expect { scenario.counters }.to raise_error(/incomplete/)
159
+ second = scenario.call
160
+ expect(scenario.validate!(first, index: 0)[:body_sha256]).to eq(Digest::SHA256.hexdigest(first.body))
161
+ expect(scenario.validate!(second, index: 1)[:canonical_body]).to eq(JSON.parse(second.body))
162
+ expect { scenario.validate!(first, index: -1) }.to raise_error(ArgumentError)
163
+ expect { scenario.call }.to raise_error(/beyond/)
164
+ scenario.reset!(iterations: 1)
165
+ expect { scenario.validate!(first, index: 0) }.to raise_error(ArgumentError, /uncalled/)
166
+ response = scenario.call
167
+ scenario.validate!(response, index: 0)
168
+ wrong = response.dup
169
+ allow(wrong).to receive(:body).and_return(response.body.sub('group1', 'wrong!'))
170
+ expect { scenario.validate!(wrong, index: 0) }.to raise_error(/rows or values/)
171
+ expect { scenario.reset!(iterations: 0) }.to raise_error(ArgumentError)
172
+ end
173
+ end
174
+
175
+ it 'owns a separate connection without changing the global ActiveRecord base or another scenario' do
176
+ original_connected = ActiveRecord::Base.connected?
177
+ original_pool = ActiveRecord::Base.connection_pool if original_connected
178
+ first = described_class.build(id: 'sqlite.includes', dimensions: { rows: 1, include_depth: 2 })
179
+ second = described_class.build(id: 'sqlite.includes', dimensions: { rows: 32, include_depth: 2 })
180
+ first_pool = first.instance_variable_get(:@base).connection_pool
181
+ second_pool = second.instance_variable_get(:@base).connection_pool
182
+ expect(first_pool).not_to equal(second_pool)
183
+ expect(ActiveRecord::Base.connected?).to eq(original_connected)
184
+ expect(ActiveRecord::Base.connection_pool).to equal(original_pool) if original_pool
185
+ first.close
186
+ expect(first_pool.connected?).to be(false)
187
+ expect { first.call }.to raise_error(/closed/)
188
+ _responses, counts = run_operations(second, 1)
189
+ expect(counts.first).to eq(queries: 3, authorizations: 65, executions: 1)
190
+ ensure
191
+ first&.close
192
+ second&.close
193
+ end
194
+ end
@@ -0,0 +1,44 @@
1
+ # Isolated positive controls. No defect switch enters the benchmark CLI/runtime.
2
+ require_relative '../../../benchmarks/endpoints'
3
+
4
+ control = ARGV.fetch(0)
5
+ spec = if %w[query sqlite-clean].include?(control)
6
+ { id: 'sqlite.page', dimensions: { rows: 128, page_size: 32 } }
7
+ else
8
+ { id: 'api.public_get', dimensions: {} }
9
+ end
10
+ HaveAPI::Performance::Worker.define_singleton_method(:inventory) { |_profile| [spec] }
11
+ # These test-only fault controls use the actual resolved server bundle from
12
+ # ordinary RSpec CI. The production worker's root/component input contract is
13
+ # unchanged and is tested separately; it has no optional lock fallback.
14
+ HaveAPI::Performance::Worker.define_singleton_method(:inputs) do |source, _profile|
15
+ { manifests: { 'servers/ruby/Gemfile' => Digest::SHA256.file(File.join(source, 'servers/ruby/Gemfile')).hexdigest },
16
+ locks: { 'servers/ruby/Gemfile.lock' => Digest::SHA256.file(File.join(HaveAPI::Performance::ROOT, 'servers/ruby/Gemfile.lock')).hexdigest } }
17
+ end
18
+ HaveAPI::Performance::Worker.singleton_class.prepend(Module.new do
19
+ define_method(:build) do |selected|
20
+ super(selected).tap do |scenario|
21
+ if control == 'query'
22
+ scenario.singleton_class.prepend(Module.new do
23
+ def execute_record
24
+ super
25
+ @base.connection.select_value('SELECT 1')
26
+ end
27
+ end)
28
+ elsif %w[allocation wrong-output].include?(control)
29
+ scenario.singleton_class.prepend(Module.new do
30
+ define_method(:call) do
31
+ Array.new(4000) { Object.new } if control == 'allocation'
32
+ response = super()
33
+ if control == 'wrong-output'
34
+ Rack::MockResponse.new(200, response.headers, response.body.sub('item-1', 'wrong-item'))
35
+ else
36
+ response
37
+ end
38
+ end
39
+ end)
40
+ end
41
+ end
42
+ end
43
+ end)
44
+ exit HaveAPI::Performance::CLI.new(['worker']).execute
@@ -0,0 +1,72 @@
1
+ require 'json'
2
+ require 'digest'
3
+ require_relative '../../../benchmarks/performance_compare'
4
+
5
+ module PerformanceSamples
6
+ def self.fixture(profile: 'quick', zero: 0)
7
+ identity = HaveAPI::Performance::Identity
8
+ policy = HaveAPI::Performance::PROFILES.fetch(profile)
9
+ baseline_sha = profile == 'weekly' ? HaveAPI::Performance::REFERENCE : 'a' * 40
10
+ body = JSON.generate(status: true, response: { zero:, no: false, empty: '', null: nil })
11
+ digest = Digest::SHA256.hexdigest(body)
12
+ output = { 'http_status' => 200, 'envelope_status' => true, 'body_bytes' => body.bytesize,
13
+ 'body_sha256' => digest, 'canonical_sha256' => identity.digest(JSON.parse(body)), 'count' => 1 }
14
+ sample = { 'operations' => 1, 'wall_seconds' => 2.0, 'cpu_seconds' => 2.0, 'allocated_objects' => 100,
15
+ 'outputs' => [output], 'bodies' => { digest => { 'body' => body, 'canonical_body' => JSON.parse(body) } },
16
+ 'counters' => { 'operations' => 1, 'record' => { 'queries' => 0, 'authorizations' => 1, 'executions' => 1 } } }
17
+ spec = { 'id' => 'api.public_get', 'dimensions' => {} }
18
+ environment = { 'ruby' => { 'version' => 'controlled-detector-trace' }, 'gems' => { 'test' => 'controlled' } }
19
+ fixtures = { 'controlled' => 'f' * 64 }
20
+ preparation = HaveAPI::Performance::Release.json_preparation(HaveAPI::Performance::Release::JSON_GEMFILES.to_h { |path| [path, "source 'https://rubygems.org'\n"] })
21
+ inputs = { 'locks' => { 'Gemfile.lock' => 'a' * 64 }, 'manifests' => preparation.fetch('normalized_sha256'), 'json_preparation' => preparation }
22
+ sources = %w[baseline candidate].each_with_index.to_h do |role, index|
23
+ [role, { 'root' => "/controlled/#{role}", 'sha' => index == 0 ? baseline_sha : 'b' * 40,
24
+ 'tree' => 'c' * 40, 'tracked_files' => 1, 'files_sha256' => { 'server.rb' => 'd' * 64 }.merge(preparation.fetch('raw_sha256')) }]
25
+ end
26
+ observations = sources.flat_map do |role, source|
27
+ %w[forward reverse].map do |order|
28
+ [order == 'forward' ? role : "#{role}-reverse",
29
+ { 'source' => source.merge('loaded_sources' => { 'server.rb' => 'd' * 64 }),
30
+ 'environment' => environment, 'fixture_sha256' => fixtures, 'inputs' => inputs,
31
+ 'runtime_policy' => HaveAPI::Performance::RUNTIME_POLICY, 'workload_order' => order,
32
+ 'host' => { 'hostname' => 'controlled', 'os' => 'controlled', 'cpu' => 'controlled' } }]
33
+ end
34
+ end
35
+ observations = observations.to_h
36
+ pair = lambda do |index, self_pair|
37
+ { 'order' => if self_pair
38
+ %w[baseline baseline]
39
+ else
40
+ (index.even? ? %w[baseline candidate] : %w[candidate baseline])
41
+ end,
42
+ 'workload_order' => index.even? ? 'forward' : 'reverse',
43
+ 'baseline' => sample.merge('observation' => index.even? ? 'baseline' : 'baseline-reverse'),
44
+ 'candidate' => sample.merge('observation' => "#{self_pair ? 'baseline' : 'candidate'}#{'-reverse' unless index.even?}") }
45
+ end
46
+ budget = { 'schema_version' => 1, 'reference_sha' => HaveAPI::Performance::REFERENCE, 'environment' => { profile => environment, 'weekly' => environment },
47
+ 'calibration' => { profile => { 'source_sha' => HaveAPI::Performance::REFERENCE, 'raw_sha256' => 'f' * 64,
48
+ 'warmup_batches' => 2, 'iterations' => 1, 'fixture_sha256' => fixtures, 'inputs' => inputs } },
49
+ 'profiles' => { profile => [spec.merge('reference_allocations' => 100, 'allocation_ceiling' => 157,
50
+ 'counters' => sample['counters']['record'])] } }
51
+ budget_json = JSON.generate(budget)
52
+ samples = { 'schema_version' => HaveAPI::Performance::SAMPLE_SCHEMA, 'profile' => profile, 'baseline_sha' => baseline_sha, 'candidate_sha' => 'b' * 40,
53
+ 'harness_sha256' => 'e' * 64, 'fixture_sha256' => fixtures,
54
+ 'budget_sha256' => Digest::SHA256.hexdigest(budget_json), 'environment' => environment,
55
+ 'inputs' => HaveAPI::Performance::Release.graph(inputs), 'sources' => sources, 'observations' => observations, 'inventory' => [spec],
56
+ 'cases' => [spec.merge('iterations' => 1, 'self_pairs' => Array.new(policy[:self_pairs]) { |i| pair.call(i, true) },
57
+ 'pairs' => Array.new(policy[:pairs]) { |i| pair.call(i, false) })] }
58
+ HaveAPI::Performance.complex_cases(profile).each do |complex|
59
+ samples['inventory'] << complex
60
+ entry = Marshal.load(Marshal.dump(samples['cases'].first.merge(complex)))
61
+ (entry['self_pairs'] + entry['pairs']).each do |pair|
62
+ %w[baseline candidate].each do |role|
63
+ record = pair[role]['counters']['record']
64
+ record['executions'] = 0
65
+ record['authorizations'] = (complex['dimensions']['resources'] * complex['dimensions']['actions']) + 1
66
+ end
67
+ end
68
+ samples['cases'] << entry
69
+ end
70
+ [JSON.parse(JSON.generate(samples)), budget_json]
71
+ end
72
+ end
@@ -0,0 +1,106 @@
1
+ require 'haveapi'
2
+
3
+ module HaveAPI
4
+ # Shared by deterministic specs and the opt-in documentation benchmark.
5
+ module DocumentationPerformanceFixture
6
+ User = Struct.new(:login)
7
+
8
+ class BasicProvider < HaveAPI::Authentication::Basic::Provider
9
+ protected
10
+
11
+ def find_user(_request, username, password)
12
+ User.new(username) if %w[admin member].include?(username) && password == 'fixture-only'
13
+ end
14
+ end
15
+
16
+ def self.build(resource_count: 4, actions_per_resource: 2, samples_per_action: 1,
17
+ parameters_per_action: 2, rust: false)
18
+ dimensions = [resource_count, actions_per_resource, samples_per_action, parameters_per_action]
19
+ unless dimensions.all? { |value| value.is_a?(Integer) && value > 0 }
20
+ raise ArgumentError, 'Fixture dimensions must be positive integers'
21
+ end
22
+
23
+ resources = Module.new
24
+ resource_count.times do |index|
25
+ resource = Class.new(HaveAPI::Resource)
26
+ resources.const_set("Resource#{index}", resource)
27
+ resource.resource_name = "Resource#{index}"
28
+ resource.version 1
29
+ resource.route "resources#{index}"
30
+ resource.desc "Synthetic resource #{index}"
31
+ actions_per_resource.times do |action_index|
32
+ add_action(resource, action_index, samples_per_action, parameters_per_action)
33
+ end
34
+ add_admin_action(resource) if index == 0
35
+ end
36
+
37
+ server = HaveAPI::Server.new(resources)
38
+ server.use_version(1)
39
+ server.default_version = 1
40
+ server.auth_chain << BasicProvider
41
+ if rust
42
+ server.documentation_clients(1, rust: { crate_name: 'documentation_performance_client' })
43
+ end
44
+ server.mount('/benchmark/api/')
45
+ server
46
+ end
47
+
48
+ def self.add_action(resource, index, samples, parameters)
49
+ resource.define_action("Action#{index}") do
50
+ route "actions#{index}"
51
+ http_method :post
52
+ auth true
53
+ desc "Synthetic action #{index}"
54
+ input(:hash) do
55
+ parameters.times { |i| string :"value#{i}" }
56
+ custom :payload
57
+ bool :enabled
58
+ integer :count
59
+ string :nullable, nullable: true
60
+ string :admin_field, default: 'ADMIN_ONLY_DEFAULT'
61
+ end
62
+ output(:hash) do
63
+ string :marker
64
+ custom :payload
65
+ string :admin_field
66
+ end
67
+ authorize do |user|
68
+ if user && user.login != 'admin'
69
+ input blacklist: [:admin_field]
70
+ output blacklist: [:admin_field]
71
+ end
72
+ allow
73
+ end
74
+
75
+ samples.times do |sample_index|
76
+ example "Public sample #{sample_index}" do
77
+ request({ value0: '', enabled: false, count: 0, nullable: nil,
78
+ payload: { 'nested' => [{ 'values' => [0, false, '', nil] }] } })
79
+ response({ marker: "PUBLIC_SAMPLE_#{sample_index}", payload: { 'items' => [false, 0, nil, ''] } })
80
+ end
81
+ end
82
+ example 'Admin sample' do
83
+ authorize { |user| user.login == 'admin' }
84
+ request({ admin_field: 'ADMIN_ONLY_REQUEST' })
85
+ response({ admin_field: 'ADMIN_ONLY_RESPONSE' })
86
+ end
87
+ end
88
+ end
89
+ private_class_method :add_action
90
+
91
+ def self.add_admin_action(resource)
92
+ resource.define_action(:AdminOnly) do
93
+ route 'admin-only'
94
+ http_method :get
95
+ auth true
96
+ desc 'ADMIN_ONLY_ACTION'
97
+ output(:hash) { string :admin_result }
98
+ authorize { |user| user && user.login == 'admin' ? allow : deny }
99
+ example 'Admin action sample' do
100
+ response({ admin_result: 'ADMIN_ONLY_ACTION_SAMPLE' })
101
+ end
102
+ end
103
+ end
104
+ private_class_method :add_admin_action
105
+ end
106
+ end