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
@@ -25,6 +25,212 @@ describe HaveAPI::ClientExample::Context do
25
25
  expect(child).to be_frozen
26
26
  end
27
27
 
28
+ describe '#with' do
29
+ let(:fields) do
30
+ {
31
+ version: 1, host: 'api.test', base_url: 'https://api.test/mount/',
32
+ version_url: 'https://api.test/mount/v1/',
33
+ description: { 'resources' => { 'widget' => { 'label' => 'Widget' } } },
34
+ resource_path: [:widget, 2], resource: { 'label' => 'Widget' },
35
+ action_name: :show, action: { 'method' => 'GET' },
36
+ sample: { 'request' => { 'values' => [0, false, '', nil] } }, profile: Object.new
37
+ }
38
+ end
39
+ let(:parent) { described_class.new(**fields) }
40
+ let(:replacements) do
41
+ {
42
+ version: 2, host: 'other.test', base_url: 'https://other.test/api/',
43
+ version_url: 'https://other.test/api/v2/',
44
+ description: { 'resources' => { 'other' => { 'label' => 'Other' } } },
45
+ resource_path: [:other, 3], resource: { 'label' => 'Other' },
46
+ action_name: :update, action: { 'method' => 'POST' },
47
+ sample: { 'request' => { 'values' => [false, 0, nil, ''] } },
48
+ profile: Object.new
49
+ }
50
+ end
51
+
52
+ it 'returns a distinct frozen context sharing every unchanged field' do
53
+ child = parent.with
54
+
55
+ expect(child).not_to equal(parent)
56
+ expect(child).to be_frozen
57
+ described_class::FIELDS.each do |field|
58
+ expect(child.public_send(field)).to equal(parent.public_send(field))
59
+ end
60
+ expect(child.description['resources']['widget']).to equal(parent.description['resources']['widget'])
61
+ end
62
+
63
+ HaveAPI::ClientExample::Context::FIELDS.each do |field|
64
+ it "normalizes an explicit #{field} replacement like the constructor and shares other fields" do
65
+ changed = { field => replacements.fetch(field) }
66
+ child = parent.with(**changed)
67
+ constructor = described_class.new(**fields, **changed)
68
+
69
+ expect(child).to be_frozen
70
+ expect(child.public_send(field)).to eq(constructor.public_send(field))
71
+ (described_class::FIELDS - [field]).each do |unchanged|
72
+ expect(child.public_send(unchanged)).to equal(parent.public_send(unchanged))
73
+ end
74
+ expect(parent.public_send(field)).to eq(described_class.new(**fields).public_send(field))
75
+ end
76
+ end
77
+
78
+ it 'copies supplied nested containers and strings even when their outer hash is frozen' do
79
+ key = ['mutable key']
80
+ source = { key => [{ 'value' => 'safe' }] }.freeze
81
+ child = parent.with(description: source, resource: source, action: source, sample: source)
82
+
83
+ %i[description resource action sample].each do |field|
84
+ owned = child.public_send(field)
85
+ expect(owned).not_to equal(source)
86
+ expect(owned).to be_frozen
87
+ expect(owned.keys.first).not_to equal(key)
88
+ expect(owned.keys.first).to be_frozen
89
+ expect(owned.keys.first.first).to be_frozen
90
+ expect(owned.values.first).to be_frozen
91
+ expect(owned.values.first.first).to be_frozen
92
+ expect(owned.values.first.first.keys.first).to be_frozen
93
+ expect(owned.values.first.first['value']).to be_frozen
94
+ end
95
+ expect(key).not_to be_frozen
96
+ expect(key.first).not_to be_frozen
97
+ expect(source.values.first).not_to be_frozen
98
+ expect(source.values.first.first['value']).not_to be_frozen
99
+ key.first.replace('changed key')
100
+ source.values.first.first['value'].replace('changed')
101
+ %i[description resource action sample].each do |field|
102
+ expect(child.public_send(field)).to eq(['mutable key'] => [{ 'value' => 'safe' }])
103
+ end
104
+ end
105
+
106
+ it 'detaches explicitly supplied strings and converts resource path elements without retaining them' do
107
+ text = +'value'
108
+ path = [text, :widget, 7]
109
+ child = parent.with(version: text, host: text, base_url: text, version_url: text,
110
+ resource_path: path, action_name: text)
111
+
112
+ %i[version host base_url version_url action_name].each do |field|
113
+ expect(child.public_send(field)).to eq('value')
114
+ expect(child.public_send(field)).not_to equal(text)
115
+ expect(child.public_send(field)).to be_frozen
116
+ end
117
+ expect(child.resource_path).to eq(%w[value widget 7])
118
+ expect(child.resource_path).to be_frozen
119
+ expect(child.resource_path).not_to equal(path)
120
+ expect(child.resource_path).to all(be_frozen)
121
+ expect(path).not_to be_frozen
122
+ expect(text).not_to be_frozen
123
+ text.replace('changed')
124
+ path << 'extra'
125
+ expect(child.version).to eq('value')
126
+ expect(child.resource_path).to eq(%w[value widget 7])
127
+ end
128
+
129
+ it 'copies explicitly supplied owned values again instead of treating frozen data as trusted' do
130
+ child = parent.with(description: parent.description, resource_path: parent.resource_path,
131
+ sample: parent.sample)
132
+
133
+ %i[description resource_path sample].each do |field|
134
+ expect(child.public_send(field)).to eq(parent.public_send(field))
135
+ expect(child.public_send(field)).not_to equal(parent.public_send(field))
136
+ end
137
+ expect(child.description['resources']).not_to equal(parent.description['resources'])
138
+ expect(child.sample['request']['values']).not_to equal(parent.sample['request']['values'])
139
+ end
140
+
141
+ it 'keeps parent, sibling and chained derivations detached from their supplied inputs' do
142
+ data = { request: { name: 'safe' } }
143
+ child = parent.with(sample: data)
144
+ sibling = parent.with(sample: data)
145
+ grandchild = child.with(action_name: :update)
146
+ fields[:description]['resources']['widget']['label'].replace('changed source')
147
+ data[:request][:name].replace('changed sample')
148
+
149
+ expect(parent.description['resources']['widget']['label']).to eq('Widget')
150
+ expect(child.description).to equal(parent.description)
151
+ expect(sibling.description).to equal(parent.description)
152
+ expect(grandchild.description).to equal(parent.description)
153
+ expect(parent.sample).to eq('request' => { 'values' => [0, false, '', nil] })
154
+ expect(child.sample).to eq(request: { name: 'safe' })
155
+ expect(sibling.sample).to eq(child.sample)
156
+ expect(sibling.sample).not_to equal(child.sample)
157
+ expect(grandchild.sample).to equal(child.sample)
158
+ expect(child.action_name).to eq('show')
159
+ expect(grandchild.action_name).to eq('update')
160
+ expect { child.sample[:request][:name].replace('forbidden') }.to raise_error(FrozenError)
161
+ end
162
+
163
+ it 'distinguishes omitted fields from explicit nil replacements' do
164
+ child = parent.with(description: nil, resource: nil, action_name: nil, action: nil, sample: nil, profile: nil)
165
+
166
+ %i[description resource action_name action sample profile].each do |field|
167
+ expect(child.public_send(field)).to be_nil
168
+ expect(parent.public_send(field)).not_to be_nil
169
+ end
170
+ expect(child.version).to equal(parent.version)
171
+ expect(parent.with(version: nil, host: nil, base_url: nil, version_url: nil))
172
+ .to have_attributes(version: '', host: '', base_url: '', version_url: '')
173
+ expect(context.with.action_name).to be_nil
174
+ end
175
+
176
+ it 'shares arbitrary profiles and non-container leaves without freezing them' do
177
+ leaf = Object.new
178
+ profile = fields.fetch(:profile)
179
+ child = parent.with(description: { custom: leaf }, profile: leaf)
180
+
181
+ expect(parent.profile).to equal(profile)
182
+ expect(parent.with.profile).to equal(profile)
183
+ expect(profile).not_to be_frozen
184
+ expect(child.profile).to equal(leaf)
185
+ expect(child.description[:custom]).to equal(leaf)
186
+ expect(leaf).not_to be_frozen
187
+ end
188
+
189
+ it 'continues rejecting invalid resource paths' do
190
+ expect { parent.with(resource_path: nil) }.to raise_error(NoMethodError)
191
+ expect { described_class.new(**fields, resource_path: nil) }.to raise_error(NoMethodError)
192
+ expect(parent.resource_path).to eq(%w[widget 2])
193
+ end
194
+
195
+ it 'rejects unknown keywords before normalizing any supplied values' do
196
+ value = Object.new
197
+ allow(value).to receive(:to_s).and_call_original
198
+
199
+ expect { parent.with(version: value, unknown: true) }.to raise_error(ArgumentError, 'unknown keyword: :unknown')
200
+ expect { parent.with(unknown: true, other: false) }
201
+ .to raise_error(ArgumentError, 'unknown keywords: :unknown, :other')
202
+ expect { parent.with('version' => '2') }.to raise_error(ArgumentError, 'unknown keyword: "version"')
203
+ expect(value).not_to have_received(:to_s)
204
+ expect(parent).not_to respond_to(:unknown)
205
+ expect(parent.version).to eq('1')
206
+ end
207
+
208
+ it 'keeps subclass constructors, extra keywords and constructor effects on every derivation' do
209
+ subclass = Class.new(described_class) do
210
+ attr_reader :marker, :initializations
211
+
212
+ def initialize(marker: :default, initializations: 0, **fields)
213
+ @marker = marker
214
+ @initializations = initializations + 1
215
+ super(**fields)
216
+ end
217
+ end
218
+ original = subclass.new(**fields, marker: :original)
219
+ child = original.with(marker: :child, initializations: original.initializations)
220
+ defaulted = original.with
221
+
222
+ expect(child).to be_a(subclass)
223
+ expect(child).to be_frozen
224
+ expect(child.marker).to eq(:child)
225
+ expect(child.initializations).to eq(2)
226
+ expect(defaulted.marker).to eq(:default)
227
+ expect(original.marker).to eq(:original)
228
+ expect(original.initializations).to eq(1)
229
+ expect(child.description).to eq(original.description)
230
+ expect(child.description).not_to equal(original.description)
231
+ end
232
+ end
233
+
28
234
  it 'preserves legacy initialization constructors and wraps plain source' do
