haveapi 0.31.1 → 0.32.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 (122) 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 +157 -0
  8. data/doc/documentation-ui.md +192 -0
  9. data/doc/index.md +3 -6
  10. data/doc/performance.md +39 -12
  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 +399 -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 +912 -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/json_activation_spec.rb +78 -1
  115. data/spec/performance/release_lineage_spec.rb +4 -3
  116. data/spec/performance/runner_spec.rb +1 -0
  117. data/test_support/all_client_performance_fixture.rb +6 -10
  118. data/test_support/documentation_oauth_fixture.rb +54 -0
  119. data/test_support/documentation_performance_fixture.rb +19 -1
  120. data/test_support/dynamic_documentation_api.rb +35 -10
  121. data/test_support/dynamic_python_login_spec.rb +3 -11
  122. metadata +52 -3
@@ -0,0 +1,89 @@
1
+ require 'spec_helper'
2
+
3
+ describe HaveAPI::Server, '#mount' do
4
+ api do
5
+ define_resource(:Ping) do
6
+ version :all
7
+ auth false
8
+ define_action(:Show) do
9
+ route 'ping'
10
+ http_method :get
11
+ output(:hash) { string :value }
12
+ authorize { allow }
13
+ def exec
14
+ { value: 'pong' }
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ def server_at(mount = '/')
21
+ server = HaveAPI::Server.new(get_opt(:api_module))
22
+ server.use_version([1, '7.0'])
23
+ server.mount(mount)
24
+ server
25
+ end
26
+
27
+ def request(server, method, path, **env)
28
+ Rack::MockRequest.new(server.app).request(method, path, { 'HTTP_HOST' => 'example.test' }.merge(env))
29
+ end
30
+
31
+ it 'redirects only declared slashless GET/HEAD roots with raw query bytes and no loop' do
32
+ server = server_at
33
+ %w[GET HEAD].product(%w[1 7.0]).each do |method, version|
34
+ query = 'page=resources&q=a+b%2f%2F&raw=%2520'
35
+ response = request(server, method, "/v#{version}?#{query}")
36
+ expect(response.status).to eq(308)
37
+ expect(response['Location']).to eq("/v#{version}/?#{query}")
38
+ expect(response['Cache-Control']).to eq('private, no-store')
39
+ canonical = request(server, method, "/v#{version}/")
40
+ expect(canonical.status).to eq(200)
41
+ expect(canonical['Location']).to be_nil
42
+ end
43
+ end
44
+
45
+ it 'includes Rack SCRIPT_NAME exactly once under the API mount' do
46
+ server = server_at('/mounted/api/')
47
+ response = request(server, 'GET', '/mounted/api/v7.0?resource=ping', 'SCRIPT_NAME' => '/rack')
48
+ expect(response.status).to eq(308)
49
+ expect(response['Location']).to eq('/rack/mounted/api/v7.0/?resource=ping')
50
+ end
51
+
52
+ it 'does not reinterpret unknown versions, action paths, OPTIONS or mutations as documentation redirects' do
53
+ server = server_at
54
+ [['GET', '/v2'], ['GET', '/v1/pings'], ['OPTIONS', '/v1'], ['POST', '/v1'], ['PUT', '/v1'], ['DELETE', '/v1']].each do |method, path|
55
+ expect(request(server, method, path).status).not_to eq(308), "#{method} #{path}"
56
+ end
57
+ expect(request(server, 'GET', '/v1/pings/ping').status).to eq(200)
58
+ expect(request(server, 'OPTIONS', '/v1/').status).to eq(200)
59
+ end
60
+
61
+ it 'rejects literal header controls but preserves their encoded query representation' do
62
+ server = server_at
63
+ ["a\rX: value", "a\n", "a\0", "a\x7f"].each do |query|
64
+ response = request(server, 'GET', '/v1', 'QUERY_STRING' => query)
65
+ expect(response.status).to eq(400)
66
+ expect(response['Location']).to be_nil
67
+ expect(response['Cache-Control']).to eq('private, no-store')
68
+ end
69
+ response = request(server, 'GET', '/v1?q=%0D%0A')
70
+ expect(response.status).to eq(308)
71
+ expect(response['Location']).to eq('/v1/?q=%0D%0A')
72
+ end
73
+
74
+ it 'keeps legacy login/logout as credential-free default-overview redirects' do
75
+ server = server_at('/mounted/api/')
76
+ allow(server.auth_chain).to receive(:authenticate).and_call_original
77
+ %w[_login _logout].product(%w[GET HEAD]).each do |path, method|
78
+ response = request(server, method, "/mounted/api/#{path}?back=https://foreign.example/", 'SCRIPT_NAME' => '/rack',
79
+ 'HTTP_REFERER' => 'https://foreign.example/',
80
+ 'HTTP_AUTHORIZATION' => 'Basic malformed')
81
+ expect(response.status).to eq(303)
82
+ expect(response['Location']).to eq('/rack/mounted/api/v7.0/')
83
+ expect(response['Cache-Control']).to eq('private, no-store')
84
+ expect(response['WWW-Authenticate']).to be_nil
85
+ expect(response['Set-Cookie']).to be_nil
86
+ end
87
+ expect(server.auth_chain).not_to have_received(:authenticate)
88
+ end
89
+ end
@@ -21,6 +21,17 @@ describe HaveAPI::ClientExample do
21
21
  path_params 1
