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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/benchmarks/documentation.rb +29 -6
- data/benchmarks/endpoints.rb +153 -24
- data/benchmarks/spec/client_examples_spec.rb +109 -37
- data/doc/client-examples.md +218 -45
- data/doc/documentation-oauth.md +157 -0
- data/doc/documentation-ui.md +192 -0
- data/doc/index.md +3 -6
- data/doc/performance.md +39 -12
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/authentication/chain.rb +19 -0
- data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
- data/lib/haveapi/client_example.rb +143 -15
- data/lib/haveapi/client_examples/curl.rb +30 -20
- data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
- data/lib/haveapi/client_examples/go_client.rb +9 -2
- data/lib/haveapi/client_examples/http.rb +9 -4
- data/lib/haveapi/client_examples/js_async_client.rb +7 -0
- data/lib/haveapi/client_examples/js_client.rb +47 -36
- data/lib/haveapi/client_examples/php_client.rb +161 -75
- data/lib/haveapi/client_examples/python_async_client.rb +3 -0
- data/lib/haveapi/client_examples/python_client.rb +61 -47
- data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
- data/lib/haveapi/client_examples/ruby_client.rb +80 -31
- data/lib/haveapi/client_examples/rust_client.rb +20 -9
- data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
- data/lib/haveapi/client_examples/ts_client.rb +3 -0
- data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
- data/lib/haveapi/documentation_context.rb +7 -2
- data/lib/haveapi/documentation_helpers.rb +223 -0
- data/lib/haveapi/documentation_oauth.rb +399 -0
- data/lib/haveapi/documentation_search.rb +225 -0
- data/lib/haveapi/documentation_search_index.rb +79 -0
- data/lib/haveapi/documentation_validators.rb +179 -0
- data/lib/haveapi/public/css/documentation.css +343 -0
- data/lib/haveapi/public/js/documentation.js +992 -0
- data/lib/haveapi/server.rb +144 -86
- data/lib/haveapi/shared_documentation_helpers.rb +54 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/doc_layout.erb +16 -0
- data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
- data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
- data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
- data/lib/haveapi/views/documentation/search_form.erb +10 -0
- data/lib/haveapi/views/documentation/search_index.erb +1 -0
- data/lib/haveapi/views/documentation/search_results.erb +20 -0
- data/lib/haveapi/views/index.erb +2 -4
- data/lib/haveapi/views/main_layout.erb +12 -6
- data/lib/haveapi/views/shared_documentation.erb +12 -0
- data/lib/haveapi/views/shared_sidebar.erb +25 -0
- data/lib/haveapi/views/version_page/auth_body.erb +12 -33
- data/lib/haveapi/views/version_page/client_overview.erb +40 -0
- data/lib/haveapi/views/version_page/example_panels.erb +18 -0
- data/lib/haveapi/views/version_page/resource_body.erb +91 -132
- data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
- data/lib/haveapi/views/version_page/usage.erb +7 -0
- data/lib/haveapi/views/version_page.erb +36 -117
- data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
- data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
- data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
- data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
- data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
- data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
- data/lib/haveapi/views/version_sidebar.erb +59 -48
- data/spec/authentication/basic_spec.rb +10 -10
- data/spec/authentication/oauth2_chain_spec.rb +72 -0
- data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
- data/spec/client_examples/assets/documentation_test.cjs +817 -0
- data/spec/client_examples/context_performance_spec.rb +5 -4
- data/spec/client_examples/context_spec.rb +23 -2
- data/spec/client_examples/detail_spec.rb +175 -0
- data/spec/client_examples/dynamic_acceptance.md +59 -26
- data/spec/client_examples/dynamic_acceptance.rb +164 -44
- data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
- data/spec/client_examples/dynamic_fixture.rb +4 -3
- data/spec/client_examples/dynamic_js_spec.rb +20 -11
- data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
- data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
- data/spec/client_examples/dynamic_python_spec.rb +62 -11
- data/spec/client_examples/go_spec.rb +14 -1
- data/spec/client_examples/presentation_spec.rb +247 -0
- data/spec/client_examples/profile_mount_spec.rb +40 -13
- data/spec/client_examples/rust_client_spec.rb +35 -1
- data/spec/client_examples/shell_spec.rb +268 -0
- data/spec/client_examples/syntax_spec.rb +192 -0
- data/spec/client_examples/ts_generated_client_spec.rb +15 -2
- data/spec/client_examples/typescript_profiles_spec.rb +1 -1
- data/spec/documentation/auth_filtering_spec.rb +9 -4
- data/spec/documentation/canonical_routes_spec.rb +89 -0
- data/spec/documentation/client_examples_spec.rb +39 -5
- data/spec/documentation/client_overview_spec.rb +145 -0
- data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
- data/spec/documentation/example_variants_spec.rb +93 -0
- data/spec/documentation/fixtures/search_index.json +476 -0
- data/spec/documentation/identity_and_guides_spec.rb +130 -0
- data/spec/documentation/identity_context_spec.rb +180 -0
- data/spec/documentation/navigation_spec.rb +296 -0
- data/spec/documentation/oauth_fixture_spec.rb +39 -0
- data/spec/documentation/oauth_spec.rb +912 -0
- data/spec/documentation/oauth_transport_spec.rb +45 -0
- data/spec/documentation/performance_spec.rb +47 -6
- data/spec/documentation/reference_headings_spec.rb +190 -0
- data/spec/documentation/search_index_routes_spec.rb +202 -0
- data/spec/documentation/search_index_spec.rb +85 -0
- data/spec/documentation/search_routes_spec.rb +199 -0
- data/spec/documentation/search_spec.rb +134 -0
- data/spec/documentation/shared_pages_spec.rb +240 -0
- data/spec/documentation/validators_spec.rb +123 -0
- data/spec/documentation_spec.rb +3 -3
- data/spec/packaging_spec.rb +21 -2
- data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
- data/spec/performance/documentation_shapes_spec.rb +97 -0
- data/spec/performance/json_activation_spec.rb +78 -1
- data/spec/performance/release_lineage_spec.rb +4 -3
- data/spec/performance/runner_spec.rb +1 -0
- data/test_support/all_client_performance_fixture.rb +6 -10
- data/test_support/documentation_oauth_fixture.rb +54 -0
- data/test_support/documentation_performance_fixture.rb +19 -1
- data/test_support/dynamic_documentation_api.rb +35 -10
- data/test_support/dynamic_python_login_spec.rb +3 -11
- 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', '<
|
|
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', '
|
|
71
|
+
expect(last_response.body).to include('clients/go', 'init-go', 'Unsupported <sample> & 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 <sample> & 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
|
|
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
|
|
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/<client>&</code
|
|
106
|
+
expect(last_response.body).to include('<code>example.test/<client>&</code>')
|
|
90
107
|
expect(last_response.body).not_to include('example.test/<client>', '&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('<script>injected</script> & "quoted"')
|
|
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
|
-
|
|
38
|
-
|
|
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
|