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,180 @@
1
+ require 'spec_helper'
2
+
3
+ module DocumentationIdentityContextSpec
4
+ User = Struct.new(:login)
5
+
6
+ class Provider < HaveAPI::Authentication::Basic::Provider
7
+ protected
8
+
9
+ def find_user(_request, username, password)
10
+ User.new(username) if password == 'pass'
11
+ end
12
+ end
13
+ end
14
+
15
+ describe DocumentationIdentityContextSpec do
16
+ api do
17
+ define_resource(:Widget) do
18
+ version :all
19
+ auth false
20
+ define_action(:Show) do
21
+ route '{widget_id}'
22
+ http_method :get
23
+ output(:hash) { string :value }
24
+ authorize { allow }
25
+ def exec
26
+ { value: 'unchanged' }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ default_version 1
32
+ use_version 1
33
+ auth_chain DocumentationIdentityContextSpec::Provider
34
+
35
+ def configured_server(name: nil, versions: [1], mount: '/')
36
+ server = HaveAPI::Server.new(get_opt(:api_module))
37
+ server.use_version(versions)
38
+ server.default_version = 1
39
+ server.documentation_name = name
40
+ server.auth_chain << DocumentationIdentityContextSpec::Provider
41
+ server.mount(mount)
42
+ server
43
+ end
44
+
45
+ def request(server, path, host: 'example.test', **env)
46
+ Rack::MockRequest.new(server.app).get(path, { 'HTTP_HOST' => host }.merge(env))
47
+ end
48
+
49
+ it 'copies and freezes the trimmed display name without assigning any URL semantics' do
50
+ server = HaveAPI::Server.new(get_opt(:api_module))
51
+ original = ' <My API> & reference '
52
+ server.documentation_name = original
53
+ original.replace('mutated')
54
+ expect(server.documentation_name).to eq('<My API> & reference')
55
+ expect(server.documentation_name).to be_frozen
56
+ server.documentation_name = nil
57
+ expect(server.documentation_name).to be_nil
58
+ end
59
+
60
+ it 'rejects empty, overlong, malformed and control-bearing names without replacing a valid name' do
61
+ server = HaveAPI::Server.new(get_opt(:api_module))
62
+ server.documentation_name = 'Valid'
63
+ ['', ' ', 'x' * 257, "bad\nname", "\tbad", "bad\u007f", "\xff".b, :name, false].each do |value|
64
+ expect { server.documentation_name = value }.to raise_error(ArgumentError)
65
+ expect(server.documentation_name).to eq('Valid')
66
+ end
67
+ server.documentation_name = 'ž' * 128
68
+ expect(server.documentation_name.bytesize).to eq(256)
69
+ expect { server.documentation_name = 'ž' * 129 }.to raise_error(ArgumentError)
70
+ end
71
+
72
+ it 'escapes a long configured identity across mounted titles and brands while retaining API routes' do
73
+ name = "<Example> & #{('long ' * 35).strip}"
74
+ server = configured_server(name:, mount: '/api/')
75
+ response = request(server, '/api/v1/?resource=widget')
76
+ expect(response.status).to eq(200)
77
+ expect(response.body).to include('<title>widget · API v1 · &lt;Example&gt; &amp;',
78
+ '<a href="/api/">&lt;Example&gt; &amp;', '<small class="docs-api-version">API v1</small>')
79
+ expect(response.body).not_to include('<Example>', 'api.vpsfree.cz')
80
+ expect { server.documentation_name = nil }.to raise_error(ArgumentError, /before mount/)
81
+ action = request(server, '/api/v1/widgets/1')
82
+ expect(action.status).to eq(200)
83
+ expect(JSON.parse(action.body).fetch('response').fetch('widget').fetch('value')).to eq('unchanged')
84
+ end
85
+
86
+ it 'uses the request host and nondefault port as fallback without inventing a canonical domain' do
87
+ server = configured_server
88
+ response = request(server, '/v1/', host: 'preview.example:8443')
89
+ expect(response.status).to eq(200)
90
+ expect(response.body).to include('<a href="/">preview.example:8443</a>', 'Overview · API v1 · preview.example:8443')
91
+ response = request(server, '/v1/', host: 'preview.example:80')
92
+ expect(response.body).to include('<a href="/">preview.example</a>')
93
+ end
94
+
95
+ it 'keeps explicit API credentials out of the sole-version documentation identity and describes once' do
96
+ app
97
+ server = instance_variable_get(:@api)
98
+ allow(server.auth_chain).to receive(:authenticate).and_call_original
99
+ allow(server).to receive(:describe).and_call_original
100
+ events = []
101
+ server.connect_hook(:post_authenticated) do |ret, user|
102
+ events << user
103
+ ret
104
+ end
105
+ login('<actual>&', 'pass')
106
+ get '/'
107
+ expect(last_response).to be_ok
108
+ expect(last_response.body).to include('<h1>API versions</h1>', 'Showing all resources, actions and parameters.')
109
+ expect(last_response.body).not_to include('docs-identity', 'docs-sign-in', 'API description', 'Available versions:')
110
+ expect(server.auth_chain).not_to have_received(:authenticate)
111
+ expect(server).to have_received(:describe).once.with(have_attributes(current_user: nil, auth_users_by_version: { 1 => nil }))
112
+ expect(events).to eq([nil])
113
+ expect(last_response.headers['Cache-Control']).to eq('private, no-store')
114
+ end
115
+
116
+ it 'keeps anonymous root search lazy without authentication or extraction' do
117
+ app
118
+ server = instance_variable_get(:@api)
119
+ allow(server.auth_chain).to receive(:authenticate).and_call_original
120
+ allow(server).to receive(:describe).and_call_original
121
+ allow(HaveAPI::DocumentationSearch::Index).to receive(:new).and_call_original
122
+ get '/'
123
+ expect(last_response).to be_ok
124
+ expect(last_response.body).to include('Showing all resources, actions and parameters.')
125
+ expect(last_response.body).not_to include('docs-identity', 'class="authentication"')
126
+ expect(server.auth_chain).not_to have_received(:authenticate)
127
+ expect(server).to have_received(:describe).once
128
+ expect(HaveAPI::DocumentationSearch::Index).not_to have_received(:new)
129
+ end
130
+
131
+ it 'resolves each multiversion documentation identity anonymously without borrowing API credentials' do
132
+ server = configured_server(versions: [1, 2])
133
+ allow(server.auth_chain).to receive(:authenticate).and_call_original
134
+ allow(server).to receive(:describe).and_call_original
135
+ events = []
136
+ server.connect_hook(:post_authenticated) do |ret, user|
137
+ events << user
138
+ ret
139
+ end
140
+ response = request(server, '/', 'HTTP_AUTHORIZATION' => "Basic #{['member:pass'].pack('m0')}")
141
+ expect(response.status).to eq(200)
142
+ expect(response.body).to include('Version 1', 'Version 2')
143
+ expect(response.body).not_to include('docs-identity', 'docs-visibility-note', 'class="docs-sign-in', 'class="docs-account-link')
144
+ expect(server.auth_chain).not_to have_received(:authenticate)
145
+ expect(server).to have_received(:describe).once.with(have_attributes(current_user: nil, auth_users_by_version: { 1 => nil, 2 => nil }))
146
+ expect(events).to eq([nil, nil])
147
+ end
148
+
149
+ it 'keeps one settings disclosure with direct theme choices and no account actions when OAuth is absent' do
150
+ server = configured_server(mount: '/api/')
151
+ response = request(server, '/api/', 'SCRIPT_NAME' => '/rack')
152
+ expect(response.status).to eq(200)
153
+ expect(response.body).to include('href="/rack/api/"', 'href="/rack/api/v1/"', 'data-documentation-menu',
154
+ 'data-documentation-menu-content', 'aria-label="Documentation settings"')
155
+ expect(response.body.scan('<details data-documentation-menu>').length).to eq(1)
156
+ expect(response.body).not_to include('data-account-menu', 'data-theme-menu', 'docs-sign-in', 'docs-account-link', '_logout')
157
+ end
158
+
159
+ it 'keeps the documentation authentication boundary out of root OPTIONS and API actions' do
160
+ app.helpers do
161
+ def documentation_authenticated?(_version)
162
+ raise 'Documentation-only authentication used for an API request'
163
+ end
164
+ end
165
+ call_api(:options, '/')
166
+ expect(last_response).to be_ok
167
+ expect(api_response.response).to have_key(:versions)
168
+ get '/v1/widgets/1'
169
+ expect(last_response).to be_ok
170
+ expect(api_response[:widget][:value]).to eq('unchanged')
171
+ end
172
+
173
+ it 'forwards renderer capabilities without pruning either panel detail level' do
174
+ get '/v1/'
175
+ clients = JSON.parse(last_response.body.match(%r{data-documentation-clients>(.*?)</script>}m)[1])
176
+ expect(clients.find { |client| client['language'] == 'http' }.fetch('details')).to eq(['simple'])
177
+ expect(clients.find { |client| client['id'] == 'javascript-async' }.fetch('details')).to eq(%w[simple thorough])
178
+ expect(last_response.body).to include('data-detail-control', 'data-detail="simple"', 'data-detail="thorough"')
179
+ end
180
+ end
@@ -0,0 +1,296 @@
1
+ require 'spec_helper'
2
+
3
+ module DocumentationNavigationSpec
4
+ User = Struct.new(:login)
5
+
6
+ class Provider < HaveAPI::Authentication::Basic::Provider
7
+ protected
8
+
9
+ def find_user(_request, username, password)
10
+ User.new(username) if password == 'pass'
11
+ end
12
+ end
13
+ end
14
+
15
+ module DocumentationNavigationSpec
16
+ API = Module.new do
17
+ def self.define_resource(name, &block)
18
+ klass = Class.new(HaveAPI::Resource)
19
+ const_set(name, klass)
20
+ klass.class_exec(&block)
21
+ klass
22
+ end
23
+ widget = define_resource(:Widget) do
24
+ version 1
25
+ auth false
26
+ desc 'Parent resource description'
27
+
28
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
29
+ input(:hash) do
30
+ bool :enabled, default: false
31
+ integer :count, default: 0
32
+ string :empty, default: ''
33
+ string :nullable, nullable: true, default: nil
34
+ string :lifetime, validate: 'At least one hour, at most one week'
35
+ end
36
+ output(:hash) do
37
+ integer :id
38
+ string :label
39
+ end
40
+ authorize { allow }
41
+ end
42
+
43
+ define_action(:Show) do
44
+ route '{widget_id}'
45
+ http_method :get
46
+ output(:hash) do
47
+ integer :id
48
+ string :label
49
+ string :name
50
+ end
51
+ authorize { allow }
52
+
53
+ # rubocop:disable RSpec/NoExpectationExample
54
+ example 'parent sample' do
55
+ path_params 1
56
+ response(name: 'PARENT_SAMPLE')
57
+ end
58
+ # rubocop:enable RSpec/NoExpectationExample
59
+
60
+ def exec
61
+ { name: 'parent' }
62
+ end
63
+ end
64
+
65
+ define_resource(:Child) do
66
+ desc 'Nested resource description'
67
+ define_action(:Show) do
68
+ route '{child_id}'
69
+ http_method :get
70
+ output(:hash) { string :name }
71
+ authorize { allow }
72
+
73
+ # rubocop:disable RSpec/NoExpectationExample
74
+ example 'child sample' do
75
+ path_params 2
76
+ response(name: 'CHILD_SAMPLE')
77
+ end
78
+ # rubocop:enable RSpec/NoExpectationExample
79
+ end
80
+ end
81
+ end
82
+
83
+ define_resource(:Reference) do
84
+ version 1
85
+ auth false
86
+ define_action(:Show) do
87
+ route '{reference_id}'
88
+ http_method :get
89
+ output(:hash) { resource widget }
90
+ authorize { allow }
91
+ end
92
+ end
93
+
94
+ define_resource(:Hidden) do
95
+ version 1
96
+ auth false
97
+ desc 'HIDDEN_RESOURCE_DESCRIPTION'
98
+ define_action(:Show) do
99
+ route '{hidden_id}'
100
+ http_method :get
101
+ output(:hash) { string :secret }
102
+ authorize { |user| user.login == 'admin' ? allow : deny }
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ describe DocumentationNavigationSpec do
109
+ api DocumentationNavigationSpec::API
110
+ default_version 1
111
+ auth_chain DocumentationNavigationSpec::Provider
112
+
113
+ let(:documentation_server) { HaveAPI::Server.new(get_opt(:api_module)) }
114
+ let(:docs_fixture) do
115
+ require_relative '../../test_support/documentation_oauth_fixture'
116
+ server = documentation_server
117
+ server.use_version(1)
118
+ server.auth_chain << DocumentationNavigationSpec::Provider
119
+ users = %w[member admin].to_h { |name| [name, DocumentationNavigationSpec::User.new(name)] }
120
+ fixture = DocumentationOAuthFixture.configure(server, users: { 1 => users })
121
+ server.mount('/')
122
+ @api = server
123
+ fixture
124
+ end
125
+
126
+ def request_as(user, path)
127
+ fixture = docs_fixture
128
+ get "#{fixture.origin}#{path}", {}, 'HTTP_COOKIE' => fixture.header_for(documentation_server, version: 1, token: user)
129
+ end
130
+
131
+ def json_element(attribute)
132
+ JSON.parse(last_response.body.match(%r{<script[^>]*#{attribute}[^>]*>(.*?)</script>}m)[1])
133
+ end
134
+
135
+ it 'renders an overview without resource actions and supplies visible legacy destinations' do
136
+ request_as('member', '/v1/')
137
+ expect(last_response).to be_ok
138
+ expect(last_response.headers['Cache-Control']).to eq('private, no-store')
139
+ expect(last_response.body).to include('data-page="overview"', 'init-0', '?resource=widget.child')
140
+ expect(last_response.body).not_to include('PARENT_SAMPLE', 'CHILD_SAMPLE', 'HIDDEN_RESOURCE_DESCRIPTION')
141
+ expect(json_element('data-legacy-anchors')).to include(
142
+ 'root-widget-show' => '/v1/?resource=widget',
143
+ 'root-widget-child-show' => '/v1/?resource=widget.child',
144
+ 'example-widget.child-show-' => '/v1/?resource=widget.child',
145
+ 'auth-basic' => '/v1/?page=authentication',
146
+ 'auth-basic-javascript-async' => '/v1/?page=authentication'
147
+ )
148
+ expect(json_element('data-legacy-anchors').keys.join).not_to include('hidden')
149
+ end
150
+
151
+ it 'renders only the selected resource actions and preserves stable example IDs' do
152
+ get '/v1/?resource=widget'
153
+ expect(last_response).to be_ok
154
+ expect(last_response.body).to include('data-page="resource"', '<h1 class="resource" id="root-widget">widget<a class="docs-permalink"', 'id="root-widget-show"',
155
+ 'id="example-widget-show-0-0"', 'data-example-details', 'PARENT_SAMPLE')
156
+ expect(last_response.body).not_to include('CHILD_SAMPLE', 'init-examples')
157
+ expect(last_response.body).to include('data-resource-breadcrumbs', '<li aria-current="page">Widget</li>')
158
+ expect(last_response.body).not_to include('data-resource-nav', 'class="docs-header"')
159
+ end
160
+
161
+ it 'renders nested resources on their own page with same-page action links' do
162
+ get '/v1/?resource=widget.child'
163
+ expect(last_response).to be_ok
164
+ expect(last_response.body).to include('id="root-widget-child-show"', 'href="#root-widget-child-show"', 'CHILD_SAMPLE')
165
+ expect(last_response.body).not_to include('PARENT_SAMPLE')
166
+ sidebar = last_response.body.match(%r{<aside[^>]*>(.*?)</aside>}m)[1]
167
+ expect(sidebar).to include('<p data-current-resource><a href="/v1/?resource=widget.child"><strong>widget.child</strong></a></p>')
168
+ expect(sidebar).not_to include('<h2>Resource</h2>', '<code>widget.child</code>')
169
+ expect(sidebar.index('data-current-resource')).to be < sidebar.index('aria-label="Actions on this resource"')
170
+ end
171
+
172
+ it 'keeps authentication content separate from the overview' do
173
+ get '/v1/?page=authentication'
174
+ expect(last_response).to be_ok
175
+ expect(last_response.body).to include('data-page="authentication"', 'id="auth-basic"')
176
+ expect(last_response.body).not_to include('PARENT_SAMPLE', 'init-examples')
177
+ end
178
+
179
+ it 'returns the same missing response for inaccessible and absent resources' do
180
+ request_as('member', '/v1/?resource=hidden')
181
+ expect(last_response.status).to eq(404)
182
+ hidden_body = last_response.body
183
+ request_as('member', '/v1/?resource=absent')
184
+ expect(last_response.status).to eq(404)
185
+ expect(last_response.body).to eq(hidden_body)
186
+ expect(last_response.body).not_to include('HIDDEN_RESOURCE_DESCRIPTION')
187
+ end
188
+
189
+ it 'rejects malformed, duplicate, nested and ambiguous HTML selection' do
190
+ %w[resource= resource=widget..child resource=../widget resource[]=widget resource[x]=widget
191
+ resource=widget&resource=reference page=unknown page=authentication&resource=widget
192
+ page=overview&page=authentication resource=%00].each do |query|
193
+ get "/v1/?#{query}"
194
+ expect(last_response.status).to eq(400), query
195
+ end
196
+ end
197
+
198
+ it 'does not reinterpret selection on OPTIONS descriptions or action endpoints' do
199
+ call_api(:options, '/v1/?resource=absent&page=unknown')
200
+ expect(last_response).to be_ok
201
+ expect(api_response[:resources]).to have_key(:widget)
202
+ get '/v1/widgets/1?resource=absent&page=unknown'
203
+ expect(last_response).to be_ok
204
+ expect(api_response[:widget][:name]).to eq('parent')
205
+ end
206
+
207
+ it 'qualifies resource parameter links with the selected resource page' do
208
+ get '/v1/?resource=reference'
209
+ expect(last_response).to be_ok
210
+ expect(last_response.body).to include('href="/v1/?resource=widget#root-widget-show"')
211
+ end
212
+
213
+ it 'preserves root version listing while supporting default-version selection' do
214
+ get '/'
215
+ expect(last_response.body).to include('<h1>API versions</h1>')
216
+ get '/?resource=widget.child'
217
+ expect(last_response).to be_ok
218
+ expect(last_response.body).to include('CHILD_SAMPLE', 'href="/v1/?resource=widget"')
219
+ end
220
+
221
+ it 'keeps the complete authorized resource hierarchy on a separate directory page' do
222
+ request_as('member', '/v1/?page=resources')
223
+ expect(last_response).to be_ok
224
+ expect(last_response.body).to include('data-page="resources"', 'data-resource-nav', 'data-documentation-search',
225
+ 'data-resource-link="widget.child"', 'data-search-query')
226
+ expect(last_response.body).not_to include('PARENT_SAMPLE', 'CHILD_SAMPLE', 'data-resource-link="hidden"')
227
+ get '/v1/'
228
+ expect(last_response.body).to include('data-resource-directory-link', '?page=resources')
229
+ expect(last_response.body).not_to include('data-resource-nav', 'data-resource-link="widget"')
230
+ expect(json_element('data-legacy-anchors')['resources']).to eq('/v1/?page=resources')
231
+ end
232
+
233
+ it 'links nested resource breadcrumbs to the directory and each real parent' do
234
+ get '/v1/?resource=widget.child'
235
+ breadcrumb = last_response.body.match(%r{<nav aria-label="Breadcrumb" data-resource-breadcrumbs>(.*?)</nav>}m)[1]
236
+ expect(breadcrumb).to include('href="/v1/?page=resources"', 'href="/v1/?resource=widget"',
237
+ '<li aria-current="page">Child</li>')
238
+ expect(last_response.body.index('data-action-nav')).to be < last_response.body.index('docs-preferences')
239
+ expect(last_response.body).to include('data-theme-choice="auto"', 'data-theme-choice="light"', 'data-theme-choice="dark"')
240
+ expect(last_response.body).not_to include('data-theme-select')
241
+ end
242
+
243
+ it 'keeps parameter values and validators readable in semantic compact tables' do
244
+ get '/v1/?resource=widget'
245
+ input = last_response.body.match(%r{<table class="docs-input-parameters">(.*?)</table>\s*</div>\s*<h\d[^>]*>Output parameters}m)[1]
246
+ output = last_response.body.match(%r{<table class="docs-output-parameters">(.*?)</table>}m)[1]
247
+ expect(input.scan('<th>').length).to eq(4)
248
+ expect(output.scan('<th>').length).to eq(3)
249
+ expect(input).to include('docs-param-details', 'Default:</strong> <code>false</code>',
250
+ 'Default:</strong> <code>0</code>', 'Default:</strong> <code>&quot;&quot;</code>',
251
+ 'Default:</strong> <code>null</code>', '<table class="docs-validator-table">',
252
+ '<caption>Validation rules</caption>', '<th scope="row">Custom</th>', 'At least one hour, at most one week')
253
+ expect(last_response.body).to include('class="docs-table-scroll" tabindex="0" role="region" aria-label="Input parameters"')
254
+ expect(input).not_to include('<dl>')
255
+ end
256
+
257
+ it 'keeps client presentation metadata safe in JSON script text' do
258
+ payload = '</script><script>window.injected = true</script>'
259
+ client = HaveAPI::ClientExample.clients.first
260
+ allow(client).to receive(:language_label).and_return(payload)
261
+ get '/v1/'
262
+ expect(last_response.body).not_to include(payload)
263
+ expect(json_element('data-documentation-clients').first.fetch('language_label')).to eq(payload)
264
+ end
265
+
266
+ it 'rejects malformed raw percent escapes before describing the version' do
267
+ response = Rack::MockRequest.new(app).get('/v1/',
268
+ 'HTTP_HOST' => 'example.test', 'QUERY_STRING' => 'resource=%ZZ')
269
+ expect(response.status).to eq(400)
270
+ end
271
+
272
+ it 'resolves documentation assets and the highlighter under a Rack mount' do
273
+ response = Rack::MockRequest.new(app).get('/v1/?resource=widget',
274
+ 'HTTP_HOST' => 'example.test', 'SCRIPT_NAME' => '/rack')
275
+ expect(response.status).to eq(200)
276
+ expect(response.body).to include('src="/rack/js/documentation.js"',
277
+ 'data-highlighter-url="/rack/js/highlighter.js"',
278
+ 'href="/rack/css/documentation.css"',
279
+ 'href="/rack/v1/?page=resources"')
280
+ end
281
+
282
+ it 'uses mounted canonical navigation and keeps shared documentation on its layout' do
283
+ server = HaveAPI::Server.new(get_opt(:api_module))
284
+ server.use_version(1)
285
+ server.mount('/mounted/api/')
286
+ response = Rack::MockRequest.new(server.app).get('/mounted/api/v1/?resource=reference', 'HTTP_HOST' => 'example.test')
287
+ expect(response.status).to eq(200)
288
+ expect(response.body).to include('href="/mounted/api/v1/?resource=widget#root-widget-show"', '/js/documentation.js')
289
+ expect(response.body).not_to include('href="/mounted/api/doc/protocol"')
290
+ expect(response.body).not_to include('/js/nojs-tabs.js', '/js/main.js')
291
+ get '/doc/protocol'
292
+ expect(last_response).to be_ok
293
+ expect(last_response.body).to include('/js/documentation.js', 'data-page="guide"')
294
+ expect(last_response.body).not_to include('/js/main.js')
295
+ end
296
+ end
@@ -0,0 +1,39 @@
1
+ require 'spec_helper'
2
+ require_relative '../../test_support/documentation_oauth_fixture'
3
+
4
+ describe DocumentationOAuthFixture do
5
+ api do
6
+ define_resource(:Widget) do
7
+ version 1
8
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
9
+ output(:hash) { string :value }
10
+ authorize { |user| user&.login == 'admin' ? allow : deny }
11
+ end
12
+ end
13
+ end
14
+
15
+ it 'issues real mounted cookies outside requests and validates role, crypto and lookup on every page' do
16
+ user = Struct.new(:login)
17
+ server = HaveAPI::Server.new(self.class::ApiModule)
18
+ server.use_version(1)
19
+ users = { 1 => { 'admin-token' => user.new('admin'), 'member-token' => user.new('member') } }
20
+ fixture = described_class.configure(server, users:, origin: 'https://fixture.example', mount_path: '/api/')
21
+ server.mount('/api/')
22
+ headers = %w[admin-token member-token].to_h { |token| [token, fixture.header_for(server, version: 1, token:)] }
23
+ expect(fixture.lookups).to be_empty
24
+ expect(headers.values.join).not_to include('admin-token', 'member-token')
25
+ allow(server.documentation_oauth).to receive(:read_cookie).and_call_original
26
+ client = Rack::MockRequest.new(server.app)
27
+ url = "#{fixture.origin}/api/v1/?resource=widget"
28
+
29
+ first = client.get(url, 'HTTP_HOST' => 'fixture.example', 'HTTP_COOKIE' => headers.fetch('admin-token'))
30
+ second = client.get(url, 'HTTP_HOST' => 'fixture.example', 'HTTP_COOKIE' => headers.fetch('admin-token'))
31
+ expect(first.status).to eq(200)
32
+ expect(second.body).to eq(first.body)
33
+ expect(first.body).to include('admin', 'name="csrf"')
34
+ member = client.get(url, 'HTTP_HOST' => 'fixture.example', 'HTTP_COOKIE' => headers.fetch('member-token'))
35
+ expect(member.status).to eq(404)
36
+ expect(fixture.lookups).to eq([[1, 'admin-token'], [1, 'admin-token'], [1, 'member-token']])
37
+ expect(server.documentation_oauth).to have_received(:read_cookie).exactly(3).times
38
+ end
39
+ end