22
22
  response({ name: 'documented widget' })
23
23
  end
24
+
25
+ example 'hostile success transcript' do
26
+ path_params 1
27
+ response({ name: "$(echo unsafe)\r\n; echo '<unsafe>'\rtrailing\\" })
28
+ end
29
+
30
+ example 'hostile failure transcript' do
31
+ path_params 1
32
+ status false
33
+ message "$(echo unsafe)\r\n; echo '<unsafe>'\rtrailing\\"
34
+ end
24
35
  # rubocop:enable RSpec/NoExpectationExample
25
36
  end
26
37
  end
@@ -39,7 +50,7 @@ describe HaveAPI::ClientExample do
39
50
 
40
51
  expect(last_response.status).to eq(200)
41
52
  expect(last_response.body).not_to include('File system', 'haveapi-fs', 'FUSE-based file system')
42
- expect(last_response.body).to include('Initialization', '<h4>Ruby</h4>', '<h4>PHP</h4>', '<h4>curl</h4>')
53
+ expect(last_response.body).to include('Initialization', '<summary>Ruby', '<summary>PHP', '<summary>curl')
43
54
  end
44
55
 
45
56
  it 'hides unconfigured generated SDKs in overview, initialization, authentication and actions' do
@@ -47,6 +58,7 @@ describe HaveAPI::ClientExample do
47
58
  expect(last_response.status).to eq(200)
48
59
  expect(last_response.body).not_to include('clients/go', 'clients/rust', 'init-go', 'init-rust', 'typescript-generated', '>Go</h', '>Rust (')
49
60
  expect(last_response.body).to include('init-javascript-callback', 'init-typescript-async', 'init-python-sync')
61
+ get '/v1/?resource=widget'
50
62
  expect(last_response.body).to include('example-widget-show-0-javascript-callback')
51
63
  end
52
64
 
@@ -56,13 +68,18 @@ describe HaveAPI::ClientExample do
56
68
  allow(HaveAPI::ClientExamples::GoClient).to receive(:render).and_return({ status: :limitation, reason: 'Unsupported <sample> & value' }.freeze)
57
69
  get '/v1/'
58
70
  expect(last_response.status).to eq(200)
59
- expect(last_response.body).to include('clients/go', 'init-go', 'example-widget-show-0-go', 'Unsupported &lt;sample&gt; &amp; value')
71
+ expect(last_response.body).to include('clients/go', 'init-go', 'Unsupported &lt;sample&gt; &amp; value')
60
72
  expect(last_response.body).not_to include('Unsupported <sample>', 'clients/rust', 'init-rust')
