haveapi 0.31.2 → 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,199 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'rack/mock'
|
|
3
|
+
require_relative '../../test_support/dynamic_documentation_api'
|
|
4
|
+
|
|
5
|
+
module DocumentationSearchRoutesSpec
|
|
6
|
+
User = Struct.new(:login)
|
|
7
|
+
|
|
8
|
+
class Provider < HaveAPI::Authentication::Basic::Provider
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
def find_user(_request, username, password)
|
|
12
|
+
User.new(username) if password == 'pass'
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
RSpec.describe HaveAPI::DocumentationSearch::Helpers do
|
|
18
|
+
api do
|
|
19
|
+
define_resource(:Widget) do
|
|
20
|
+
version 1
|
|
21
|
+
auth false
|
|
22
|
+
define_action(:Renew) do
|
|
23
|
+
http_method :post
|
|
24
|
+
route 'renew'
|
|
25
|
+
aliases ['extend']
|
|
26
|
+
authorize do |user|
|
|
27
|
+
input blacklist: [:private_ticket] unless user.login == 'admin'
|
|
28
|
+
allow
|
|
29
|
+
end
|
|
30
|
+
input(:hash) do
|
|
31
|
+
integer :lifetime, label: 'Lease duration'
|
|
32
|
+
string :private_ticket, label: 'Secret ticket', default: 'DEFAULT_SECRET'
|
|
33
|
+
end
|
|
34
|
+
output(:hash) { string :valid_to, label: 'Expiry date' }
|
|
35
|
+
end
|
|
36
|
+
define_action(:Hidden) do
|
|
37
|
+
http_method :post
|
|
38
|
+
route 'hidden'
|
|
39
|
+
authorize { |user| user.login == 'admin' ? allow : deny }
|
|
40
|
+
output(:hash) { string :private_response }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
define_resource(:Child) do
|
|
44
|
+
define_action(:Show) do
|
|
45
|
+
http_method :get
|
|
46
|
+
route '{child_id}'
|
|
47
|
+
authorize { allow }
|
|
48
|
+
output(:hash) { string :name }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
default_version 1
|
|
54
|
+
auth_chain DocumentationSearchRoutesSpec::Provider
|
|
55
|
+
|
|
56
|
+
def results
|
|
57
|
+
last_response.body.match(%r{<section data-search-results.*?</section>}m)[0]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'renders ordinary GET search forms on versions, resources and neutral guides without discovery for guides' do
|
|
61
|
+
app
|
|
62
|
+
server = instance_variable_get(:@api)
|
|
63
|
+
allow(server).to receive(:describe_version).and_call_original
|
|
64
|
+
get '/doc/protocol'
|
|
65
|
+
expect(last_response.body).to include('data-documentation-search method="get" action="/v1/"',
|
|
66
|
+
'name="page" value="resources"', 'name="q" data-search-query', 'Search API v1')
|
|
67
|
+
expect(last_response.body).to include('placeholder="Resource..."')
|
|
68
|
+
expect(server).not_to have_received(:describe_version)
|
|
69
|
+
get '/v1/?page=resources'
|
|
70
|
+
expect(last_response.body).to include('data-resource-nav', 'data-resource-link="widget"')
|
|
71
|
+
expect(last_response.body).not_to include('data-search-results', 'data-resource-filter')
|
|
72
|
+
ids = last_response.body.scan(/\bid="(docs-search-[^"]+)"/).flatten
|
|
73
|
+
expect(ids).to contain_exactly('docs-search-sidebar-query')
|
|
74
|
+
main = last_response.body.match(%r{<main[^>]*>(.*?)</main>}m)[1]
|
|
75
|
+
expect(main).not_to include('data-documentation-search', 'type="submit"')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'trims and escapes the query, title and results while describing exactly once' do
|
|
79
|
+
app
|
|
80
|
+
server = instance_variable_get(:@api)
|
|
81
|
+
allow(server).to receive(:describe_version).and_call_original
|
|
82
|
+
get '/v1/', page: 'resources', q: ' lease duration '
|
|
83
|
+
expect(last_response).to be_ok
|
|
84
|
+
expect(last_response.headers['Cache-Control']).to eq('private, no-store')
|
|
85
|
+
expect(last_response.body).to include('<title>Search API v1: lease duration', 'value="lease duration"')
|
|
86
|
+
expect(results).to include('Parameter:', 'lifetime', 'Input', 'Integer')
|
|
87
|
+
expect(server).to have_received(:describe_version).once
|
|
88
|
+
get '/v1/', page: 'resources', q: '<script>evil</script>'
|
|
89
|
+
expect(last_response.body).not_to include('<script>evil</script>')
|
|
90
|
+
expect(results).to include('<script>evil</script>', 'No matches')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'renders a nested resource full path and escaped ancestor context without a duplicate leaf label' do
|
|
94
|
+
app
|
|
95
|
+
server = instance_variable_get(:@api)
|
|
96
|
+
allow(server).to receive(:describe_version).and_wrap_original do |original, *arguments|
|
|
97
|
+
original.call(*arguments).tap { |help| help[:resources]['widget'][:resources]['child'][:label] = 'Child <node>' }
|
|
98
|
+
end
|
|
99
|
+
get '/v1/', page: 'resources', q: 'widget.child'
|
|
100
|
+
expect(last_response).to be_ok
|
|
101
|
+
expect(results).to include('Resource: <code>widget.child</code>', '<span>Widget · Child <node></span>',
|
|
102
|
+
'href="/v1/?resource=widget.child"', '<span>widget.child · show</span>')
|
|
103
|
+
expect(results).not_to include('Resource: <code>child</code>', '<span>child</span>', '<span>Child <node></span>', '<node>')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'does not run query matching on unqueried pages or a query supplied to another page' do
|
|
107
|
+
allow(HaveAPI::DocumentationSearch).to receive(:new).and_call_original
|
|
108
|
+
['/v1/', '/v1/?resource=widget', '/v1/?page=resources', '/v1/?page=resources&q=++', '/v1/?q=widget'].each do |path|
|
|
109
|
+
get path
|
|
110
|
+
expect(last_response).to be_ok
|
|
111
|
+
end
|
|
112
|
+
expect(HaveAPI::DocumentationSearch).not_to have_received(:new)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'rejects duplicate, bracketed, malformed UTF-8 and overlong search queries before describing' do
|
|
116
|
+
app
|
|
117
|
+
server = instance_variable_get(:@api)
|
|
118
|
+
allow(server).to receive(:describe_version).and_call_original
|
|
119
|
+
['q=a&q=b', 'q[]=widget', 'q[x]=widget', 'q=%FF', 'q=%C0%AF', 'q=%ZZ', "q=#{'a' * 257}"].each do |query|
|
|
120
|
+
response = Rack::MockRequest.new(app).get('/v1/', 'HTTP_HOST' => 'example.test', 'QUERY_STRING' => "page=resources&#{query}")
|
|
121
|
+
expect(response.status).to eq(400), query
|
|
122
|
+
end
|
|
123
|
+
expect(server).not_to have_received(:describe_version)
|
|
124
|
+
get '/v1/', page: 'resources', q: 'ž' * 256
|
|
125
|
+
expect(last_response).to be_ok
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'links parameter results to the exact row and keeps its owning action as a containing section' do
|
|
129
|
+
get '/v1/', page: 'resources', q: 'lifetime'
|
|
130
|
+
anchor = HaveAPI::DocumentationSearch.parameter_anchor(resource_path: ['widget'], action_name: 'renew',
|
|
131
|
+
direction: :input, parameter_name: 'lifetime')
|
|
132
|
+
expect(results).to include("href=\"/v1/?resource=widget##{anchor}\"")
|
|
133
|
+
get '/v1/?resource=widget'
|
|
134
|
+
section = last_response.body.match(%r{<section data-doc-section id="root-widget-renew">(.*?)</section>}m)[1]
|
|
135
|
+
expect(section).to include("<tr id=\"#{anchor}\"")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'links advertised provider parameters to their actual authentication-page rows' do
|
|
139
|
+
request = Rack::MockRequest.new(DynamicDocumentationAPI.build.app)
|
|
140
|
+
response = request.get('/mount/v2/?page=resources&q=token+request+user', 'HTTP_HOST' => 'example.test')
|
|
141
|
+
expect(response.status).to eq(200)
|
|
142
|
+
anchor = HaveAPI::DocumentationSearch.parameter_anchor(resource_path: ['token'], action_name: 'request',
|
|
143
|
+
direction: :input, parameter_name: 'user', authentication: :token)
|
|
144
|
+
expect(response.body).to include("href=\"/mount/v2/?page=authentication##{anchor}\"", 'Authentication: token')
|
|
145
|
+
page = request.get('/mount/v2/?page=authentication', 'HTTP_HOST' => 'example.test')
|
|
146
|
+
expect(page.body).to include("<tr id=\"#{anchor}\"")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'keeps result links canonical under both API and Rack mounts and leaves OPTIONS query semantics unchanged' do
|
|
150
|
+
server = HaveAPI::Server.new(get_opt(:api_module))
|
|
151
|
+
server.use_version(1)
|
|
152
|
+
server.mount('/api/')
|
|
153
|
+
response = Rack::MockRequest.new(server.app).get('/api/v1/?page=resources&q=lifetime',
|
|
154
|
+
'HTTP_HOST' => 'example.test', 'SCRIPT_NAME' => '/rack')
|
|
155
|
+
expect(response.status).to eq(200)
|
|
156
|
+
expect(response.body).to include('action="/rack/api/v1/"', 'href="/rack/api/v1/?resource=widget#parameter-')
|
|
157
|
+
call_api(:options, '/v1/?page=resources&q[]=bad&q=other')
|
|
158
|
+
expect(last_response).to be_ok
|
|
159
|
+
expect(api_response[:resources]).to have_key(:widget)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
context 'with OAuth documentation sessions' do
|
|
163
|
+
let(:documentation_server) do
|
|
164
|
+
HaveAPI::Server.new(self.class.const_get(:ApiModule)).tap do |api|
|
|
165
|
+
api.use_version(1)
|
|
166
|
+
api.auth_chain << DocumentationSearchRoutesSpec::Provider
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
let(:documentation_fixture) do
|
|
170
|
+
require_relative '../../test_support/documentation_oauth_fixture'
|
|
171
|
+
DocumentationOAuthFixture.configure(documentation_server, users: {
|
|
172
|
+
1 => { 'member-token' => DocumentationSearchRoutesSpec::User.new('member'),
|
|
173
|
+
'admin-token' => DocumentationSearchRoutesSpec::User.new('admin') }
|
|
174
|
+
})
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
before do
|
|
178
|
+
documentation_fixture
|
|
179
|
+
documentation_server.mount('/')
|
|
180
|
+
@api = documentation_server
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'uses only cookie-user accessible parameters and actions while anonymous docs retain their full view' do
|
|
184
|
+
get "#{documentation_fixture.origin}/v1/", page: 'resources', q: 'private_ticket'
|
|
185
|
+
expect(results).to include('private_ticket')
|
|
186
|
+
header 'Cookie', documentation_fixture.header_for(documentation_server, version: 1, token: 'member-token')
|
|
187
|
+
login('admin', 'pass')
|
|
188
|
+
get "#{documentation_fixture.origin}/v1/", page: 'resources', q: 'private_ticket'
|
|
189
|
+
expect(results).not_to include('<code>private_ticket</code>', 'Secret ticket', 'DEFAULT_SECRET')
|
|
190
|
+
get "#{documentation_fixture.origin}/v1/", page: 'resources', q: 'hidden'
|
|
191
|
+
expect(results).not_to include('<code>hidden</code>', 'private_response')
|
|
192
|
+
header 'Cookie', documentation_fixture.header_for(documentation_server, version: 1, token: 'admin-token')
|
|
193
|
+
login('member', 'pass')
|
|
194
|
+
get "#{documentation_fixture.origin}/v1/", page: 'resources', q: 'private_ticket'
|
|
195
|
+
expect(results).to include('<code>private_ticket</code>')
|
|
196
|
+
expect(documentation_fixture.lookups).to eq([[1, 'member-token'], [1, 'member-token'], [1, 'admin-token']])
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe HaveAPI::DocumentationSearch do
|
|
4
|
+
let(:action) do
|
|
5
|
+
{ method: 'POST', path: '/v1/widgets/{id}/renew', aliases: ['extend'],
|
|
6
|
+
input: { parameters: { lifetime: { label: 'Lease duration', type: 'Integer', default: 'DEFAULT_SECRET',
|
|
7
|
+
validators: { include: { values: ['VALIDATOR_SECRET'] } } } } },
|
|
8
|
+
output: { parameters: { valid_to: { label: 'Expires at', type: 'Datetime' } } },
|
|
9
|
+
description: 'DESCRIPTION_SECRET', examples: [{ request: { name: 'EXAMPLE_SECRET' } }] }
|
|
10
|
+
end
|
|
11
|
+
let(:description) do
|
|
12
|
+
{ resources: { widget: { actions: { renew: action }, resources: {} } },
|
|
13
|
+
authentication: { token: { resources: { token: { actions: { request: action }, resources: {} } } } } }
|
|
14
|
+
end
|
|
15
|
+
let(:presentation) { Object.new.extend(described_class::Helpers) }
|
|
16
|
+
|
|
17
|
+
def search(query, source = description)
|
|
18
|
+
index = described_class::Index.new(description: source, base_path: '/v1/', version: '1', locale: 'en', visibility: 'anonymous')
|
|
19
|
+
described_class.new(index: index.payload, query:).call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'uses complete resource identities and ancestor labels without changing the index' do
|
|
23
|
+
fixture = JSON.parse(File.read(File.join(__dir__, 'fixtures/search_index.json')))
|
|
24
|
+
index = fixture.fetch('index')
|
|
25
|
+
before = JSON.generate(index)
|
|
26
|
+
fixture.fetch('display_queries').each do |query|
|
|
27
|
+
results = described_class.new(index:, query: query.fetch('query')).call[:results]
|
|
28
|
+
display = results.map do |result|
|
|
29
|
+
{ 'title' => "#{result[:kind].to_s.capitalize}: #{presentation.documentation_search_result_title(result)}",
|
|
30
|
+
'context' => presentation.documentation_search_result_context(result) }
|
|
31
|
+
end
|
|
32
|
+
expect(display).to eq(query.fetch('expected'))
|
|
33
|
+
end
|
|
34
|
+
expect(JSON.generate(index)).to eq(before)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'keeps ancestor labels within their provider and omits an identical resource context' do
|
|
38
|
+
source = { resources: { token: { label: 'Public tokens', resources: { child: { label: 'Public child' } } },
|
|
39
|
+
same: { label: 'same' } },
|
|
40
|
+
authentication: { token: { resources: { token: { label: 'Private tokens',
|
|
41
|
+
resources: { child: { label: 'Private child' } } } } } } }
|
|
42
|
+
results = search('child', source)[:results]
|
|
43
|
+
labels = results.map { |result| [result[:authentication], result[:resource_label]] }
|
|
44
|
+
expect(labels).to eq([[nil, 'Public tokens · Public child'], ['token', 'Private tokens · Private child']])
|
|
45
|
+
same = search('same', source)[:results].first
|
|
46
|
+
expect(presentation.documentation_search_result_context(same)).to eq([])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'uses machine segments when a compact fixture omits an ancestor record' do
|
|
50
|
+
index = { resources: [{ path: %w[parent child], label: 'Child label', search: ['child'] }], actions: [], parameters: [] }
|
|
51
|
+
result = described_class.new(index:, query: 'child').call[:results].first
|
|
52
|
+
expect(result[:resource_label]).to eq('parent · Child label')
|
|
53
|
+
expect(presentation.documentation_search_result_title(result)).to eq('parent.child')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'matches all case-insensitive query tokens across parent and selected metadata' do
|
|
57
|
+
result = search('WIDGET lease INTEGER')
|
|
58
|
+
expect(result[:results]).to include(include(kind: :parameter, name: 'lifetime', direction: :input,
|
|
59
|
+
type: 'Integer', resource_path: ['widget'], action_name: 'renew'))
|
|
60
|
+
expect(search('extend POST')[:results]).to include(include(kind: :action, name: 'renew'))
|
|
61
|
+
expect(search('/v1/widgets/{id}/renew')[:results]).to include(include(kind: :action, name: 'renew'))
|
|
62
|
+
expect(search('Expires')[:results]).to include(include(name: 'valid_to', direction: :output, type: 'Datetime'))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'excludes examples, defaults, validators, arbitrary prose and provider credentials' do
|
|
66
|
+
source = Marshal.load(Marshal.dump(description))
|
|
67
|
+
source[:authentication][:token][:password] = 'CREDENTIAL_SECRET'
|
|
68
|
+
%w[DEFAULT_SECRET VALIDATOR_SECRET DESCRIPTION_SECRET EXAMPLE_SECRET CREDENTIAL_SECRET].each do |query|
|
|
69
|
+
expect(search(query, source)[:results]).to eq([])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'ranks exact machine matches before prefixes and remaining token matches with deterministic ties' do
|
|
74
|
+
tree = %w[widget_long mywidget widget].reverse.to_h do |name|
|
|
75
|
+
[name, { actions: {}, resources: {} }]
|
|
76
|
+
end
|
|
77
|
+
source = { resources: tree }
|
|
78
|
+
expect(search('widget', source)[:results].map { |result| result[:name] }).to eq(%w[widget widget_long mywidget])
|
|
79
|
+
reverse = { resources: tree.to_a.reverse.to_h }
|
|
80
|
+
expect(search('widget', reverse)).to eq(search('widget', source))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'prioritizes resources, actions and parameters before match strength within each kind' do
|
|
84
|
+
source = { resources: { mywidget: { actions: {
|
|
85
|
+
widget: { input: { parameters: { widget: { type: 'String' } } } },
|
|
86
|
+
widget_long: { input: { parameters: { widget_long: { type: 'String' } } } }
|
|
87
|
+
} }, widget: { actions: {} } } }
|
|
88
|
+
results = search('widget', source)[:results]
|
|
89
|
+
expect(results.map { |result| [result[:kind], result[:name]] }).to eq([
|
|
90
|
+
[:resource, 'widget'], [:resource, 'mywidget'],
|
|
91
|
+
[:action, 'widget'], [:action, 'widget_long'],
|
|
92
|
+
[:parameter, 'widget'], [:parameter, 'widget_long']
|
|
93
|
+
])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'caps returned matches at twenty and explicitly reports more without changing the source' do
|
|
97
|
+
tree = 32.times.to_h { |index| ["widget#{index}", { actions: {}, resources: {} }] }
|
|
98
|
+
source = { resources: tree }
|
|
99
|
+
before = Marshal.dump(source)
|
|
100
|
+
result = search('widget', source)
|
|
101
|
+
expect(result[:results].length).to eq(20)
|
|
102
|
+
expect(result[:more]).to be(true)
|
|
103
|
+
expect(search('widget31', source)[:more]).to be(false)
|
|
104
|
+
expect(search('absent', source)).to eq(query: 'absent', results: [], more: false)
|
|
105
|
+
expect(Marshal.dump(source)).to eq(before)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'returns provider-resource matches only from the supplied filtered authentication tree' do
|
|
109
|
+
results = search('token lifetime')[:results]
|
|
110
|
+
expect(results).to contain_exactly(include(kind: :parameter, authentication: 'token', resource_path: ['token'],
|
|
111
|
+
action_name: 'request', name: 'lifetime'))
|
|
112
|
+
expect(search('token', description.merge(authentication: {}))[:results]).to eq([])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'supports serialized string keys and nested resource context without reading hidden trees' do
|
|
116
|
+
source = JSON.parse(JSON.generate(description))
|
|
117
|
+
source['resources']['parent'] = { 'actions' => {}, 'resources' => { 'child' => { 'actions' => { 'renew' => action } } } }
|
|
118
|
+
expect(search('parent child duration', source)[:results]).to include(include(resource_path: %w[parent child], name: 'lifetime'))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'encodes every parameter anchor component unambiguously and uses it in result records' do
|
|
122
|
+
arguments = { resource_path: %w[a b-c], action_name: 'show', direction: :input, parameter_name: 'x-y' }
|
|
123
|
+
first = described_class.parameter_anchor(**arguments)
|
|
124
|
+
variations = [arguments.merge(resource_path: %w[a-b c]), arguments.merge(action_name: 'show-input'),
|
|
125
|
+
arguments.merge(direction: :output), arguments.merge(parameter_name: 'x/y "ž"'),
|
|
126
|
+
arguments.merge(authentication: 'token')]
|
|
127
|
+
anchors = [first] + variations.map { |value| described_class.parameter_anchor(**value) }
|
|
128
|
+
expect(anchors.uniq.length).to eq(anchors.length)
|
|
129
|
+
expect(anchors).to all(match(/\Aparameter-[0-9a-f]+\z/))
|
|
130
|
+
record = search('widget lifetime')[:results].first
|
|
131
|
+
expect(record[:anchor]).to eq(described_class.parameter_anchor(resource_path: ['widget'], action_name: 'renew',
|
|
132
|
+
direction: :input, parameter_name: 'lifetime'))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'rack/mock'
|
|
3
|
+
|
|
4
|
+
RSpec.describe HaveAPI::SharedDocumentationHelpers do
|
|
5
|
+
api do
|
|
6
|
+
define_resource(:Widget) do
|
|
7
|
+
version 1
|
|
8
|
+
auth false
|
|
9
|
+
|
|
10
|
+
define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
|
|
11
|
+
authorize { allow }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
define_resource(:OtherWidget) do
|
|
16
|
+
version 2
|
|
17
|
+
auth false
|
|
18
|
+
|
|
19
|
+
define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
|
|
20
|
+
authorize { allow }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
default_version 1
|
|
26
|
+
|
|
27
|
+
def json_element(body, name)
|
|
28
|
+
JSON.parse(body.match(%r{<script[^>]*#{name}[^>]*>(.*?)</script>}m)[1])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def expect_neutral_shell(body, page)
|
|
32
|
+
expect(body).to include('data-docs', "data-page=\"#{page}\"", 'data-resource=""',
|
|
33
|
+
'id="docs-main"', '/js/documentation.js', '/css/documentation.css',
|
|
34
|
+
'Generated by')
|
|
35
|
+
expect(body).not_to include('/js/main.js', '/js/jquery', '/css/bootstrap', 'data-resource-nav',
|
|
36
|
+
'data-language-select', 'data-documentation-login', 'docs-identity', 'docs-visibility-note',
|
|
37
|
+
'class="docs-sign-in', 'class="authentication"')
|
|
38
|
+
expect(body).to include('<details data-documentation-menu>', 'data-documentation-menu-content')
|
|
39
|
+
expect(body).not_to include('data-theme-menu', 'data-account-menu')
|
|
40
|
+
expect(json_element(body, 'data-documentation-clients')).to eq([])
|
|
41
|
+
expect(json_element(body, 'data-legacy-anchors')).to eq({})
|
|
42
|
+
%w[auto light dark].each do |theme|
|
|
43
|
+
expect(body).to include("data-theme-choice=\"#{theme}\"")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'keeps root discovery once and renders its existing versions in the common shell' do
|
|
48
|
+
app
|
|
49
|
+
server = instance_variable_get(:@api)
|
|
50
|
+
allow(server).to receive(:describe).and_call_original
|
|
51
|
+
|
|
52
|
+
get '/'
|
|
53
|
+
|
|
54
|
+
expect(last_response).to be_ok
|
|
55
|
+
expect(last_response.headers['Cache-Control']).to eq('private, no-store')
|
|
56
|
+
expect(last_response.body).to include('<h1>API versions</h1>', 'href="/v1/"', 'href="/v2/"',
|
|
57
|
+
'Version 1', 'Version 2', '(default)', '<title>API versions')
|
|
58
|
+
main = last_response.body.match(%r{<main\b[^>]*>(.*?)</main>}m)[1]
|
|
59
|
+
expect(main.scan(/<h1\b/).length).to eq(1)
|
|
60
|
+
expect(main).not_to include('API description', 'Available versions:')
|
|
61
|
+
expect_neutral_shell(last_response.body, 'versions')
|
|
62
|
+
expect(server).to have_received(:describe).once
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
'/doc' => 'Server-side implementation',
|
|
67
|
+
'/doc/index' => 'Server-side implementation',
|
|
68
|
+
'/doc/index.md' => 'Server-side implementation',
|
|
69
|
+
'/doc/readme' => 'HaveAPI',
|
|
70
|
+
'/doc/protocol' => 'Protocol definition',
|
|
71
|
+
'/doc/protocol.md' => 'Protocol definition',
|
|
72
|
+
'/doc/create-client' => 'Client definition',
|
|
73
|
+
'/doc/create-client.md' => 'Client definition',
|
|
74
|
+
'/doc/client-examples' => 'Client examples',
|
|
75
|
+
'/doc/client-examples.md' => 'Client examples',
|
|
76
|
+
'/doc/json-schema' => 'Envelope'
|
|
77
|
+
}.each do |path, heading|
|
|
78
|
+
it "serves #{path} in the neutral shell without API discovery or cookie authentication" do
|
|
79
|
+
app
|
|
80
|
+
server = instance_variable_get(:@api)
|
|
81
|
+
allow(server).to receive(:describe).and_call_original
|
|
82
|
+
allow(server).to receive(:describe_version).and_call_original
|
|
83
|
+
allow(server.auth_chain).to receive(:authenticate).and_call_original
|
|
84
|
+
|
|
85
|
+
get path
|
|
86
|
+
|
|
87
|
+
expect(last_response).to be_ok
|
|
88
|
+
expect(last_response.headers['Content-Type']).to include('text/html')
|
|
89
|
+
expect(last_response.headers['Cache-Control']).to eq('private, no-store')
|
|
90
|
+
expect(last_response.body).to include(heading, 'aria-label="Breadcrumb"',
|
|
91
|
+
'href="/"', 'href="/doc"', 'href="/v1/?page=resources"')
|
|
92
|
+
expect_neutral_shell(last_response.body, 'guide')
|
|
93
|
+
breadcrumb = last_response.body.match(%r{<nav aria-label="Breadcrumb">(.*?)</nav>}m)[1]
|
|
94
|
+
expect(breadcrumb).to include('API versions', 'HaveAPI internals')
|
|
95
|
+
expect(breadcrumb.scan(/<li\b/).length).to eq(path.match?(%r{\A/doc(?:/(?:index(?:\.md)?|readme))?\z}) ? 2 : 3)
|
|
96
|
+
expect(server).not_to have_received(:describe)
|
|
97
|
+
expect(server).not_to have_received(:describe_version)
|
|
98
|
+
expect(server.auth_chain).not_to have_received(:authenticate)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'renders the real README for every internal landing alias with one sidebar menu' do
|
|
103
|
+
pages = %w[/doc /doc/index /doc/index.md /doc/readme].map do |path|
|
|
104
|
+
get path
|
|
105
|
+
body = last_response.body
|
|
106
|
+
main = body.match(%r{<main\b[^>]*>(.*?)</main>}m)[1]
|
|
107
|
+
expect(main).to include('<h1', '>HaveAPI</h1>', 'Server-side implementation', 'Server framework features',
|
|
108
|
+
'<pre><code class="ruby">', 'class User < ActiveRecord::Base')
|
|
109
|
+
expect(body).to include('<title>HaveAPI internals · ')
|
|
110
|
+
expect(main).not_to include('href="/doc/protocol"', 'href="/doc/client-examples"')
|
|
111
|
+
expect(main.scan('HaveAPI internals').length).to eq(1)
|
|
112
|
+
menu = body.match(%r{<nav aria-label="Shared documentation">(.*?)</nav>}m)[1]
|
|
113
|
+
expect(menu).to include('<h2><a href="/doc">HaveAPI internals</a></h2>')
|
|
114
|
+
expect(menu).not_to include('>Overview</a>')
|
|
115
|
+
expect(menu).not_to include('href="/doc/readme"')
|
|
116
|
+
%w[protocol json-schema create-client client-examples].each do |article|
|
|
117
|
+
expect(menu.scan("href=\"/doc/#{article}\"").length).to eq(1)
|
|
118
|
+
end
|
|
119
|
+
main
|
|
120
|
+
end
|
|
121
|
+
expect(pages.uniq.length).to eq(1)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'uses configured escaped identity or the actual host and port in neutral titles and brand' do
|
|
125
|
+
server = HaveAPI::Server.new(get_opt(:api_module))
|
|
126
|
+
server.use_version(1)
|
|
127
|
+
server.documentation_name = '<API & site>'
|
|
128
|
+
server.mount('/')
|
|
129
|
+
request = Rack::MockRequest.new(server.app)
|
|
130
|
+
%w[/ /doc /doc/protocol].each do |path|
|
|
131
|
+
response = request.get(path, 'HTTP_HOST' => 'example.test:4567')
|
|
132
|
+
expect(response.status).to eq(200)
|
|
133
|
+
expect(response.body).to include('href="/"><API & site></a>', ' · <API & site></title>')
|
|
134
|
+
expect(response.body).not_to include('<API & site>')
|
|
135
|
+
end
|
|
136
|
+
fallback = HaveAPI::Server.new(get_opt(:api_module))
|
|
137
|
+
fallback.use_version(1)
|
|
138
|
+
fallback.mount('/')
|
|
139
|
+
response = Rack::MockRequest.new(fallback.app).get('/doc/protocol', 'HTTP_HOST' => 'example.test:4567')
|
|
140
|
+
expect(response.body).to include('href="/">example.test:4567</a>', ' · example.test:4567</title>')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
%w[protocol create-client json-schema].each do |name|
|
|
144
|
+
it "uses actual heading IDs for every #{name} TOC target" do
|
|
145
|
+
get "/doc/#{name}"
|
|
146
|
+
body = last_response.body
|
|
147
|
+
headings = body.scan(/<h[1-6]\b[^>]*\bid="([^"]+)"/).flatten
|
|
148
|
+
navigation = body.match(%r{<nav[^>]*data-action-nav[^>]*>(.*?)</nav>}m)[1]
|
|
149
|
+
targets = navigation.scan(/href="#([^"]+)"/).flatten
|
|
150
|
+
expect(targets).not_to be_empty
|
|
151
|
+
expect(targets - headings).to eq([])
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it 'keeps semantic shared tables in keyboard-accessible overflow regions without changing their cells' do
|
|
156
|
+
get '/doc/performance'
|
|
157
|
+
tables = last_response.body.scan(%r{<table\b[^>]*>.*?</table>}m)
|
|
158
|
+
expect(tables).not_to be_empty
|
|
159
|
+
tables.each do |table|
|
|
160
|
+
expect(last_response.body).to include(%(<div class="docs-table-scroll" role="region" aria-label="Documentation table" tabindex="0">#{table}</div>))
|
|
161
|
+
end
|
|
162
|
+
expect(last_response.body).to include('<th>Measurement policy</th>')
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'keeps missing documents and unsupported aliases at 404' do
|
|
166
|
+
%w[/doc/missing /doc/missing.md /doc/readme.md /doc/json-schema.md].each do |path|
|
|
167
|
+
get path
|
|
168
|
+
expect(last_response.status).to eq(404), path
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it 'escapes version labels and links from the existing index description' do
|
|
173
|
+
app
|
|
174
|
+
server = instance_variable_get(:@api)
|
|
175
|
+
label = '<script>version</script>'
|
|
176
|
+
help = '/v1/" onclick="unsafe'
|
|
177
|
+
allow(server).to receive(:describe).and_return(versions: { label => { help: } }, default_version: label)
|
|
178
|
+
get '/'
|
|
179
|
+
expect(last_response.body).to include('Version <script>version</script>', 'href="/v1/" onclick="unsafe"')
|
|
180
|
+
expect(last_response.body).not_to include(label, 'href="/v1/" onclick="unsafe"')
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'keeps shared and root links mounted under both the API and Rack prefixes' do
|
|
184
|
+
server = HaveAPI::Server.new(get_opt(:api_module))
|
|
185
|
+
server.use_version(1)
|
|
186
|
+
server.mount('/api/')
|
|
187
|
+
request = Rack::MockRequest.new(server.app)
|
|
188
|
+
%w[/api/ /api/doc/protocol].each do |path|
|
|
189
|
+
response = request.get(path, 'HTTP_HOST' => 'example.test', 'SCRIPT_NAME' => '/rack')
|
|
190
|
+
expect(response.status).to eq(200)
|
|
191
|
+
expect(response['Cache-Control']).to eq('private, no-store')
|
|
192
|
+
expect(response.body).to include('href="/rack/api/"', 'href="/rack/api/doc"',
|
|
193
|
+
'href="/rack/api/v1/"', 'href="/rack/api/v1/?page=resources"',
|
|
194
|
+
'src="/rack/js/documentation.js"')
|
|
195
|
+
expect(response.body).not_to include('href="/doc"', 'href="/v1/"')
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'keeps guides anonymous and omits Account when documentation OAuth is not configured' do
|
|
200
|
+
server = HaveAPI::Server.new(get_opt(:api_module))
|
|
201
|
+
server.use_version(1)
|
|
202
|
+
server.mount('/api/')
|
|
203
|
+
allow(server).to receive(:describe).and_call_original
|
|
204
|
+
allow(server).to receive(:describe_version).and_call_original
|
|
205
|
+
allow(server.auth_chain).to receive(:authenticate).and_call_original
|
|
206
|
+
response = Rack::MockRequest.new(server.app).get('/api/doc/protocol', 'HTTP_HOST' => 'example.test')
|
|
207
|
+
expect(response.status).to eq(200)
|
|
208
|
+
expect(response.body).not_to include('aria-label="Account"')
|
|
209
|
+
expect(response.body).not_to include('data-account-menu', 'data-documentation-login', 'docs-visibility-note')
|
|
210
|
+
expect(server).not_to have_received(:describe)
|
|
211
|
+
expect(server).not_to have_received(:describe_version)
|
|
212
|
+
expect(server.auth_chain).not_to have_received(:authenticate)
|
|
213
|
+
get '/doc/protocol'
|
|
214
|
+
expect(last_response.body).not_to include('aria-label="Account"', 'data-account-menu')
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
context 'with documentation OAuth configured' do
|
|
218
|
+
it 'offers a neutral Account link without authenticating or describing even with a real session cookie' do
|
|
219
|
+
require_relative '../../test_support/documentation_oauth_fixture'
|
|
220
|
+
server = HaveAPI::Server.new(self.class.const_get(:ApiModule))
|
|
221
|
+
server.use_version(1)
|
|
222
|
+
user = Struct.new(:login).new('guide-user')
|
|
223
|
+
fixture = DocumentationOAuthFixture.configure(server, users: { 1 => { 'guide-token' => user } }, mount_path: '/api/')
|
|
224
|
+
server.mount('/api/')
|
|
225
|
+
allow(server).to receive(:describe).and_call_original
|
|
226
|
+
allow(server).to receive(:describe_version).and_call_original
|
|
227
|
+
allow(server.auth_chain).to receive(:authenticate).and_call_original
|
|
228
|
+
cookie = fixture.header_for(server, version: 1, token: 'guide-token')
|
|
229
|
+
response = Rack::MockRequest.new(server.app).get("#{fixture.origin}/api/doc/protocol",
|
|
230
|
+
'HTTP_HOST' => URI(fixture.origin).host, 'HTTP_COOKIE' => cookie)
|
|
231
|
+
expect(response.status).to eq(200)
|
|
232
|
+
expect(response.body).to include('href="/api/v1/?page=authentication" aria-label="Account"')
|
|
233
|
+
expect(response.body).not_to include('guide-user', 'data-documentation-login', 'docs-visibility-note')
|
|
234
|
+
expect(fixture.lookups).to be_empty
|
|
235
|
+
expect(server).not_to have_received(:describe)
|
|
236
|
+
expect(server).not_to have_received(:describe_version)
|
|
237
|
+
expect(server.auth_chain).not_to have_received(:authenticate)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|