haveapi 0.31.2 → 0.32.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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/benchmarks/documentation.rb +29 -6
  4. data/benchmarks/endpoints.rb +153 -24
  5. data/benchmarks/spec/client_examples_spec.rb +109 -37
  6. data/doc/client-examples.md +218 -45
  7. data/doc/documentation-oauth.md +188 -0
  8. data/doc/documentation-ui.md +192 -0
  9. data/doc/index.md +3 -6
  10. data/doc/performance.md +48 -14
  11. data/haveapi.gemspec +1 -1
  12. data/lib/haveapi/authentication/chain.rb +19 -0
  13. data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
  14. data/lib/haveapi/client_example.rb +143 -15
  15. data/lib/haveapi/client_examples/curl.rb +30 -20
  16. data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
  17. data/lib/haveapi/client_examples/go_client.rb +9 -2
  18. data/lib/haveapi/client_examples/http.rb +9 -4
  19. data/lib/haveapi/client_examples/js_async_client.rb +7 -0
  20. data/lib/haveapi/client_examples/js_client.rb +47 -36
  21. data/lib/haveapi/client_examples/php_client.rb +161 -75
  22. data/lib/haveapi/client_examples/python_async_client.rb +3 -0
  23. data/lib/haveapi/client_examples/python_client.rb +61 -47
  24. data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
  25. data/lib/haveapi/client_examples/ruby_client.rb +80 -31
  26. data/lib/haveapi/client_examples/rust_client.rb +20 -9
  27. data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
  28. data/lib/haveapi/client_examples/ts_client.rb +3 -0
  29. data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
  30. data/lib/haveapi/documentation_context.rb +7 -2
  31. data/lib/haveapi/documentation_helpers.rb +223 -0
  32. data/lib/haveapi/documentation_oauth.rb +411 -0
  33. data/lib/haveapi/documentation_search.rb +225 -0
  34. data/lib/haveapi/documentation_search_index.rb +79 -0
  35. data/lib/haveapi/documentation_validators.rb +179 -0
  36. data/lib/haveapi/public/css/documentation.css +343 -0
  37. data/lib/haveapi/public/js/documentation.js +992 -0
  38. data/lib/haveapi/server.rb +144 -86
  39. data/lib/haveapi/shared_documentation_helpers.rb +54 -0
  40. data/lib/haveapi/version.rb +1 -1
  41. data/lib/haveapi/views/doc_layout.erb +16 -0
  42. data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
  43. data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
  44. data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
  45. data/lib/haveapi/views/documentation/search_form.erb +10 -0
  46. data/lib/haveapi/views/documentation/search_index.erb +1 -0
  47. data/lib/haveapi/views/documentation/search_results.erb +20 -0
  48. data/lib/haveapi/views/index.erb +2 -4
  49. data/lib/haveapi/views/main_layout.erb +12 -6
  50. data/lib/haveapi/views/shared_documentation.erb +12 -0
  51. data/lib/haveapi/views/shared_sidebar.erb +25 -0
  52. data/lib/haveapi/views/version_page/auth_body.erb +12 -33
  53. data/lib/haveapi/views/version_page/client_overview.erb +40 -0
  54. data/lib/haveapi/views/version_page/example_panels.erb +18 -0
  55. data/lib/haveapi/views/version_page/resource_body.erb +91 -132
  56. data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
  57. data/lib/haveapi/views/version_page/usage.erb +7 -0
  58. data/lib/haveapi/views/version_page.erb +36 -117
  59. data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
  60. data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
  61. data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
  62. data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
  63. data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
  64. data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
  65. data/lib/haveapi/views/version_sidebar.erb +59 -48
  66. data/spec/authentication/basic_spec.rb +10 -10
  67. data/spec/authentication/oauth2_chain_spec.rb +72 -0
  68. data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
  69. data/spec/client_examples/assets/documentation_test.cjs +817 -0
  70. data/spec/client_examples/context_performance_spec.rb +5 -4
  71. data/spec/client_examples/context_spec.rb +23 -2
  72. data/spec/client_examples/detail_spec.rb +175 -0
  73. data/spec/client_examples/dynamic_acceptance.md +59 -26
  74. data/spec/client_examples/dynamic_acceptance.rb +164 -44
  75. data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
  76. data/spec/client_examples/dynamic_fixture.rb +4 -3
  77. data/spec/client_examples/dynamic_js_spec.rb +20 -11
  78. data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
  79. data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
  80. data/spec/client_examples/dynamic_python_spec.rb +62 -11
  81. data/spec/client_examples/go_spec.rb +14 -1
  82. data/spec/client_examples/presentation_spec.rb +247 -0
  83. data/spec/client_examples/profile_mount_spec.rb +40 -13
  84. data/spec/client_examples/rust_client_spec.rb +35 -1
  85. data/spec/client_examples/shell_spec.rb +268 -0
  86. data/spec/client_examples/syntax_spec.rb +192 -0
  87. data/spec/client_examples/ts_generated_client_spec.rb +15 -2
  88. data/spec/client_examples/typescript_profiles_spec.rb +1 -1
  89. data/spec/documentation/auth_filtering_spec.rb +9 -4
  90. data/spec/documentation/canonical_routes_spec.rb +89 -0
  91. data/spec/documentation/client_examples_spec.rb +39 -5
  92. data/spec/documentation/client_overview_spec.rb +145 -0
  93. data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
  94. data/spec/documentation/example_variants_spec.rb +93 -0
  95. data/spec/documentation/fixtures/search_index.json +476 -0
  96. data/spec/documentation/identity_and_guides_spec.rb +130 -0
  97. data/spec/documentation/identity_context_spec.rb +180 -0
  98. data/spec/documentation/navigation_spec.rb +296 -0
  99. data/spec/documentation/oauth_fixture_spec.rb +39 -0
  100. data/spec/documentation/oauth_spec.rb +1058 -0
  101. data/spec/documentation/oauth_transport_spec.rb +45 -0
  102. data/spec/documentation/performance_spec.rb +47 -6
  103. data/spec/documentation/reference_headings_spec.rb +190 -0
  104. data/spec/documentation/search_index_routes_spec.rb +202 -0
  105. data/spec/documentation/search_index_spec.rb +85 -0
  106. data/spec/documentation/search_routes_spec.rb +199 -0
  107. data/spec/documentation/search_spec.rb +134 -0
  108. data/spec/documentation/shared_pages_spec.rb +240 -0
  109. data/spec/documentation/validators_spec.rb +123 -0
  110. data/spec/documentation_spec.rb +3 -3
  111. data/spec/packaging_spec.rb +21 -2
  112. data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
  113. data/spec/performance/documentation_shapes_spec.rb +97 -0
  114. data/spec/performance/event_baseline_spec.rb +97 -2
  115. data/spec/performance/json_activation_spec.rb +78 -1
  116. data/spec/performance/release_lineage_spec.rb +4 -3
  117. data/spec/performance/runner_spec.rb +1 -0
  118. data/test_support/all_client_performance_fixture.rb +6 -10
  119. data/test_support/documentation_oauth_fixture.rb +54 -0
  120. data/test_support/documentation_performance_fixture.rb +19 -1
  121. data/test_support/dynamic_documentation_api.rb +35 -10
  122. data/test_support/dynamic_python_login_spec.rb +3 -11
  123. metadata +52 -3
