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,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 · <Example> &',
|
|
78
|
+
'<a href="/api/"><Example> &', '<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>""</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
|