haveapi 0.29.12 → 0.30.1

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Rakefile +1 -0
  4. data/doc/client-examples.md +198 -0
  5. data/haveapi.gemspec +1 -1
  6. data/lib/haveapi/client_example.rb +47 -1
  7. data/lib/haveapi/client_examples/dynamic_helpers.rb +129 -0
  8. data/lib/haveapi/client_examples/go_client.rb +37 -0
  9. data/lib/haveapi/client_examples/js_async_client.rb +14 -0
  10. data/lib/haveapi/client_examples/js_client.rb +179 -135
  11. data/lib/haveapi/client_examples/python_async_client.rb +14 -0
  12. data/lib/haveapi/client_examples/python_client.rb +172 -0
  13. data/lib/haveapi/client_examples/rust_client.rb +59 -0
  14. data/lib/haveapi/client_examples/ts_async_client.rb +14 -0
  15. data/lib/haveapi/client_examples/ts_client.rb +14 -0
  16. data/lib/haveapi/documentation_clients.rb +249 -0
  17. data/lib/haveapi/documentation_context.rb +47 -0
  18. data/lib/haveapi/documentation_schema.rb +73 -0
  19. data/lib/haveapi/public/js/highlight-LICENSE +29 -0
  20. data/lib/haveapi/public/js/highlight.pack.js +2 -2
  21. data/lib/haveapi/public/js/highlighter.js +16 -6
  22. data/lib/haveapi/public/js/main.js +9 -1
  23. data/lib/haveapi/public/js/nojs-tabs.js +37 -3
  24. data/lib/haveapi/server.rb +39 -0
  25. data/lib/haveapi/version.rb +1 -1
  26. data/lib/haveapi/views/main_layout.erb +11 -0
  27. data/lib/haveapi/views/version_page/auth_body.erb +3 -3
  28. data/lib/haveapi/views/version_page/client_auth.erb +2 -2
  29. data/lib/haveapi/views/version_page/client_example.erb +2 -11
  30. data/lib/haveapi/views/version_page/client_init.erb +2 -2
  31. data/lib/haveapi/views/version_page/client_result.erb +5 -0
  32. data/lib/haveapi/views/version_page/resource_body.erb +3 -7
  33. data/lib/haveapi/views/version_page.erb +13 -9
  34. data/spec/client_examples/assets/highlighter_test.cjs +26 -0
  35. data/spec/client_examples/assets/tabs_test.cjs +54 -0
  36. data/spec/client_examples/context_spec.rb +68 -0
  37. data/spec/client_examples/dynamic_acceptance.md +83 -0
  38. data/spec/client_examples/dynamic_acceptance.rb +151 -0
  39. data/spec/client_examples/dynamic_acceptance_spec.rb +76 -0
  40. data/spec/client_examples/dynamic_fixture.rb +36 -0
  41. data/spec/client_examples/dynamic_js_spec.rb +196 -0
  42. data/spec/client_examples/dynamic_metadata_spec.rb +52 -0
  43. data/spec/client_examples/dynamic_mounted_paths_spec.rb +65 -0
  44. data/spec/client_examples/dynamic_python_spec.rb +123 -0
  45. data/spec/client_examples/go_spec.rb +57 -0
  46. data/spec/client_examples/profile_mount_spec.rb +215 -0
  47. data/spec/client_examples/profiles_spec.rb +265 -0
  48. data/spec/client_examples/rust_client_spec.rb +75 -0
  49. data/spec/client_examples/structural_schema_spec.rb +48 -0
  50. data/spec/client_examples/syntax_spec.rb +2 -1
  51. data/spec/documentation/auth_filtering_spec.rb +12 -0
  52. data/spec/documentation/client_examples_spec.rb +92 -0
  53. data/spec/packaging_spec.rb +13 -4
  54. data/test_support/dynamic_documentation_api.rb +171 -0
  55. data/test_support/dynamic_protocol_spec.rb +133 -0
  56. data/test_support/dynamic_python_login_spec.rb +75 -0
  57. metadata +37 -4
  58. data/lib/haveapi/client_examples/fs_client.rb +0 -119