@@ -0,0 +1,247 @@
1
+ require 'spec_helper'
2
+ require 'tmpdir'
3
+ require_relative 'dynamic_fixture'
4
+
5
+ RSpec.describe HaveAPI::ClientExample do
6
+ include DynamicExampleFixture
7
+
8
+ it 'retains all registered direct lanes while keeping authored Ruby and PHP assembly separators clean' do
9
+ expect(described_class.clients.length).to eq(15)
10
+ sample = { path_params: [7], request: { name: 'widget' }, response: { id: 7 }, status: true }
11
+ action = dynamic_action.merge(path: '/mount/v2/widgets/{widget_id}')
12
+ base = dynamic_context(resource_path: ['widget'], action_name: 'show', action:, sample:)
13
+ lanes = [[:init, base, nil], [:auth, base, :basic], [:auth, base, :token], [:auth, base, :oauth2],
14
+ [:example, base, nil], [:example, base.with(action: action.merge(auth: true)), nil],
15
+ [:example, base.with(sample: sample.merge(status: false)), nil]]
16
+ described_class.clients.each do |renderer|
17
+ %i[simple thorough].each do |detail|
18
+ lanes.each do |kind, context, provider|
19
+ result = renderer.render(kind, context: context.with(detail:), auth_method: provider, auth_description: token_description)
20
+ expect(result).to include(status: satisfy { |status| %i[code limitation].include?(status) })
21
+ next unless result[:status] == :code && %w[ruby php].include?(renderer.language)
22
+
23
+ source = result.fetch(:code)
24
+ expect(source).not_to match(/\A\n|\n\n\z|\n[ \t]+\n|\n\n\n/)
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ it 'preserves custom renderer whitespace and literal content exactly' do
31
+ source = "\ncustom literal\n\n\n content\n\n"
32
+ renderer = Class.new(described_class) { define_method(:init) { source } }
33
+ expect(renderer.render(:init, context: dynamic_context).fetch(:code)).to eq(source)
34
+ end
35
+
36
+ it 'keeps built-in client IDs, order and language metadata together' do
37
+ expected = [
38
+ ['0', 'ruby', 'Ruby', nil],
39
+ %w[javascript-callback javascript JavaScript Callback],
40
+ %w[javascript-async javascript JavaScript Async],
41
+ %w[typescript-callback typescript TypeScript Callback],
42
+ %w[typescript-async typescript TypeScript Async],
43
+ %w[python-sync python Python Sync],
44
+ %w[python-async python Python Async],
45
+ ['go', 'go', 'Go', nil],
46
+ %w[rust-async rust Rust Async],
47
+ %w[rust-blocking rust Rust Blocking],
48
+ ['typescript-generated', 'typescript', 'TypeScript', 'API client'],
49
+ ['2', 'php', 'PHP', nil], ['3', 'cli', 'CLI', nil],
50
+ ['5', 'curl', 'curl', nil], ['6', 'http', 'HTTP', nil]
51
+ ]
52
+ actual = described_class.clients.map { |renderer| [renderer.id, renderer.language, renderer.language_label, renderer.variant] }
53
+ expect(actual).to eq(expected)
54
+ end
55
+
56
+ it 'defaults unspecified third-party metadata to its own ID and label without inheriting a built-in group' do
57
+ renderer = Class.new(HaveAPI::ClientExamples::PythonClient) do
58
+ def self.label
59
+ 'Custom client'
60
+ end
61
+ end
62
+ stub_const('CustomDocumentationExample', renderer)
63
+
64
+ expect(renderer.language).to eq(renderer.id)
65
+ expect(renderer.language_label).to eq('Custom client')
66
+ expect(renderer.variant).to be_nil
67
+ end
68
+
69
+ it 'defaults undeclared custom renderers to both frozen detail levels' do
70
+ renderer = Class.new(described_class)
71
+ expect(renderer.detail_levels).to eq(%i[simple thorough])
72
+ expect(renderer.detail_levels).to be_frozen
73
+ expect { renderer.detail_levels << :other }.to raise_error(FrozenError)
74
+ end
75
+
76
+ it 'inherits the nearest detail declaration and freezes an independently owned override' do
77
+ parent = Class.new(described_class)
78
+ levels = [:simple]
79
+ parent.detail_levels(*levels)
80
+ levels << :thorough
81
+ child = Class.new(parent)
82
+ grandchild = Class.new(child)
83
+ expect(child.detail_levels).to equal(parent.detail_levels)
84
+ expect(grandchild.detail_levels).to eq([:simple])
85
+ child.detail_levels :simple, :thorough
86
+ expect(child.detail_levels).to be_frozen
87
+ expect(grandchild.detail_levels).to equal(child.detail_levels)
88
+ expect(parent.detail_levels).to eq([:simple])
89
+ end
90
+
91
+ it 'rejects unsupported, duplicate, reversed and non-symbol detail declarations without changing metadata' do
92
+ renderer = Class.new(described_class)
93
+ renderer.detail_levels :simple
94
+ [[:thorough], %i[thorough simple], %i[simple simple], %i[simple thorough thorough],
95
+ [:other], ['simple'], [nil], [[:simple]]].each do |values|
96
+ expect { renderer.detail_levels(*values) }.to raise_error(ArgumentError)
97
+ expect(renderer.detail_levels).to eq([:simple])
98
+ end
99
+ end
100
+
101
+ it 'declares one format for HTTP, inherited curl and CLI without changing registry IDs or other clients' do
102
+ single_level = [HaveAPI::ClientExamples::Http, HaveAPI::ClientExamples::Curl, HaveAPI::ClientExamples::RubyCli]
103
+ registry = described_class.clients
104
+ single_level.each do |renderer|
105
+ expect(renderer.detail_levels).to eq([:simple])
106
+ expect(renderer.detail_levels).to be_frozen
107
+ end
108
+ expect(HaveAPI::ClientExamples::Curl.detail_levels).to equal(HaveAPI::ClientExamples::Http.detail_levels)
109
+ (registry - single_level).each { |renderer| expect(renderer.detail_levels).to eq(%i[simple thorough]) }
110
+ expect(described_class.clients).to eq(registry)
111
+ end
112
+
113
+ it 'uses simple for legacy positional instances and reads thorough only from its context' do
114
+ renderer = described_class.new('api.test', 'https://api.test/', '1')
115
+ context = described_class::Context.new(version: '1', host: 'api.test', base_url: 'https://api.test/',
116
+ version_url: 'https://api.test/v1/', description: {})
117
+ expect(renderer.thorough?).to be(false)
118
+ expect(renderer.with_context(context).thorough?).to be(false)
119
+ expect(renderer.with_context(context.with(detail: :thorough)).thorough?).to be(true)
120
+ end
121
+
122
+ it 'keeps a custom Simple limitation and its distinct thorough result under the legacy declaration' do
123
+ renderer = Class.new(described_class) do
124
+ def init
125
+ thorough? ? 'custom recovery instructions' : { status: :limitation, reason: 'custom Simple limitation' }
126
+ end
127
+ end
128
+ context = described_class::Context.new(version: '1', host: 'api.test', base_url: 'https://api.test/',
129
+ version_url: 'https://api.test/v1/', description: {})
130
+ result = renderer.render(:init, context:)
131
+ expect(result).to include(status: :limitation)
132
+ expect(renderer.detail_levels_for(:init, context:, result:)).to equal(renderer.detail_levels)
133
+ expect(renderer.render(:init, context: context.with(detail: :thorough))).to include(code: 'custom recovery instructions')
134
+ renderer.detail_levels :simple
135
+ expect(renderer.detail_levels_for(:init, context:, result:)).to equal(renderer.detail_levels)
136
+ expect(context.detail).to eq(:simple)
137
+ end
138
+
139
+ it 'retains inherited legacy declarations for every wrapper without invoking the adapter' do
140
+ calls = []
141
+ adapter = Class.new do
142
+ define_method(:resolve) { |**| calls << :resolve }
143
+ define_method(:render) { |**| calls << :render }
144
+ end.new.freeze
145
+ limitation = { status: :limitation, reason: 'Simple format unavailable' }.freeze
146
+ [[HaveAPI::ClientExamples::GoClient, :go],
147
+ [HaveAPI::ClientExamples::RustAsyncClient, :rust],
148
+ [HaveAPI::ClientExamples::RustBlockingClient, :rust],
149
+ [HaveAPI::ClientExamples::TsGeneratedClient, :typescript]].each do |wrapper, language|
150
+ profile = described_class::Profile.new(language:, package_name: 'fixture-client', package_version: nil, adapter:)
151
+ context = described_class::Context.new(version: '1', host: 'api.test', base_url: 'https://api.test/',
152
+ version_url: 'https://api.test/v1/', description: {}, profile:)
153
+ child = Class.new(wrapper)
154
+ expect(child.detail_levels_for(:init, context:, result: limitation)).to equal(wrapper.detail_levels)
155
+ expect(child.detail_levels_for(:init, context:, result: limitation)).to eq(%i[simple thorough])
156
+ child.detail_levels :simple
157
+ expect(child.detail_levels_for(:init, context:, result: limitation)).to equal(child.detail_levels)
158
+ expect(child.detail_levels_for(:init, context:, result: limitation)).to be_frozen
159
+ expect(context.profile).to equal(profile)
160
+ expect(context.profile.adapter).to equal(adapter)
161
+ end
162
+ expect(calls).to be_empty
163
+ end
164
+
165
+ it 'keeps a pinned saved-profile adapter with only resolve/render through actual wrapper selection' do
166
+ calls = []
167
+ adapter = Class.new do
168
+ define_method(:resolve) { |**| calls << :resolve }
169
+ define_method(:render) do |kind:, context:, mode:, **|
170
+ calls << [kind, context.detail, mode]
171
+ if context.detail == :thorough
172
+ { status: :code, code: 'legacy recovery instructions' }.freeze
173
+ else
174
+ { status: :limitation, reason: 'legacy Simple limitation' }.freeze
175
+ end
176
+ end
177
+ end.new
178
+ profiles = described_class::Profiles.new
179
+ generator = Module.new
180
+ generator.const_set(:VERSION, '0.29.0')
181
+ documentation = Class.new
182
+ generator.const_set(:Documentation, documentation)
183
+ stub_const('HaveAPI::GoClient', generator)
184
+ allow(profiles).to receive(:require).with('haveapi/go_client/documentation').and_return(true)
185
+ allow(documentation).to receive(:new).and_return(adapter)
186
+ Dir.mktmpdir('haveapi-legacy-detail-') do |directory|
187
+ path = File.join(directory, 'description.json')
188
+ File.write(path, JSON.generate(resources: {}, authentication: {}, meta: { namespace: '_meta' }))
189
+ profiles.register(1, go: { import_path: 'example.test/client', description_path: path,
190
+ generator_version: '0.29.0' })
191
+ profiles.mount([1])
192
+ server = instance_double(HaveAPI::Server)
193
+ profiles.finalize(server)
194
+ profile = profiles.for_version(1).fetch(:go)
195
+ context = described_class::Context.new(version: '1', host: 'api.test', base_url: 'https://api.test/',
196
+ version_url: 'https://api.test/v1/', description: {}, profile:)
197
+ wrapper = HaveAPI::ClientExamples::GoClient
198
+ simple = wrapper.render(:init, context:)
199
+ levels = wrapper.detail_levels_for(:init, context:, result: simple)
200
+ expect(levels).to eq(%i[simple thorough])
201
+ expect(levels).to be_frozen
202
+ expect(calls).to eq([%i[init simple sync]])
203
+ panels = levels.map do |detail|
204
+ [detail, detail == :simple ? simple : wrapper.render(:init, context: context.with(detail:))]
205
+ end
206
+ expect(panels).to eq([
207
+ [:simple, { status: :limitation, reason: 'legacy Simple limitation' }],
208
+ [:thorough, { status: :code, code: 'legacy recovery instructions' }]
209
+ ])
210
+ expect(calls).to eq([%i[init simple sync], %i[init thorough sync]])
211
+ expect(profile.adapter).to equal(adapter)
212
+ expect(profile).to be_frozen
213
+ expect(adapter).to be_frozen
214
+ expect(adapter).not_to respond_to(:detail_levels_for)
215
+ expect(context.detail).to eq(:simple)
216
+ expect(documentation).to have_received(:new).once
217
+ end
218
+ end
219
+
220
+ it 'delegates modern limitation queries exactly once and propagates exceptions and invalid levels' do
221
+ wrapper = HaveAPI::ClientExamples::GoClient
222
+ adapter = Object.new
223
+ def adapter.detail_levels_for(**); end
224
+ allow(adapter).to receive(:detail_levels_for).and_return([:simple])
225
+ context = Object.new.freeze
226
+ result = { status: :limitation, reason: 'current limitation' }.freeze
227
+ auth_description = { http_header: 'X-Token' }.freeze
228
+ levels = wrapper.adapter_detail_levels_for(adapter:, kind: :auth, context:, result:, mode: :sync,
229
+ auth_method: :token, auth_description:)
230
+ expect(levels).to eq([:simple])
231
+ expect(levels).to be_frozen
232
+ expect(adapter).to have_received(:detail_levels_for).once.with(
233
+ kind: :auth, context:, result:, mode: :sync, auth_method: :token, auth_description:
234
+ )
235
+ [nil, [], [:thorough], %i[thorough simple], %i[simple simple], ['simple']].each do |invalid|
236
+ allow(adapter).to receive(:detail_levels_for).and_return(invalid)
237
+ expect do
238
+ wrapper.adapter_detail_levels_for(adapter:, kind: :init, context:, result:, mode: :sync)
239
+ end.to raise_error(ArgumentError)
240
+ end
241
+ failure = RuntimeError.new('modern query failure')
242
+ allow(adapter).to receive(:detail_levels_for).and_raise(failure)
243
+ expect do
244
+ wrapper.adapter_detail_levels_for(adapter:, kind: :init, context:, result:, mode: :sync)
245
+ end.to(raise_error { |error| expect(error).to equal(failure) })
246
+ end
247
+ end
@@ -1,5 +1,5 @@
1
1
  require 'spec_helper'