29
235
  renderer = Class.new(HaveAPI::ClientExample) do
30
236
  def initialize(host, base_url, version)
@@ -83,6 +83,7 @@ RSpec.describe HaveAPI::ClientExamples::JsClient do
83
83
  end
84
84
  end
85
85
  options = { strict: true, noEmit: true, target: 'ES2020', module: 'ESNext', moduleResolution: 'node',
86
+ ignoreDeprecations: '6.0',
86
87
  typeRoots: [File.join(root, 'clients/js/node_modules/@types')], types: ['node'],
87
88
  baseUrl: directory, paths: { 'haveapi-client' => [File.join(root, 'clients/js/dist/types/haveapi')] } }
88
89
  config = File.join(directory, 'tsconfig.json')
@@ -0,0 +1,215 @@
1
+ require 'spec_helper'
2
+ require 'base64'
3
+
4
+ module ProfileMountSpec
5
+ User = Struct.new(:login)
6
+
7
+ module AuthenticationResources
8
+ class Session < HaveAPI::Resource
9
+ auth false
10
+ version :all
11
+
12
+ class Show < HaveAPI::Action
13
+ route ''
14
+ http_method :get
15
+ output(:hash) { string :id }
16
+ authorize { allow }
17
+ end
18
+ end
19
+ end
20
+
21
+ class Provider < HaveAPI::Authentication::Basic::Provider
22
+ def resource_module
23
+ AuthenticationResources
24
+ end
25
+
26
+ protected
27
+
28
+ def find_user(_request, username, password)
29
+ User.new(username) if %w[member admin].include?(username) && password == 'pass'
30
+ end
31
+ end
32
+
33
+ class Added < HaveAPI::Resource
34
+ auth false
35
+ version :all
36
+
37
+ class Index < HaveAPI::Action
38
+ route ''
39
+ http_method :get
40
+ output(:hash) { string :late }
41
+ authorize { allow }
42
+ end
43
+ end
44
+ end
45
+
46
+ describe HaveAPI::Server, '#mount' do
47
+ api do
48
+ define_resource(:Widget) do
49
+ version :all
50
+ auth false
51
+
52
+ define_action(:Show) do
53
+ route '{widget_id}'
54
+ http_method :get
55
+ desc HaveAPI.message('profile_mount_spec.action')
56
+ input(:hash) do
57
+ string :visible
58
+ string :secret, default: 'PRIVATE_DEFAULT'
59
+ end
60
+ output(:hash) do
61
+ string :visible
62
+ string :secret
63
+ end
64
+ authorize do |user|
65
+ unless user.login == 'admin'
66
+ input blacklist: [:secret]
67
+ output blacklist: [:secret]
68
+ end
69
+ allow
70
+ end
71
+
72
+ # rubocop:disable RSpec/NoExpectationExample
73
+ example 'visible sample' do
74
+ path_params 7
75
+ request({ visible: 'public sample' })
76
+ response({ visible: 'public response' })
77
+ end
78
+
79
+ example 'privileged sample' do
80
+ authorize { |user| user.login == 'admin' }
81
+ path_params 7
82
+ request({ secret: 'PRIVATE_SAMPLE' })
83
+ response({ secret: 'PRIVATE_RESPONSE' })
84
+ end
85
+ # rubocop:enable RSpec/NoExpectationExample
86
+ end
87
+
88
+ define_action(:Secret) do
89
+ route 'secret'
90
+ http_method :get
91
+ output(:hash) { string :admin_only }
92
+ authorize { |user| user.login == 'admin' ? allow : deny }
93
+ end
94
+ end
95
+ end
96
+
97
+ let(:server) { described_class.new(get_opt(:api_module)) }
98
+ let(:descriptions) { [] }
99
+ let(:contexts) { [] }
100
+ let(:documentation) { Class.new }
101
+ let(:adapter) do
102
+ selected_contexts = contexts
103
+ Class.new do
104
+ define_method(:resolve) { |**| nil }
105
+ define_method(:render) do |kind:, context:, **|
106
+ selected_contexts << context
107
+ { status: :code, code: JSON.generate(kind == :example ? context.sample : { url: context.version_url }) }
108
+ end
109
+ end.new
110
+ end
111
+
112
+ before do
113
+ generator = Module.new
114
+ generator.const_set(:VERSION, HaveAPI::VERSION)
115
+ generator.const_set(:Documentation, documentation)
116
+ stub_const('HaveAPI::GoClient', generator)
117
+ allow(registry).to receive(:require).with('haveapi/go_client/documentation').and_return(true)
118
+ allow(documentation).to receive(:new) do |description:, **|
119
+ descriptions << description
120
+ adapter
121
+ end
122
+ server.use_version([1, 2])
123
+ server.default_version = 1
124
+ server.auth_chain << ProfileMountSpec::Provider
125
+ end
126
+
127
+ def registry
128
+ server.instance_variable_get(:@documentation_clients)
129
+ end
130
+
131
+ def register(version = 1)
132
+ server.documentation_clients(version, go: { import_path: 'example.test/widget/client' })
133
+ end
134
+
135
+ def request_as(user, locale)
136
+ Rack::MockRequest.new(server.app).get(
137
+ '/mounted/api/v1/', 'HTTP_HOST' => 'example.org', 'HTTP_ACCEPT_LANGUAGE' => locale,
138
+ 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:pass")}"
139
+ )
140
+ end
141
+
142
+ it 'captures the complete selected version after auth, action-state and post-mount routes exist' do
143
+ register
144
+ server.action_state = Object.new
145
+ server.connect_hook(:post_mount) do |_ret, api_server, _sinatra|
146
+ expect(api_server.documentation_client_profiles(1)).to be_empty
147
+ api_server.mount_resource('/mounted/api/v1/', 1, ProfileMountSpec::Added, api_server.routes[1][:resources])
148
+ {}
149
+ end
150
+ allow(server).to receive(:describe_version).and_call_original
151
+ server.mount('/mounted/api/')
152
+ expect(server).to have_received(:describe_version).once
153
+ expect(descriptions.size).to eq(1)
154
+ private_schema = descriptions.first
155
+ expect(private_schema['help']).to eq('/mounted/api/v1/')
156
+ expect(private_schema['resources'].keys).to include('widget', 'action_state', 'added')
157
+ expect(private_schema.dig('resources', 'widget', 'actions').keys).to include('show', 'secret')
158
+ expect(private_schema.dig('authentication', 'basic', 'resources')).to have_key('session')
159
+ expect(private_schema.dig('resources', 'widget', 'actions', 'show', 'input', 'parameters')).to have_key('secret')
160
+ expect(JSON.generate(private_schema)).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE')
161
+ expect(server.documentation_client_profiles(1).fetch(:go).package_version).to be_nil
162
+ expect(server.documentation_client_profiles(2)).to be_empty
163
+ end
164
+
165
+ it 'captures each configured version once and does not initialize from requests' do
166
+ register(1)
167
+ register(2)
168
+ server.mount('/mounted/api/')
169
+ expect(descriptions.map { |description| description['help'] }).to eq(['/mounted/api/v1/', '/mounted/api/v2/'])
170
+ request_as('member', 'en')
171
+ request_as('admin', 'cs')
172
+ expect(descriptions.size).to eq(2)
173
+ expect { register }.to raise_error(HaveAPI::ClientExample::Profiles::Invalid, /already mounted/)
174
+ end
175
+
176
+ it 'keeps private schema separate from sequential users, locales, HTML and samples' do
177
+ register
178
+ server.available_locales = %i[en cs]
179
+ ::I18n.backend.store_translations(:en, profile_mount_spec: { action: 'Visible English action' })
180
+ ::I18n.backend.store_translations(:cs, profile_mount_spec: { action: 'Viditelna ceska akce' })
181
+ server.mount('/mounted/api/')
182
+ privileged = request_as('admin', 'en')
183
+ expect(privileged.status).to eq(200)
184
+ expect(privileged.body).to include('PRIVATE_SAMPLE', 'Visible English action')
185
+ contexts.clear
186
+ public_response = request_as('member', 'cs')
187
+ expect(public_response.status).to eq(200)
188
+ expect(public_response.body).to include('Viditelna ceska akce', 'init-go', 'example.test/widget/client')
189
+ expect(public_response.body).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE', 'admin_only', 'haveapi.invalid', 'StructuralSchema')
190
+ selected = contexts.find { |context| context.action_name == 'show' }
191
+ expect(selected.action.dig(:input, :parameters).keys.map(&:to_s)).to eq(['visible'])
192
+ expect(selected.description[:resources]['widget'][:actions]).not_to have_key('secret')
193
+ expect(selected.sample[:request]).to eq(visible: 'public sample')
194
+ expect(selected.action[:description]).to eq('Viditelna ceska akce')
195
+ expect(request_as('member', 'en').body).to include('Visible English action')
196
+ expect(descriptions.size).to eq(1)
197
+ end
198
+
199
+ it 'makes the server unusable and publishes no profiles after an adapter failure' do
200
+ register
201
+ allow(documentation).to receive(:new).and_raise('PRIVATE_SCHEMA_DIAGNOSTIC')
202
+ expect { server.mount('/') }.to raise_error(HaveAPI::ClientExample::Profiles::Invalid, 'Configured documentation client schema or configuration is invalid')
203
+ expect(server.app).to be_nil
204
+ expect(server.documentation_client_profiles(1)).to be_empty
205
+ end
206
+
207
+ it 'makes the server unusable when a post-mount hook fails before finalization' do
208
+ register
209
+ server.connect_hook(:post_mount) { raise 'fixture mount failure' }
210
+ expect { server.mount('/') }.to raise_error(RuntimeError, 'fixture mount failure')
211
+ expect(documentation).not_to have_received(:new)
212
+ expect(server.app).to be_nil
213
+ expect(server.documentation_client_profiles(1)).to be_empty
214
+ end
215
+ end