73
+ get '/v1/?resource=widget'
74
+ expect(last_response.body).to include('example-widget-show-0-go', 'Unsupported &lt;sample&gt; &amp; value')
61
75
  end
62
76
 
63
77
  it 'passes mounted version-aware URLs and stable canonical action data into renderers' do
64
78
  contexts = []
65
- allow(HaveAPI::ClientExamples::GoClient).to receive(:available?).and_return(true)
79
+ allow(HaveAPI::ClientExamples::GoClient).to receive_messages(
80
+ available?: true,
81
+ detail_levels_for: HaveAPI::ClientExamples::GoClient.detail_levels
82
+ )
66
83
  allow(HaveAPI::ClientExamples::GoClient).to receive(:render) do |kind, context:, **_kwargs|
67
84
  contexts << [kind, context]
68
85
  { status: :code, code: context.version_url }
@@ -70,7 +87,7 @@ describe HaveAPI::ClientExample do
70
87
  server = HaveAPI::Server.new(get_opt(:api_module))
71
88
  server.use_version(1)
72
89
  server.mount('/mounted/api/')
73
- response = Rack::MockRequest.new(server.app).get('/mounted/api/v1/', 'HTTP_HOST' => 'example.org')
90
+ response = Rack::MockRequest.new(server.app).get('/mounted/api/v1/?resource=widget', 'HTTP_HOST' => 'example.org')
74
91
  expect(response.status).to eq(200)
75
92
  selected = contexts.find { |kind, ctx| kind == :example && ctx.action_name == 'show' }.last
76
93
  expect(selected.base_url).to eq('http://example.org/mounted/api/')
@@ -86,7 +103,24 @@ describe HaveAPI::ClientExample do
86
103
  allow(HaveAPI::ClientExamples::GoClient).to receive(:render).and_return({ status: :code, code: 'example' })
87
104
  get '/v1/'
88
105
  expect(last_response.status).to eq(200)
89
- expect(last_response.body).to include('<code>example.test/&lt;client&gt;&amp;</code></li>')
106
+ expect(last_response.body).to include('<code>example.test/&lt;client&gt;&amp;</code>')
90
107
  expect(last_response.body).not_to include('example.test/<client>', '&amp;lt;', 'version = "latest"')
91
108
  end
109
+
110
+ it 'preserves inert shell response comments in actual HTML after entity and physical-line decoding' do
111
+ get '/v1/?resource=widget'
112
+ expect(last_response.status).to eq(200)
113
+ panels = last_response.body.scan(%r{<details\b[^>]*id="(example-widget-show-[12]-[35])"[^>]*>.*?<code[^>]*>(.*?)</code>}m).to_h
114
+ expect(panels.keys).to contain_exactly('example-widget-show-1-3', 'example-widget-show-1-5',
115
+ 'example-widget-show-2-3', 'example-widget-show-2-5')
116
+ panels.each do |id, html|
117
+ source = CGI.unescapeHTML(html.gsub(/\r\n?/, "\n"))
118
+ expect(source).to start_with('$ ')
119
+ expect(source).to include('$(echo unsafe)', '<unsafe>', 'trailing')
120
+ response = source.split("\n# ", 2).fetch(1)
121
+ expect("# #{response}".lines).to all(start_with('# '))
122
+ expect(source).not_to include("\n; echo", "\ntrailing")
123
+ expect(source).to include('# Action failed:') if id == 'example-widget-show-2-3'
124
+ end
125
+ end
92
126
  end