2
- require 'base64'
2
+ require_relative '../../test_support/documentation_oauth_fixture'
3
3
 
4
4
  module ProfileMountSpec
5
5
  User = Struct.new(:login)
@@ -132,10 +132,21 @@ describe HaveAPI::Server, '#mount' do
132
132
  server.documentation_clients(version, go: { import_path: 'example.test/widget/client' })
133
133
  end
134
134
 
135
- def request_as(user, locale)
135
+ def configure_documentation_identity
136
+ fixture = DocumentationOAuthFixture.configure(
137
+ server, users: { 1 => %w[member admin].to_h { |role| [role, ProfileMountSpec::User.new(role)] } },
138
+ mount_path: '/mounted/api/'
139
+ )
140
+ server.define_singleton_method(:documentation_fixture) { fixture }
141
+ end
142
+
143
+ def request_as(user, locale, resource: nil)
144
+ fixture = server.documentation_fixture
145
+ path = '/mounted/api/v1/'
146
+ path += "?resource=#{CGI.escape(resource)}" if resource
136
147
  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")}"
148
+ "#{fixture.origin}#{path}", 'HTTP_HOST' => 'example.org', 'HTTP_ACCEPT_LANGUAGE' => locale,
149
+ 'HTTP_COOKIE' => fixture.header_for(server, version: 1, token: user)
139
150
  )