@@ -0,0 +1,123 @@
1
+ require 'spec_helper'
2
+ require 'open3'
3
+ require_relative 'dynamic_fixture'
4
+
5
+ RSpec.describe HaveAPI::ClientExamples::PythonClient do
6
+ include DynamicExampleFixture
7
+
8
+ let(:renderers) { [described_class, HaveAPI::ClientExamples::PythonAsyncClient] }
9
+
10
+ it 'keeps both variants available without a generated SDK profile' do
11
+ expect(renderers.map(&:id)).to eq(%w[python-sync python-async])
12
+ expect(renderers.map(&:order)).to eq([14, 15])
13
+ expect(renderers).to all(be_available(dynamic_context))
14
+ end
15
+
16
+ it 'renders complete parseable init, auth, action and async ownership' do
17
+ renderers.each do |renderer|
18
+ results = [renderer.render(:init, context: dynamic_context), renderer.render(:example, context: dynamic_context)]
19
+ %i[basic token oauth2].each do |method|
20
+ results << renderer.render(:auth, context: dynamic_context, auth_method: method, auth_description: token_description)
21
+ end
22
+ results.each do |result|
23
+ expect(result[:status]).to eq(:code), result.inspect
24
+ expect(result).to be_frozen
25
+ output, status = Open3.capture2e('python', '-c', 'import ast, sys; ast.parse(sys.stdin.read())', stdin_data: result[:code])
26
+ expect(status).to be_success, output
27
+ expect(result[:code]).to include('https://api.example.test/mount/', 'version="2"')
28
+ expect(result[:code]).to include('except ActionError', 'except HaveAPIError')
29
+ end
30
+ end
31
+ end
32
+
33
+ it 'preserves exact mapping names, positional path order and every input value' do
34
+ renderers.each do |renderer|
35
+ source = renderer.render(:example, context: dynamic_context).fetch(:code)
36
+ expect(source).to include('.resources["constructor"].resources["resources"].actions[',
37
+ 'path_params={"parent_id": 7, "widget_id": "name/with space"}',
38
+ '"enabled": False', '"count": 0', '"nullable": None', '"__proto__":',
39
+ 'print(response.data)', 'print(response.meta)')
40
+ if renderer.id == 'python-async'
41
+ expect(source).to include('async with AsyncClient', 'response = await ', 'asyncio.run(main())')
42
+ else
43
+ expect(source).to include('with Client')
44
+ expect(source).not_to include('await ')
45
+ end
46
+ end
47
+ end
48
+
49
+ it 'retains list rows and exposes a limitation for an unnamespaced list' do
50
+ action = dynamic_action.merge(input: { layout: :hash_list, namespace: :widgets, parameters: { name: { type: 'String' } } })
51
+ context = dynamic_context(action:, sample: dynamic_sample.merge(request: [{ name: '' }, { name: 'second' }]))
52
+ renderers.each do |renderer|
53
+ source = renderer.render(:example, context:).fetch(:code)
54
+ expect(source).to include('params=[{"name": ""}, {"name": "second"}]')
55
+ result = renderer.render(:example, context: context.with(action: action.merge(input: action[:input].merge(namespace: nil))))
56
+ expect(result[:status]).to eq(:limitation)
57
+ end
58
+ end
59
+
60
+ it 'does not turn a prose-only or incomplete example into an executable call' do
61
+ renderers.each do |renderer|
62
+ [dynamic_sample.merge(path_params: []), { request: nil, response: nil }].each do |sample|
63
+ result = renderer.render(:example, context: dynamic_context(sample:))
64
+ expect(result[:status]).to eq(:limitation)
65
+ end
66
+ end
67
+ end
68
+
69
+ it 'uses real token challenge APIs and advertised login fields without printing credentials' do
70
+ renderers.each do |renderer|
71
+ source = renderer.render(:auth, context: dynamic_context, auth_method: :token, auth_description: token_description).fetch(:code)
72
+ expect(source).to include('begin_token_login', 'step.input', 'step.submit(values)', 'HAVEAPI_AUTH_login-name')
73
+ expect(source).not_to include('print(step.token)', 'qwertyuiop')
74
+ end
75
+ end
76
+
77
+ it 'keeps advertised required lifecycle fields while omitting optional interval in both modes' do
78
+ description = token_description
79
+ parameters = description[:resources][:token][:actions]['request'][:input][:parameters]
80
+ parameters[:lifetime][:required] = true
81
+ parameters[:interval] = { type: 'Integer', required: false }
82
+ renderers.each do |renderer|
83
+ source = renderer.render(:auth, context: dynamic_context, auth_method: :token, auth_description: description).fetch(:code)
84
+ expect(source).to include('"lifetime": json.loads(os.environ["HAVEAPI_AUTH_lifetime"])')
85
+ expect(source).not_to include('HAVEAPI_AUTH_interval', 'HAVEAPI_CHALLENGE_token', 'renewable_auto')
86
+ expect(source).to include('if "HAVEAPI_TOKEN" in os.environ:', 'set_auth(TokenAuth(os.environ["HAVEAPI_TOKEN"]))',
87
+ 'for name in step.input if name != "token"', 'step.submit(values)')
88
+ end
89
+ parameters[:interval][:required] = true
90
+ renderers.each do |renderer|
91
+ source = renderer.render(:auth, context: dynamic_context, auth_method: :token, auth_description: description).fetch(:code)
92
+ expect(source).to include('"interval": json.loads(os.environ["HAVEAPI_AUTH_interval"])')
93
+ end
94
+ end
95
+
96
+ it 'limits GET examples to flat scalars and safe query names in both modes' do
97
+ action = dynamic_action.merge(method: 'GET', input: {
98
+ layout: :hash, namespace: :widget, parameters: { payload: { type: 'Custom', nullable: true } }
99
+ })
100
+ renderers.each do |renderer|
101
+ [{ enabled: false, count: 0 }, [], {}, { nested: 'string' }, ['string'], false, 0].each do |payload|
102
+ result = renderer.render(:example, context: dynamic_context(action:, sample: dynamic_sample.merge(request: { payload: })))
103
+ expect(result[:status]).to eq(:limitation)
104
+ expect(result[:reason]).to include('flat scalar')
105
+ end
106
+ [nil, '', 'safe'].each do |payload|
107
+ result = renderer.render(:example, context: dynamic_context(action:, sample: dynamic_sample.merge(request: { payload: })))
108
+ expect(result[:status]).to eq(:code), result.inspect
109
+ end
110
+ unsafe = action.merge(input: action[:input].merge(namespace: 'bad[name]'))
111
+ expect(renderer.render(:example, context: dynamic_context(action: unsafe, sample: dynamic_sample.merge(request: {})))[:status]).to eq(:limitation)
112
+ end
113
+ end
114
+
115
+ it 'keeps JSON input intact and limits methods unsupported by Python' do
116
+ renderers.each do |renderer|
117
+ %w[POST PUT DELETE HEAD OPTIONS].each do |method|
118
+ result = renderer.render(:example, context: dynamic_context(action: dynamic_action.merge(method:)))
119
+ expect(result[:status]).to eq(%w[POST PUT DELETE].include?(method) ? :code : :limitation)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,57 @@
1
+ require 'haveapi/documentation_context'
2
+ require 'haveapi/documentation_clients'
3
+ require 'haveapi/client_examples/go_client'
4
+
5
+ RSpec.describe HaveAPI::ClientExamples::GoClient do
6
+ let(:result) { { status: :code, code: 'package main' }.freeze }
7
+ let(:adapter) do
8
+ contract = Class.new do
9
+ def render(**); end
10
+ end
11
+ instance_double(contract, render: result)
12
+ end
13
+ let(:profile) { Struct.new(:language, :adapter).new(:go, adapter) }
14
+ let(:context) do
15
+ HaveAPI::ClientExample::Context.new(
16
+ version: '7', host: 'api.example.test', base_url: 'https://api.example.test/mounted/',
17
+ version_url: 'https://api.example.test/mounted/v7/', description: {},
18
+ resource_path: %w[widget], action_name: 'update', action: {},
19
+ sample: { status: true, request: { enabled: false } }, profile:
20
+ )
21
+ end
22
+
23
+ it 'registers the frozen Go ID and requires its configured profile' do
24
+ expect(described_class.id).to eq('go')
25
+ expect(described_class.code).to eq(:go)
26
+ expect(described_class.order).to eq(16)
27
+ expect(described_class).to be_available(context)
28
+ expect(described_class).not_to be_available(context.with(profile: nil))
29
+ expect(described_class).not_to be_available(context.with(profile: Struct.new(:language).new(:rust)))
30
+ end
31
+
32
+ it 'delegates initialization without requiring the optional generator gem' do
33
+ expect(described_class.render(:init, context:)).to eq(result)
34
+ expect(adapter).to have_received(:render).with(kind: :init, context:, mode: :sync)
35
+ end
36
+
37
+ it 'delegates advertised authentication with its filtered descriptor' do
38
+ descriptor = { description: 'HTTP Basic authentication' }
39
+ expect(described_class.render(:auth, context:, auth_method: :basic, auth_description: descriptor)).to eq(result)
40
+ expect(adapter).to have_received(:render).with(kind: :auth, context:, mode: :sync,
41
+ auth_method: :basic, auth_description: descriptor)
42
+ end
43
+
44
+ it 'binds the selected filtered example and preserves plain limitation results' do
45
+ limitation = { status: :limitation, reason: 'This Go SDK input is unsupported.' }.freeze
46
+ allow(adapter).to receive(:render).and_return(limitation)
47
+ expect(described_class.render(:example, context:)).to eq(limitation)
48
+ expect(adapter).to have_received(:render) do |**arguments|
49
+ selected = arguments.fetch(:context)
50
+ expect(arguments[:kind]).to eq(:example)
51
+ expect(arguments[:mode]).to eq(:sync)
52
+ expect(selected.sample).to eq(context.sample)
53
+ expect(selected.sample).to be_frozen
54
+ expect(selected.description).to eq(context.description)
55
+ end
56
+ end
57
+ end
@@ -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
@@ -0,0 +1,265 @@
1
+ require 'spec_helper'
2
+ require 'tmpdir'
3
+
4
+ describe HaveAPI::ClientExample::Profiles do
5
+ let(:profiles) { described_class.new }
6
+ let(:adapter) do
7
+ Class.new do
8
+ def resolve(**); end
9
+ def render(**); end
10
+ end.new
11
+ end
12
+ let(:directory) { Dir.mktmpdir('haveapi-doc-profiles-') }
13
+ let(:description) { { resources: {}, authentication: {}, meta: { namespace: '_meta' } } }
14
+ let(:server) { instance_double(HaveAPI::Server, versions: [1], root: '/', describe_version: description) }
15
+
16
+ def description_path
17
+ File.join(directory, 'description.json')
18
+ end
19
+
20
+ def config_path
21
+ File.join(directory, 'generator.toml')
22
+ end
23
+
24
+ def go_config
25
+ { import_path: 'example.test/widget/client', module_version: 'v1.2.3', description_path:, generator_version: HaveAPI::VERSION }
26
+ end
27
+
28
+ def rust_config
29
+ { snapshot_path: description_path, generator_config_path: config_path, generator_version: HaveAPI::VERSION }
30
+ end
31
+
32
+ def generator_config
33
+ { 'crate_name' => 'widget-client', 'crate_version' => '1.2.3' }
34
+ end
35
+
36
+ before do
37
+ File.write(description_path, JSON.generate(description))
38
+ File.write(config_path, 'fixture config')
39
+ end
40
+
41
+ after { FileUtils.remove_entry(directory) }
42
+
43
+ def install_go_adapter(version: HaveAPI::VERSION)
44
+ generator = Module.new
45
+ generator.const_set(:VERSION, version)
46
+ documentation = Class.new
47
+ generator.const_set(:Documentation, documentation)
48
+ stub_const('HaveAPI::GoClient', generator)
49
+ allow(profiles).to receive(:require).with('haveapi/go_client/documentation').and_return(true)
50
+ allow(documentation).to receive(:new).and_return(adapter)
51
+ documentation
52
+ end
53
+
54
+ def install_rust_adapter(factories: true)
55
+ generator = Module.new
56
+ generator.const_set(:VERSION, HaveAPI::VERSION)
57
+ documentation = Class.new
58
+ generator.const_set(:Documentation, documentation)
59
+ generator.const_set(:Generator, Class.new)
60
+ generator.const_set(:Model, Module.new)
61
+ generator.const_set(:Snapshot, Class.new)
62
+ stub_const('HaveAPI::RustClient', generator)
63
+ stub_const('Tomlrb', Module.new)
64
+ allow(Tomlrb).to receive(:load_file).with(config_path).and_return(generator_config)
65
+ allow(profiles).to receive(:require).with('haveapi/rust_client/documentation').and_return(true)
66
+ allow(documentation).to receive(:new).and_return(adapter)
67
+ if factories
68
+ allow(documentation).to receive_messages(from_description: adapter, from_snapshot: adapter)
69
+ allow(documentation).to receive(:validate_source) { |source| source }
70
+ allow(HaveAPI::RustClient::Generator).to receive(:validate_config)
71
+ allow(HaveAPI::RustClient::Model).to receive(:validate_package)
72
+ end
73
+ documentation
74
+ end
75
+
76
+ def finalize
77
+ profiles.mount([1])
78
+ profiles.finalize(server)
79
+ end
80
+
81
+ it 'needs no optional generator or schema when profiles are absent' do
82
+ allow(profiles).to receive(:require).and_raise('Unexpected optional dependency')
83
+ finalize
84
+ expect(profiles).not_to have_received(:require)
85
+ expect(server).not_to have_received(:describe_version)
86
+ expect(profiles.for_version(1)).to eq({})
87
+ expect(profiles.for_version(1)).to be_frozen
88
+ end
89
+
90
+ it 'loads an explicit saved adapter once without rereading it during finalization' do
91
+ documentation = install_go_adapter
92
+ profiles.register(1, go: go_config)
93
+ expect(profiles.for_version(1)).to be_empty
94
+ File.unlink(description_path)
95
+ finalize
96
+ profile = profiles.for_version(1).fetch(:go)
97
+ expect(profile).to have_attributes(language: :go, package_name: 'example.test/widget/client', package_version: 'v1.2.3')
98
+ expect(profile).to be_frozen
99
+ expect(profile.adapter).to equal(adapter)
100
+ expect(documentation).to have_received(:new).once.with(description: JSON.parse(JSON.generate(description)), import_path: 'example.test/widget/client', module_version: 'v1.2.3')
101
+ expect(server).not_to have_received(:describe_version)
102
+ expect(profile).not_to respond_to(:description_path)
103
+ end
104
+
105
+ it 'defaults the generator version and defers automatic Go without inventing an SDK version' do
106
+ documentation = install_go_adapter
107
+ config = { import_path: 'example.test/widget/client' }
108
+ profiles.register(1, go: config)
109
+ config[:import_path].replace('changed.test/client')
110
+ expect(documentation).not_to have_received(:new)
111
+ finalize
112
+ profile = profiles.for_version(1).fetch(:go)
113
+ expect(profile.package_name).to eq('example.test/widget/client')
114
+ expect(profile.package_version).to be_nil
115
+ expect(documentation).to have_received(:new).with(description: JSON.parse(JSON.generate(description)), import_path: 'example.test/widget/client', module_version: nil)
116
+ expect(server).to have_received(:describe_version).once
117
+ end
118
+
119
+ it 'permits nil SDK versions and retains explicitly pinned generator versions' do
120
+ documentation = install_go_adapter(version: '0.29.0')
121
+ profiles.register(1, go: go_config.merge(module_version: nil, generator_version: '0.29.0'))
122
+ finalize
123
+ expect(profiles.for_version(1).fetch(:go).package_version).to be_nil
124
+ expect(documentation).to have_received(:new).with(description: anything, import_path: anything, module_version: nil)
125
+ end
126
+
127
+ it 'rejects unknown languages, malformed fields and unsafe package configuration' do
128
+ expect { profiles.register(1, python: {}) }.to raise_error(described_class::Invalid, /Unknown/)
129
+ expect { profiles.register(1, go: go_config.merge(unexpected: 'value')) }.to raise_error(described_class::Invalid, /fields/)
130
+ expect { profiles.register(1, go: go_config.merge(import_path: "example.test/\nsource")) }.to raise_error(described_class::Invalid, /package/)
131
+ ['latest', '', 1, false].each do |value|
132
+ expect { profiles.register(1, go: go_config.merge(module_version: value)) }.to raise_error(described_class::Invalid, /package/)
133
+ end
134
+ expect { profiles.register(1, go: go_config.merge(generator_version: nil)) }.to raise_error(described_class::Invalid)
135
+ expect { profiles.register(1, go: go_config.merge(generator_version: 'latest')) }.to raise_error(described_class::Invalid, /generator version/)
136
+ expect { profiles.register(1, go: {}) }.to raise_error(described_class::Invalid, /package/)
137
+ end
138
+
139
+ it 'rejects missing adapters, mismatched generator versions and missing files at registration' do
140
+ allow(profiles).to receive(:require).and_raise(LoadError)
141
+ expect { profiles.register(1, go: go_config) }.to raise_error(described_class::Invalid, /not installed/)
142
+ install_go_adapter
143
+ expect { profiles.register(1, go: go_config.merge(generator_version: '0.29.0')) }.to raise_error(described_class::Invalid, /version mismatch/)
144
+ expect { profiles.register(1, go: go_config.merge(description_path: 'relative.json')) }.to raise_error(described_class::Invalid, /absolute local/)
145
+ end
146
+
147
+ it 'checks the configured adapter interface' do
148
+ documentation = install_go_adapter
149
+ allow(documentation).to receive(:new).and_return(Object.new)
150
+ expect { profiles.register(1, go: go_config) }.to raise_error(described_class::Invalid, /adapter interface/)
151
+ end
152
+
153
+ it 'does not leave a partially registered language set after an invalid second profile' do
154
+ install_go_adapter
155
+ expect { profiles.register(1, go: go_config, rust: { unknown: 'field' }) }.to raise_error(described_class::Invalid)
156
+ profiles.register(1, go: go_config)
157
+ finalize
158
+ expect(profiles.for_version(1).keys).to eq([:go])
159
+ end
160
+
161
+ it 'rejects duplicates and registration after mounting starts' do
162
+ install_go_adapter
163
+ profiles.register(1, go: go_config)
164
+ expect { profiles.register('1', go: go_config) }.to raise_error(described_class::Invalid, /Duplicate/)
165
+ profiles.mount([1])
166
+ expect { profiles.register(2, go: go_config) }.to raise_error(described_class::Invalid, /already mounted/)
167
+ profiles.finalize(server)
168
+ expect { profiles.finalize(server) }.to raise_error(described_class::Invalid, /not ready/)
169
+ end
170
+
171
+ it 'rejects profiles for an unavailable version' do
172
+ install_go_adapter
173
+ profiles.register(1, go: go_config)
174
+ expect { profiles.mount([2]) }.to raise_error(described_class::Invalid, /unavailable API version/)
175
+ expect(profiles.for_version(1)).to be_empty
176
+ end
177
+
178
+ it 'preserves legacy Rust construction even without the new factories' do
179
+ documentation = install_rust_adapter(factories: false)
180
+ File.write(description_path, JSON.generate({ api_version: '1' }))
181
+ profiles.register(1, rust: rust_config)
182
+ finalize
183
+ expect(documentation).to have_received(:new).with(snapshot: { 'api_version' => '1' }, config: generator_config)
184
+ expect(profiles.for_version(1).fetch(:rust)).to have_attributes(package_name: 'widget-client', package_version: '1.2.3')
185
+ end
186
+
187
+ it 'rejects a saved Rust snapshot for a different selected API version at mount' do
188
+ install_rust_adapter
189
+ File.write(description_path, JSON.generate({ api_version: '2' }))
190
+ profiles.register(1, rust: rust_config)
191
+ expect { profiles.mount([1]) }.to raise_error(described_class::Invalid, /snapshot API version mismatch/)
192
+ expect(profiles.for_version(1)).to be_empty
193
+ end
194
+
195
+ it 'validates inline Rust identity and source before building from the mounted description' do
196
+ documentation = install_rust_adapter
197
+ source = { git: 'https://example.test/widget-client', rev: 'a' * 40 }
198
+ profiles.register(1, rust: { crate_name: 'widget-client', source: })
199
+ expect(HaveAPI::RustClient::Model).to have_received(:validate_package).with(crate_name: 'widget-client', crate_version: nil)
200
+ expect(documentation).to have_received(:validate_source).with(source)
201
+ expect(documentation).not_to have_received(:from_description)
202
+ finalize
203
+ expect(documentation).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: HaveAPI::PROTOCOL_VERSION,
204
+ mount_path: '/', crate_name: 'widget-client', crate_version: nil, source:)
205
+ expect(profiles.for_version(1).fetch(:rust).package_version).to be_nil
206
+ end
207
+
208
+ it 'supports an inline Rust identity with a saved snapshot and optional explicit SDK version' do
209
+ documentation = install_rust_adapter
210
+ snapshot = { 'api_version' => '1' }
211
+ File.write(description_path, JSON.generate(snapshot))
212
+ profiles.register(1, rust: { snapshot_path: description_path, crate_name: 'widget-client', crate_version: '1.2.3' })
213
+ finalize
214
+ expect(documentation).to have_received(:from_snapshot).with(snapshot:, crate_name: 'widget-client', crate_version: '1.2.3', source: nil)
215
+ expect(server).not_to have_received(:describe_version)
216
+ end
217
+
218
+ it 'validates full legacy TOML before using it with an automatic Rust description' do
219
+ documentation = install_rust_adapter
220
+ snapshot = { 'api_version' => '1' }
221
+ allow(HaveAPI::RustClient::Snapshot).to receive(:from_description).and_return(snapshot)
222
+ profiles.register(1, rust: { generator_config_path: config_path })
223
+ expect(HaveAPI::RustClient::Generator).to have_received(:validate_config).with(generator_config)
224
+ finalize
225
+ expect(HaveAPI::RustClient::Snapshot).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: HaveAPI::PROTOCOL_VERSION,
226
+ mount_path: '/', source_url: 'https://haveapi.invalid/v1/', profile: 'server-documentation')
227
+ expect(documentation).to have_received(:new).with(snapshot:, config: generator_config)
228
+ end
229
+
230
+ it 'rejects automatic and inline modes when an older Rust generator lacks their factories' do
231
+ install_rust_adapter(factories: false)
232
+ expect { profiles.register(1, rust: { generator_config_path: config_path }) }.to raise_error(described_class::Invalid, /does not support/)
233
+ expect { profiles.register(1, rust: { crate_name: 'widget-client', snapshot_path: description_path }) }.to raise_error(described_class::Invalid, /does not support/)
234
+ end
235
+
236
+ it 'rejects conflicting Rust identities and unknown source profile fields' do
237
+ install_rust_adapter
238
+ expect { profiles.register(1, rust: rust_config.merge(crate_name: 'widget-client')) }.to raise_error(described_class::Invalid, /Conflicting/)
239
+ expect { profiles.register(1, rust: rust_config.merge(crate_version: nil)) }.to raise_error(described_class::Invalid, /Conflicting/)
240
+ expect { profiles.register(1, rust: { crate_name: 'widget-client', branch: 'main' }) }.to raise_error(described_class::Invalid, /fields/)
241
+ end
242
+
243
+ it 'captures one private structural schema per version shared by automatic profiles' do
244
+ go = install_go_adapter
245
+ rust = install_rust_adapter
246
+ profiles.register(1, go: { import_path: 'example.test/client' }, rust: { crate_name: 'widget-client' })
247
+ finalize
248
+ expect(server).to have_received(:describe_version).once do |context|
249
+ expect(context).to have_attributes(server:, version: 1, current_user: nil, endpoint: false, doc: true)
250
+ end
251
+ expect(go).to have_received(:new).with(description: anything, import_path: anything, module_version: nil)
252
+ expect(rust).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: anything, mount_path: '/', crate_name: anything, crate_version: nil, source: nil)
253
+ end
254
+
255
+ it 'publishes no partial profiles and hides private diagnostics when finalization fails' do
256
+ install_go_adapter
257
+ rust = install_rust_adapter
258
+ allow(rust).to receive(:from_description).and_raise('HIDDEN_SCHEMA_DEFAULT')
259
+ profiles.register(1, go: { import_path: 'example.test/client' }, rust: { crate_name: 'widget-client' })
260
+ profiles.mount([1])
261
+ expect { profiles.finalize(server) }.to raise_error(described_class::Invalid, 'Configured documentation client schema or configuration is invalid')
262
+ expect(profiles.for_version(1)).to be_empty
263
+ expect { profiles.register(1, go: go_config) }.to raise_error(described_class::Invalid, /already mounted/)
264
+ end
265
+ end