@@ -0,0 +1,145 @@
1
+ require 'spec_helper'
2
+
3
+ describe HaveAPI::DocumentationHelpers, '#render_version_documentation' do
4
+ empty_api
5
+ use_version 1
6
+
7
+ def overview(response = last_response)
8
+ expect(response.status).to eq(200)
9
+ response.body.match(%r{<section id="client-libraries">(.*?)</section>}m).to_a.fetch(1)
10
+ end
11
+
12
+ def library_entries(body)
13
+ body.scan(%r{<li>(.*?)</li>}m).flatten.map { |entry| CGI.unescapeHTML(entry.gsub(/<[^>]*>/, '')).strip }
14
+ end
15
+
16
+ def profile(language, name, version = nil)
17
+ adapter = Object.new
18
+ def adapter.render(**)
19
+ { status: :limitation, reason: 'Fixture API client sample is unavailable.' }
20
+ end
21
+ HaveAPI::ClientExample::Profile.new(language:, package_name: name, package_version: version, adapter:)
22
+ end
23
+
24
+ def use_profiles(profiles)
25
+ allow(app.settings.api_server).to receive(:documentation_client_profiles).and_return(profiles)
26
+ end
27
+
28
+ it 'links every available maintained dynamic client and both execution styles to its guide' do
29
+ get '/v1/'
30
+ body = overview
31
+ {
32
+ 'clients/ruby#client-library' => 'library',
33
+ 'clients/ruby#cli' => 'CLI',
34
+ 'clients/js#javascript-with-callbacks' => 'callback',
35
+ 'clients/js#javascript-with-async-await' => 'async',
36
+ 'clients/js#typescript-with-callbacks' => 'callback',
37
+ 'clients/js#typescript-with-async-await' => 'async',
38
+ 'clients/python#synchronous-client' => 'sync',
39
+ 'clients/python#asynchronous-client' => 'async',
40
+ 'clients/php' => 'PHP'
41
+ }.each do |path, label|
42
+ expect(body).to include("href=\"https://github.com/vpsfreecz/haveapi/tree/master/#{path}\">#{label}</a>")
43
+ end
44
+ expect(library_entries(body)).to eq(['Ruby (library and CLI)', 'JavaScript (callback and async)',
45
+ 'TypeScript (callback and async)', 'Python (sync and async)', 'PHP'])
46
+ expect(body).not_to include('clients/go', 'clients/rust', 'clients/typescript', 'Elixir', 'File system')
47
+ end
48
+
49
+ it 'shows configured API client identities and only their explicitly configured versions' do
50
+ use_profiles(go: profile(:go, 'example.test/widget/client', 'v2.3.4'),
51
+ rust: profile(:rust, 'widget-client'),
52
+ typescript: profile(:typescript, '@example/widget', '1.2.3'))
53
+ get '/v1/'
54
+ body = overview
55
+ expect(body).to include('clients/go', 'example.test/widget/client', 'v2.3.4', 'clients/rust', 'widget-client',
56
+ 'clients/typescript', '@example/widget', '1.2.3')
57
+ expect(body.scan('@example/widget').length).to eq(1)
58
+ expect(body.scan('widget-client').length).to eq(1)
59
+ expect(body.scan('example.test/widget/client').length).to eq(1)
60
+ expect(body.scan('v2.3.4').length).to eq(1)
61
+ expect(body.scan('1.2.3').length).to eq(1)
62
+ expect(body).not_to include('latest', '0.0.0')
63
+ entries = library_entries(body)
64
+ expect(entries.length).to eq(7)
65
+ expect(entries.grep(/\ATypeScript/)).to eq(['TypeScript (callback, async and API client): @example/widget 1.2.3'])
66
+ expect(entries.grep(/\ARust/)).to eq(['Rust (async and blocking): widget-client'])
67
+ end
68
+
69
+ it 'does not advertise unavailable API client renderers or unsupported profiles' do
70
+ use_profiles(go: profile(:go, 'HIDDEN_SDK_IDENTITY'), elixir: profile(:elixir, 'UNSUPPORTED_SDK_IDENTITY'))
71
+ allow(HaveAPI::ClientExamples::GoClient).to receive(:available?).and_return(false)
72
+ get '/v1/'
73
+ body = overview
74
+ expect(body).not_to include('HIDDEN_SDK_IDENTITY', 'UNSUPPORTED_SDK_IDENTITY', 'clients/go', 'clients/elixir')
75
+ expect(body).to include('Ruby', 'Python (')
76
+ end
77
+
78
+ it 'filters dynamic client links through the same availability contract' do
79
+ allow(HaveAPI::ClientExamples::PythonAsyncClient).to receive(:available?).and_return(false)
80
+ get '/v1/'
81
+ expect(library_entries(overview)).to include('Python (sync)')
82
+ expect(overview).not_to include('#asynchronous-client')
83
+ end
84
+
85
+ it 'omits unavailable generated variants without hiding their remaining language choices' do
86
+ use_profiles(rust: profile(:rust, 'visible-rust-sdk'), typescript: profile(:typescript, 'HIDDEN_TYPESCRIPT_SDK'))
87
+ allow(HaveAPI::ClientExamples::TsGeneratedClient).to receive(:available?).and_return(false)
88
+ allow(HaveAPI::ClientExamples::RustBlockingClient).to receive(:available?).and_return(false)
89
+ get '/v1/'
90
+ body = overview
91
+ entries = library_entries(body)
92
+ expect(entries.grep(/\ATypeScript/)).to eq(['TypeScript (callback and async)'])
93
+ expect(entries.grep(/\ARust/)).to eq(['Rust (async): visible-rust-sdk'])
94
+ expect(body).not_to include('HIDDEN_TYPESCRIPT_SDK', 'clients/typescript', '>blocking</a>')
95
+ expect(body.scan('visible-rust-sdk').length).to eq(1)
96
+ end
97
+
98
+ it 'keeps Ruby and CLI independently discoverable when only one is available' do
99
+ allow(HaveAPI::ClientExamples::RubyClient).to receive(:available?).and_return(false)
100
+ get '/v1/'
101
+ entries = library_entries(overview)
102
+ expect(entries).to include('CLI')
103
+ expect(entries.grep(/\ARuby/)).to be_empty
104
+ allow(HaveAPI::ClientExamples::RubyClient).to receive(:available?).and_return(true)
105
+ allow(HaveAPI::ClientExamples::RubyCli).to receive(:available?).and_return(false)
106
+ get '/v1/'
107
+ entries = library_entries(overview)
108
+ expect(entries).to include('Ruby')
109
+ expect(entries).not_to include('CLI', 'Ruby (library and CLI)')
110
+ end
111
+
112
+ it 'escapes renderer labels and configured API client identities and versions' do
113
+ payload = '<script>injected</script> & "quoted"'
114
+ use_profiles(go: profile(:go, payload, payload))
115
+ allow(HaveAPI::ClientExamples::GoClient).to receive(:language_label).and_return(payload)
116
+ allow(HaveAPI::ClientExamples::RubyClient).to receive(:language_label).and_return(payload)
117
+ allow(HaveAPI::ClientExamples::PythonAsyncClient).to receive(:variant).and_return(payload)
118
+ get '/v1/'
119
+ body = overview
120
+ expect(body).not_to include(payload, '<script>', 'href="javascript:')
121
+ expect(body).to include('&lt;script&gt;injected&lt;/script&gt; &amp; &quot;quoted&quot;')
122
+ expect(body).to include('href="https://github.com/vpsfreecz/haveapi/tree/master/clients/go"')
123
+ end
124
+
125
+ it 'makes curl and raw HTTP examples discoverable without claiming client packages' do
126
+ get '/v1/'
127
+ body = overview
128
+ expect(body).to include('href="/v1/?page=resources">curl</a>', 'href="/v1/?page=resources">HTTP</a>',
129
+ 'request examples are available on resource pages.')
130
+ expect(body).not_to include('clients/curl', 'clients/http')
131
+ end
132
+
133
+ it 'keeps local discovery links within the mounted API and Rack prefix' do
134
+ server = HaveAPI::Server.new(get_opt(:api_module))
135
+ server.use_version(1)
136
+ server.default_version = 1
137
+ server.mount('/mounted/api/')
138
+ response = Rack::MockRequest.new(server.app).get('/mounted/api/v1/',
139
+ 'HTTP_HOST' => 'example.test', 'SCRIPT_NAME' => '/rack')
140
+ body = overview(response)
141
+ expect(body).to include('href="/rack/mounted/api/v1/?page=resources">curl</a>')
142
+ expect(body).to include('href="/rack/mounted/api/v1/?page=resources">HTTP</a>')
143
+ expect(body).to include('href="https://github.com/vpsfreecz/haveapi/tree/master/clients/ruby#client-library">library</a>')
144
+ end
145
+ end
@@ -34,8 +34,13 @@ describe CurrentUserHtmlEscapingSpec do
34
34
  it 'escapes the authenticated login in HTML documentation' do