140
151
  end
141
152
 
@@ -179,6 +190,7 @@ describe HaveAPI::Server, '#mount' do
179
190
  it 'captures each configured version once and does not initialize from requests' do
180
191
  register(1)
181
192
  register(2)
193
+ configure_documentation_identity
182
194
  server.mount('/mounted/api/')
183
195
  expect(descriptions.map { |description| description['help'] }).to eq(['/mounted/api/v1/', '/mounted/api/v2/'])
184
196
  request_as('member', 'en')
@@ -189,24 +201,32 @@ describe HaveAPI::Server, '#mount' do
189
201
 
190
202
  it 'keeps private schema separate from sequential users, locales, HTML and samples' do
191
203
  register
204
+ configure_documentation_identity
192
205
  server.available_locales = %i[en cs]
193
206
  ::I18n.backend.store_translations(:en, profile_mount_spec: { action: 'Visible English action' })
194
207
  ::I18n.backend.store_translations(:cs, profile_mount_spec: { action: 'Viditelna ceska akce' })
195
208
  server.mount('/mounted/api/')
196
- privileged = request_as('admin', 'en')
209
+ privileged = request_as('admin', 'en', resource: 'widget')
197
210
  expect(privileged.status).to eq(200)
198
211
  expect(privileged.body).to include('PRIVATE_SAMPLE', 'Visible English action')
212
+ expect(contexts.select { |context| context.sample&.dig(:request, :secret) == 'PRIVATE_SAMPLE' }.map(&:detail))
213
+ .to contain_exactly(:simple, :thorough)
214
+ public_overview = request_as('member', 'cs')
215
+ expect(public_overview.status).to eq(200)
216
+ expect(public_overview.body).to include('init-go', 'example.test/widget/client')
217
+ expect(public_overview.body).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE')
199
218
  contexts.clear
200
- public_response = request_as('member', 'cs')
219
+ public_response = request_as('member', 'cs', resource: 'widget')
201
220
  expect(public_response.status).to eq(200)
202
- expect(public_response.body).to include('Viditelna ceska akce', 'init-go', 'example.test/widget/client')
221
+ expect(public_response.body).to include('Viditelna ceska akce', 'example-widget-show-0-go', 'example-widget-show-0-go-thorough')
203
222
  expect(public_response.body).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE', 'admin_only', 'haveapi.invalid', 'StructuralSchema')
204
223
  selected = contexts.find { |context| context.action_name == 'show' }
205
224
  expect(selected.action.dig(:input, :parameters).keys.map(&:to_s)).to eq(['visible'])
206
225
  expect(selected.description[:resources]['widget'][:actions]).not_to have_key('secret')