35
35
  payload = '<script>window.__haveapi_xss = 1</script>'
36
36
 
37
- login(payload, 'pass')
38
- get '/v1/'
37
+ require_relative '../../test_support/documentation_oauth_fixture'
38
+ server = HaveAPI::Server.new(get_opt(:api_module))
39
+ server.use_version(1)
40
+ fixture = DocumentationOAuthFixture.configure(server, users: { 1 => { 'escaped-user' => CurrentUserHtmlEscapingSpec::User.new(1, payload) } })
41
+ server.mount('/')
42
+ @api = server
43
+ get "#{fixture.origin}/v1/", {}, 'HTTP_COOKIE' => fixture.header_for(server, version: 1, token: 'escaped-user')
39
44
 
40
45
  expect(last_response.status).to eq(200)
41
46
  expect(last_response.headers['Content-Type']).to include('text/html')
@@ -0,0 +1,93 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe HaveAPI::Server::ServerHelpers, '#documentation_example_variants' do
4
+ let(:context) do
5
+ HaveAPI::ClientExample::Context.new(version: 1, host: 'example.test', base_url: 'https://example.test/',
6
+ version_url: 'https://example.test/v1/', description: { resources: {}, authentication: {} })
7
+ end
8
+ let(:view) { Object.new.extend(described_class).extend(HaveAPI::DocumentationHelpers) }
9
+ let(:client) do
10
+ Class.new(HaveAPI::ClientExample) do
11
+ id 'custom'
12
+ def self.render(kind, context:, **)
13
+ return { status: :limitation, reason: 'Simple limitation' }.freeze if kind == :init && context.detail == :simple
14
+
15
+ { status: :code, code: "#{kind}:#{context.detail}" }.freeze
16
+ end
17
+ end
18
+ end
19
+
20
+ before do
21
+ allow(view).to receive(:documentation_clients).and_return([client])
22
+ allow(view).to receive(:documentation_context) { |_client, **changes| context.with(**changes) }
23
+ end
24
+
25
+ it 'renders Simple once and only declared Thorough while preserving custom limitation behavior' do
26
+ allow(client).to receive(:render).and_call_original
27
+ allow(client).to receive(:detail_levels_for).and_call_original
28
+ variants = view.documentation_example_variants(:init)
29
+ expect(variants.map { |_client, detail, result| [detail, result[:status]] }).to eq([%i[simple limitation], %i[thorough code]])
30
+ expect(client).to have_received(:render).with(:init, context: have_attributes(detail: :simple), auth_method: nil, auth_description: nil).once
31
+ expect(client).to have_received(:render).with(:init, context: have_attributes(detail: :thorough), auth_method: nil, auth_description: nil).once
32
+ expect(client).to have_received(:detail_levels_for).with(:init, context: have_attributes(detail: :simple),
33
+ result: variants.first.last, auth_method: nil, auth_description: nil).once
34
+ expect(view.documentation_client_metadata.first[:details]).to eq(%w[simple thorough])
35
+ expect(context.detail).to eq(:simple)
36
+ expect(context.description).to eq(resources: {}, authentication: {})
37
+ end
38
+
39
+ it 'collects an ordered page union without a prepass or speculative rendering' do
40
+ allow(client).to receive(:detail_levels_for) { |kind, **| kind == :init ? [:simple].freeze : %i[simple thorough].freeze }
41
+ allow(client).to receive(:render).and_call_original
42
+ expect(view.documentation_client_metadata.first[:details]).to eq(['simple'])
43
+ expect(client).not_to have_received(:render)
44
+ expect(view.documentation_example_variants(:init).map { |_client, detail, _result| detail }).to eq([:simple])
45
+ expect(view.documentation_client_metadata.first[:details]).to eq(['simple'])
46
+ expect(view.documentation_example_variants(:example).map { |_client, detail, _result| detail }).to eq(%i[simple thorough])
47
+ expect(view.documentation_client_metadata.first[:details]).to eq(%w[simple thorough])
48
+ expect(view.documentation_example_variants(:init).length).to eq(1)
49
+ expect(view.documentation_client_metadata.first[:details]).to eq(%w[simple thorough])
50
+ end
51
+
52
+ it 'keeps absent results absent without querying capabilities or advertising a group' do
53
+ allow(client).to receive(:render).and_return(nil)
54
+ allow(client).to receive(:detail_levels_for).and_call_original
55
+ expect(view.documentation_example_variants(:init)).to be_empty
56
+ expect(client).not_to have_received(:detail_levels_for)
57
+ expect(view.documentation_client_metadata.first[:details]).to eq(['simple'])
58
+ end
59
+
60
+ it 'rejects malformed custom capability returns rather than inventing a fallback' do
61
+ [nil, [], [:thorough], ['simple'], %i[thorough simple], %i[simple simple]].each do |levels|
62
+ allow(client).to receive(:detail_levels_for).and_return(levels)
63
+ expect { view.documentation_example_variants(:init) }.to raise_error(ArgumentError, 'Invalid documentation example detail levels')
64
+ end
65
+ end
66
+
67
+ it 'preserves legacy adapter HTML panels when its Simple limitation has distinct Thorough output' do
68
+ adapter = Object.new
69
+ def adapter.resolve(**)
70
+ nil
71
+ end
72
+
73
+ def adapter.render(kind:, context:, **)
74
+ return { status: :limitation, reason: 'Legacy Simple limitation' } if context.detail == :simple
75
+
76
+ { status: :code, code: "legacy #{kind} thorough" }
77
+ end
78
+ profile = HaveAPI::ClientExample::Profile.new(language: :typescript, package_name: 'legacy-client', package_version: nil, adapter:)
79
+ server = HaveAPI::Server.new(Module.new)
80
+ server.use_version(1)
81
+ allow(server).to receive(:documentation_client_profiles).and_return(typescript: profile)
82
+ server.mount('/')
83
+ response = Rack::MockRequest.new(server.app).get('/v1/', 'HTTP_HOST' => 'example.test')
84
+ expect(response.status).to eq(200)
85
+ group = response.body.match(%r{data-example-details="([^"]*)" id="init-examples">(.*?)</div>}m)
86
+ expect(JSON.parse(CGI.unescapeHTML(group[1])).fetch('typescript-generated')).to eq(%w[simple thorough])
87
+ expect(group[2]).to include('id="init-typescript-generated"', 'Legacy Simple limitation',
88
+ 'id="init-typescript-generated-thorough"', 'legacy init thorough')
89
+ panels = group[2].scan(/data-client="typescript-generated"[^>]*data-detail="([^"]+)"/).flatten
90
+ expect(panels).to eq(%w[simple thorough])
91
+ expect(adapter).not_to respond_to(:detail_levels_for)
92
+ end
93
+ end