207
226
  expect(selected.sample[:request]).to eq(visible: 'public sample')
208
227
  expect(selected.action[:description]).to eq('Viditelna ceska akce')
209
- expect(request_as('member', 'en').body).to include('Visible English action')
228
+ expect(contexts.select { |context| context.action_name == 'show' }.map(&:detail)).to contain_exactly(:simple, :thorough)
229
+ expect(request_as('member', 'en', resource: 'widget').body).to include('Visible English action')
210
230
  expect(descriptions.size).to eq(1)
211
231
  end
212
232
 
@@ -229,23 +249,30 @@ describe HaveAPI::Server, '#mount' do
229
249
 
230
250
  it 'mounts a generated TypeScript panel beside dynamic tabs using only authorized request samples' do
231
251
  register_typescript
252
+ configure_documentation_identity
232
253
  server.mount('/mounted/api/')
233
254
  expect(descriptions.size).to eq(1)
234
255
  private_schema = descriptions.first
235
256
  expect(private_schema).to be_frozen
236
257
  expect(JSON.generate(private_schema)).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE')
237
258
  expect(private_schema.dig('resources', 'widget', 'actions')).to have_key('secret')
238
- expect(request_as('admin', 'en').body).to include('PRIVATE_SAMPLE')
259
+ expect(request_as('admin', 'en', resource: 'widget').body).to include('PRIVATE_SAMPLE')
260
+ overview = request_as('member', 'en')
261
+ expect(overview.status).to eq(200)
262
+ expect(overview.body).to include('init-typescript-generated', 'init-typescript-callback',
263
+ 'init-typescript-async', '@example/widget-client')
264
+ expect(overview.body).not_to include('PRIVATE_SAMPLE', 'example-widget-show-0-typescript-generated')
239
265
  contexts.clear
240
- response = request_as('member', 'en')
266
+ response = request_as('member', 'en', resource: 'widget')
241
267
  expect(response.status).to eq(200)
242
- expect(response.body).to include('TypeScript (generated)', 'init-typescript-generated', 'init-typescript-callback',
243
- 'init-typescript-async', 'example-widget-show-0-typescript-generated', '@example/widget-client')
268
+ expect(response.body).to include('TypeScript API client', 'example-widget-show-0-typescript-generated',
269
+ 'example-widget-show-0-typescript-generated-thorough')
244
270
  expect(response.body).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE', 'admin_only')
245
271
  selected = contexts.find { |context| context.action_name == 'show' }
246
- expect(selected.version_url).to eq('http://example.org/mounted/api/v1/')
272
+ expect(selected.version_url).to eq('https://example.org/mounted/api/v1/')
247
273
  expect(selected.action.dig(:input, :parameters).keys).to eq([:visible])
248
274
  expect(selected.sample[:request]).to eq(visible: 'public sample')
275
+ expect(contexts.select { |context| context.action_name == 'show' }.map(&:detail)).to contain_exactly(:simple, :thorough)
249
276
  expect(documentation).to have_received(:from_description).once
250
277
  end
251
278
 
@@ -3,6 +3,8 @@ require 'haveapi/documentation_context'
3
3
  require 'haveapi/client_examples/rust_client'
4
4
 
5
5
  class RustDocumentationAdapterFixture
6
+ def detail_levels_for(kind:, context:, result:, mode:, auth_method: nil, auth_description: nil); end
7
+
6
8
  def render(kind:, context:, mode:, auth_method: nil, auth_description: nil); end
7
9
  end
8
10
 
@@ -61,7 +63,7 @@ describe HaveAPI::ClientExamples::RustClient do
61
63
  end
62
64
 
63
65
  it 'preserves a concise adapter limitation without fabricating source' do
64
- result = { status: :limitation, reason: 'This sample needs an unsupported SDK capability.' }.freeze
66
+ result = { status: :limitation, reason: 'This sample needs an unsupported API client capability.' }.freeze
65
67
  allow(adapter).to receive(:render).and_return(result)
66
68
  expect(HaveAPI::ClientExamples::RustAsyncClient.render(:example, context:)).to eq(result)
67
69
  end
@@ -72,4 +74,36 @@ describe HaveAPI::ClientExamples::RustClient do
72
74
  expect(renderer.auth(:basic, {})).to include(status: :limitation)
73
75
  expect(renderer.example({})).to include(status: :limitation)
74
76
  end
77
+
78
+ it 'delegates each mode capability with the exact Simple result without rerendering or mutating context' do
79
+ description = { http_header: 'X-Token' }.freeze
80
+ [HaveAPI::ClientExamples::RustAsyncClient, HaveAPI::ClientExamples::RustBlockingClient].each do |renderer|
81
+ allow(adapter).to receive(:detail_levels_for).and_return(%i[simple thorough].freeze)
82
+ levels = renderer.detail_levels_for(:auth, context:, result: code,
83
+ auth_method: :token, auth_description: description)
84
+ expect(levels).to eq(%i[simple thorough])
85
+ expect(levels).to be_frozen
86
+ expect(adapter).to have_received(:detail_levels_for).with(
87
+ kind: :auth, context:, result: code, mode: renderer.rust_mode,
88
+ auth_method: :token, auth_description: description
89
+ )
90
+ end
91
+ expect(adapter).not_to have_received(:render)
92
+ expect(context.detail).to eq(:simple)
93
+ expect(context.profile.adapter).to equal(adapter)
94
+ end
95
+
96
+ it 'delegates limitation policy and rejects malformed modern levels instead of falling back' do
97
+ renderer = HaveAPI::ClientExamples::RustAsyncClient
98
+ limitation = { status: :limitation, reason: 'Unsupported sample' }.freeze
99
+ allow(adapter).to receive(:detail_levels_for).and_return([:simple].freeze)
100
+ expect(renderer.detail_levels_for(:example, context:, result: limitation)).to eq([:simple])
101
+ expect(adapter).to have_received(:detail_levels_for).with(
102
+ kind: :example, context:, result: limitation, mode: :async, auth_method: nil, auth_description: nil
103
+ )
104
+ [nil, [], [:thorough], %i[thorough simple], %i[simple simple], ['simple']].each do |invalid|
105
+ allow(adapter).to receive(:detail_levels_for).and_return(invalid)
106
+ expect { renderer.detail_levels_for(:example, context:, result: code) }.to raise_error(ArgumentError)
107
+ end
108
+ end
75
109
  end