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,45 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe HaveAPI::DocumentationOAuth::Transport do
|
|
4
|
+
let(:uri) { URI('https://issuer.example/token') }
|
|
5
|
+
let(:http) { instance_double(Net::HTTP) }
|
|
6
|
+
let(:response) { instance_double(Net::HTTPOK, code: '200') }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
allow(Net::HTTP).to receive(:start).and_yield(http)
|
|
10
|
+
allow(http).to receive(:max_retries=)
|
|
11
|
+
allow(http).to receive(:request).and_yield(response)
|
|
12
|
+
allow(response).to receive(:read_body).and_yield('{}')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'requires verified HTTPS with bounded timeouts and sends credentials only in the body' do
|
|
16
|
+
expect(described_class.new.post(uri, 'client_secret' => 'private')).to eq('{}')
|
|
17
|
+
expect(Net::HTTP).to have_received(:start).with('issuer.example', 443,
|
|
18
|
+
use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_PEER,
|
|
19
|
+
open_timeout: 5, read_timeout: 10, write_timeout: 10)
|
|
20
|
+
expect(http).to have_received(:max_retries=).with(0)
|
|
21
|
+
expect(http).to have_received(:request) do |request|
|
|
22
|
+
expect(request.uri.to_s).to eq('https://issuer.example/token')
|
|
23
|
+
expect(request.body).to eq('client_secret=private')
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'rejects redirects without following them or exposing their body' do
|
|
28
|
+
allow(response).to receive(:code).and_return('302')
|
|
29
|
+
allow(response).to receive(:read_body).and_yield('PRIVATE_PROVIDER_BODY')
|
|
30
|
+
expect { described_class.new.post(uri, {}) }.to raise_error(HaveAPI::DocumentationOAuth::Failure, 'Documentation OAuth request failed')
|
|
31
|
+
expect(http).to have_received(:request).once
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'bounds the body while streaming rather than reading an unbounded response' do
|
|
35
|
+
allow(response).to receive(:read_body).and_yield('x' * 8192).and_yield('x' * 8193)
|
|
36
|
+
expect { described_class.new.post(uri, {}) }.to raise_error(HaveAPI::DocumentationOAuth::Failure)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'converts TLS and DNS failures into generic authentication failure' do
|
|
40
|
+
[OpenSSL::SSL::SSLError.new('PRIVATE_TLS_DETAIL'), SocketError.new('PRIVATE_DNS_DETAIL')].each do |error|
|
|
41
|
+
allow(Net::HTTP).to receive(:start).and_raise(error)
|
|
42
|
+
expect { described_class.new.post(uri, {}) }.to raise_error(HaveAPI::DocumentationOAuth::Failure, 'Documentation OAuth request failed')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -4,10 +4,24 @@ require_relative '../../test_support/documentation_performance_fixture'
|
|
|
4
4
|
|
|
5
5
|
describe HaveAPI::Server, '#mount' do
|
|
6
6
|
let(:server) do
|
|
7
|
-
HaveAPI::DocumentationPerformanceFixture.build(resource_count: 3, actions_per_resource: 2)
|
|
7
|
+
HaveAPI::DocumentationPerformanceFixture.build(resource_count: 3, actions_per_resource: 2, documentation_oauth: true)
|
|
8
8
|
end
|
|
9
9
|
let(:contexts) { [] }
|
|
10
10
|
let(:help_inputs) { [] }
|
|
11
|
+
let(:resource_derivations) { [] }
|
|
12
|
+
|
|
13
|
+
around do |example|
|
|
14
|
+
trace = TracePoint.new(:call) do |call|
|
|
15
|
+
next unless call.defined_class == HaveAPI::ClientExample::Context && call.method_id == :normalize
|
|
16
|
+
next unless call.binding.local_variable_get(:field) == :resource
|
|
17
|
+
|
|
18
|
+
value = call.binding.local_variable_get(:value)
|
|
19
|
+
resource_derivations << value if value
|
|
20
|
+
end
|
|
21
|
+
trace.enable { example.run }
|
|
22
|
+
ensure
|
|
23
|
+
trace&.disable
|
|
24
|
+
end
|
|
11
25
|
|
|
12
26
|
before do
|
|
13
27
|
HaveAPI::ClientExample.clients.each do |client|
|
|
@@ -25,19 +39,24 @@ describe HaveAPI::Server, '#mount' do
|
|
|
25
39
|
|
|
26
40
|
def request_as(user)
|
|
27
41
|
contexts.clear
|
|
42
|
+
resource_derivations.clear
|
|
28
43
|
Rack::MockRequest.new(server.app).get(
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
"#{server.fixture_documentation_oauth.origin}/benchmark/api/v1/?resource=resource0", 'HTTP_HOST' => 'example.test',
|
|
45
|
+
'HTTP_COOKIE' => server.fixture_documentation_cookie(user)
|
|
31
46
|
)
|
|
32
47
|
end
|
|
33
48
|
|
|
34
49
|
def expect_shared_request(response)
|
|
35
50
|
expect(response.status).to eq(200)
|
|
36
51
|
expect(response.body).to include('PUBLIC_SAMPLE_0', 'example-resource0-action0-0',
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
52
|
+
'data-client="javascript-callback"', 'data-client="javascript-async"',
|
|
53
|
+
'data-client="typescript-callback"', 'data-client="typescript-async"',
|
|
54
|
+
'data-client="python-sync"', 'data-client="python-async"')
|
|
55
|
+
expect(response.body).not_to include('id="example-resource1-', 'id="example-resource2-', 'init-examples')
|
|
40
56
|
expect(contexts.length).to be > 20
|
|
57
|
+
expect(contexts.map(&:resource_path).uniq).to eq([['resource0']])
|
|
58
|
+
expect(contexts.map { |context| context.resource.__id__ }.uniq.length).to eq(1)
|
|
59
|
+
expect(resource_derivations.length).to eq(1)
|
|
41
60
|
description = contexts.first.description
|
|
42
61
|
contexts.each { |context| expect(context.description).to equal(description) }
|
|
43
62
|
expect(description).to be_frozen
|
|
@@ -76,4 +95,26 @@ describe HaveAPI::Server, '#mount' do
|
|
|
76
95
|
expect(help[:resources]['resource0'][:actions]['action0'][:examples].first[:request]).not_to be_frozen
|
|
77
96
|
end
|
|
78
97
|
end
|
|
98
|
+
|
|
99
|
+
it 'does not alias distinct raw resources at the same canonical path' do
|
|
100
|
+
helper = Object.new
|
|
101
|
+
helper.extend(HaveAPI::Server::ServerHelpers)
|
|
102
|
+
parent = HaveAPI::ClientExample::Context.new(version: 1, host: 'example.test',
|
|
103
|
+
base_url: 'http://example.test/', version_url: 'http://example.test/v1/',
|
|
104
|
+
description: {})
|
|
105
|
+
helper.instance_variable_set(:@documentation_context, parent)
|
|
106
|
+
first = { actions: { 'show' => { description: 'first' } }, resources: {} }
|
|
107
|
+
second = { actions: { 'show' => { description: 'second' } }, resources: {} }
|
|
108
|
+
before = Marshal.dump([first, second])
|
|
109
|
+
a = helper.documentation_context(resource_path: [:widget], resource: first)
|
|
110
|
+
b = helper.documentation_context(resource_path: ['widget'], resource: first, detail: :thorough)
|
|
111
|
+
c = helper.documentation_context(resource_path: ['widget'], resource: second)
|
|
112
|
+
expect(a.resource).to equal(b.resource)
|
|
113
|
+
expect(a.resource).not_to equal(c.resource)
|
|
114
|
+
expect(a.resource).not_to equal(first)
|
|
115
|
+
expect(a.description).to equal(c.description)
|
|
116
|
+
expect(a.resource[:actions]['show'][:description]).to eq('first')
|
|
117
|
+
expect(c.resource[:actions]['show'][:description]).to eq('second')
|
|
118
|
+
expect(Marshal.dump([first, second])).to eq(before)
|
|
119
|
+
end
|
|
79
120
|
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
module DocumentationReferenceSpec
|
|
4
|
+
module AuthenticationResources
|
|
5
|
+
class Session < HaveAPI::Resource
|
|
6
|
+
version :all
|
|
7
|
+
auth false
|
|
8
|
+
class Show < HaveAPI::Action
|
|
9
|
+
route ''
|
|
10
|
+
http_method :get
|
|
11
|
+
input(:hash) { string :token }
|
|
12
|
+
output(:hash) { string :token }
|
|
13
|
+
authorize { allow }
|
|
14
|
+
# rubocop:disable RSpec/NoExpectationExample
|
|
15
|
+
example 'provider sample' do
|
|
16
|
+
request(token: 'visible')
|
|
17
|
+
response(token: 'visible')
|
|
18
|
+
end
|
|
19
|
+
# rubocop:enable RSpec/NoExpectationExample
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class FirstProvider < HaveAPI::Authentication::Base
|
|
25
|
+
auth_method :first
|
|
26
|
+
def register_routes(_sinatra, _prefix)
|
|
27
|
+
@server.routes[@version] ||= { authentication: {} }
|
|
28
|
+
@server.routes[@version][:authentication][name] = { resources: {} }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resource_module
|
|
32
|
+
AuthenticationResources
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class SecondProvider < FirstProvider
|
|
37
|
+
auth_method :second
|
|
38
|
+
def resource_module
|
|
39
|
+
AuthenticationResources
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe HaveAPI::DocumentationHelpers, '#render_version_documentation' do
|
|
45
|
+
api do
|
|
46
|
+
define_resource(:Parent) do
|
|
47
|
+
version 1
|
|
48
|
+
auth false
|
|
49
|
+
define_action(:Index) do
|
|
50
|
+
route ''
|
|
51
|
+
http_method :get
|
|
52
|
+
authorize { allow }
|
|
53
|
+
end
|
|
54
|
+
define_action(:Show) do
|
|
55
|
+
route '{parent_id}'
|
|
56
|
+
http_method :get
|
|
57
|
+
output(:hash) { string :value }
|
|
58
|
+
authorize { allow }
|
|
59
|
+
# rubocop:disable RSpec/NoExpectationExample
|
|
60
|
+
example '<Example>&' do
|
|
61
|
+
path_params 1
|
|
62
|
+
response(value: 'visible')
|
|
63
|
+
end
|
|
64
|
+
# rubocop:enable RSpec/NoExpectationExample
|
|
65
|
+
end
|
|
66
|
+
define_resource(:Child) do
|
|
67
|
+
define_action(:Show) do
|
|
68
|
+
route '{child_id}'
|
|
69
|
+
http_method :get
|
|
70
|
+
authorize { allow }
|
|
71
|
+
end
|
|
72
|
+
define_resource(:Grandchild) do
|
|
73
|
+
define_action(:Show) do
|
|
74
|
+
route '{grandchild_id}'
|
|
75
|
+
http_method :get
|
|
76
|
+
authorize { allow }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
default_version 1
|
|
83
|
+
|
|
84
|
+
def ids(body)
|
|
85
|
+
body.scan(/\sid="([^"]+)"/).flatten
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def assert_fragments(body)
|
|
89
|
+
body.scan(/<a[^>]*\shref="#([^"]+)"/).flatten.each do |target|
|
|
90
|
+
expect(ids(body).count(target)).to eq(1), target
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reference(**options)
|
|
95
|
+
Object.new.extend(described_class).documentation_reference_anchor(**options)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'encodes typed provider/resource/action/section/example tuples without delimiter collisions' do
|
|
99
|
+
arguments = { resource_path: %w[a-b c], action_name: 'show', section: :input }
|
|
100
|
+
target = reference(**arguments)
|
|
101
|
+
expect(target).to eq(reference(**arguments))
|
|
102
|
+
expect(JSON.parse([target.delete_prefix('reference-')].pack('H*'))).to eq([nil, %w[a-b c], 'show', 'input', nil])
|
|
103
|
+
alternatives = [reference(**arguments, authentication: :first), reference(**arguments, authentication: :second),
|
|
104
|
+
reference(**arguments, resource_path: %w[a b-c]), reference(**arguments, section: :output),
|
|
105
|
+
reference(**arguments, example_index: 0), reference(**arguments, example_index: 1)]
|
|
106
|
+
expect([target, *alternatives].uniq.length).to eq(7)
|
|
107
|
+
expect(reference(resource_path: ['žluťoučký'], section: :examples)).to match(/\Areference-[0-9a-f]+\z/)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'provides native per-action sections including empty input/output and titled examples' do
|
|
111
|
+
get '/v1/?resource=parent'
|
|
112
|
+
expect(last_response).to be_ok
|
|
113
|
+
body = last_response.body
|
|
114
|
+
expect(body).to include('data-doc-nav-owner="root-parent-index"', 'data-doc-primary href="#root-parent-index"',
|
|
115
|
+
'data-doc-subnav="root-parent-index"', 'data-doc-subsection', 'No parameters.',
|
|
116
|
+
'aria-label="Link to Input parameters"', '<Example>&')
|
|
117
|
+
index_menu = body.match(%r{<ul data-doc-subnav="root-parent-index">(.*?)</ul>}m)[1]
|
|
118
|
+
expect(index_menu).to include('Input parameters', 'Output parameters')
|
|
119
|
+
expect(index_menu).not_to include('Examples')
|
|
120
|
+
expect(body).to include('id="example-parent-show-0-examples"', 'id="example-parent-show-0-0"')
|
|
121
|
+
expect(body).not_to include('data-doc-subnav="root-parent-index" hidden')
|
|
122
|
+
assert_fragments(body)
|
|
123
|
+
File.write(File.join(ENV['HAVEAPI_REFERENCE_FIXTURES'], 'resource.html'), body) if ENV['HAVEAPI_REFERENCE_FIXTURES']
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'emits one panel per group declaration and page metadata from actual rendered groups' do
|
|
127
|
+
['/v1/', '/v1/?resource=parent', '/v1/?page=resources'].each do |path|
|
|
128
|
+
get path
|
|
129
|
+
body = last_response.body
|
|
130
|
+
groups = body.scan(%r{<div class="docs-example" data-example data-example-details="([^"]*)" id="([^"]*)">(.*?)</div>}m)
|
|
131
|
+
union = {}
|
|
132
|
+
groups.each do |json, base, html|
|
|
133
|
+
levels = JSON.parse(CGI.unescapeHTML(json))
|
|
134
|
+
panels = html.scan(/data-client="([^"]+)"[^>]*data-detail="([^"]+)"/)
|
|
135
|
+
expect(panels.sort).to eq(levels.flat_map { |client, details| details.map { |detail| [client, detail] } }.sort)
|
|
136
|
+
expect(panels.uniq).to eq(panels)
|
|
137
|
+
levels.each do |client, details|
|
|
138
|
+
union[client] = (union.fetch(client, []) | details)
|
|
139
|
+
if details == ['simple']
|
|
140
|
+
expect(ids(body)).to include("#{base.delete_suffix('-examples')}-#{client}-thorough")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
clients = JSON.parse(body.match(%r{data-documentation-clients>(.*?)</script>}m)[1])
|
|
145
|
+
clients.each { |client| expect(client['details']).to eq(union.fetch(client['id'], ['simple'])) }
|
|
146
|
+
expect(clients.find { |client| client['id'] == '0' }.fetch('details')).to eq(path == '/v1/?page=resources' ? ['simple'] : %w[simple thorough])
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'lists direct subresources with full dotted identities and reuses existing reference targets' do
|
|
151
|
+
get '/v1/?resource=parent'
|
|
152
|
+
sidebar = last_response.body.match(%r{<nav aria-label="Subresources">(.*?)</nav>}m)[1]
|
|
153
|
+
expect(sidebar).to include('href="/v1/?resource=parent.child"', '>parent.child</a>')
|
|
154
|
+
expect(sidebar).not_to include('grandchild')
|
|
155
|
+
expect(last_response.body).to include('aria-label="Link to Subresources"', 'aria-label="Link to parent"')
|
|
156
|
+
get '/v1/?resource=parent.child'
|
|
157
|
+
expect(last_response.body).to include('>parent.child.grandchild</a>')
|
|
158
|
+
get '/v1/'
|
|
159
|
+
expect(last_response.body).to include('aria-label="Link to Initialization"', 'aria-label="Link to Client libraries"',
|
|
160
|
+
'aria-label="Link to Using this reference"', 'aria-label="Documentation settings"')
|
|
161
|
+
expect(last_response.body).not_to include('M4 6h16', 'Generated SDKs')
|
|
162
|
+
assert_fragments(last_response.body)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'scopes duplicate authentication resource/action targets while keeping legacy aliases once' do
|
|
166
|
+
server = HaveAPI::Server.new(get_opt(:api_module))
|
|
167
|
+
server.use_version(1)
|
|
168
|
+
server.auth_chain << DocumentationReferenceSpec::FirstProvider
|
|
169
|
+
server.auth_chain << DocumentationReferenceSpec::SecondProvider
|
|
170
|
+
server.mount('/')
|
|
171
|
+
body = Rack::MockRequest.new(server.app).get('/v1/?page=authentication', 'HTTP_HOST' => 'example.test').body
|
|
172
|
+
%i[first second].each do |provider|
|
|
173
|
+
action = Object.new.extend(described_class).documentation_resource_target(resource_path: ['session'], action_name: :show, authentication: provider)
|
|
174
|
+
expect(body).to include("data-doc-nav-owner=\"auth-#{provider}\"", "data-doc-subnav=\"auth-#{provider}\"", "href=\"##{action}\"")
|
|
175
|
+
expect(ids(body).count(action)).to eq(1)
|
|
176
|
+
%i[input output].each do |section|
|
|
177
|
+
expect(body).to include("href=\"##{reference(resource_path: ['session'], action_name: :show, authentication: provider, section:)}\"")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
expect(ids(body).uniq.length).to eq(ids(body).length)
|
|
181
|
+
%w[example-session-show-0-examples example-session-show-0-0 example-session-show-0-0-thorough].each do |legacy|
|
|
182
|
+
expect(ids(body).count(legacy)).to eq(1)
|
|
183
|
+
end
|
|
184
|
+
expect(ids(body).count('root-session')).to eq(1)
|
|
185
|
+
expect(ids(body).count('root-session-show')).to eq(1)
|
|
186
|
+
expect(body.index('id="root-session-show"')).to be < body.index('id="auth-second"')
|
|
187
|
+
assert_fragments(body)
|
|
188
|
+
File.write(File.join(ENV['HAVEAPI_REFERENCE_FIXTURES'], 'authentication.html'), body) if ENV['HAVEAPI_REFERENCE_FIXTURES']
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'rack/mock'
|
|
3
|
+
|
|
4
|
+
module DocumentationSearchIndexSpec
|
|
5
|
+
User = Struct.new(:login)
|
|
6
|
+
class << self
|
|
7
|
+
attr_accessor :private_visible
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Provider < HaveAPI::Authentication::Basic::Provider
|
|
11
|
+
protected
|
|
12
|
+
|
|
13
|
+
def find_user(_request, username, password)
|
|
14
|
+
User.new(username) if password == 'pass'
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module API
|
|
19
|
+
class Widget < HaveAPI::Resource
|
|
20
|
+
version 1
|
|
21
|
+
auth false
|
|
22
|
+
class Show < HaveAPI::Actions::Default::Show
|
|
23
|
+
authorize do |user|
|
|
24
|
+
input blacklist: [:private_ticket] unless user.login == 'admin' || DocumentationSearchIndexSpec.private_visible
|
|
25
|
+
allow
|
|
26
|
+
end
|
|
27
|
+
input(:hash) do
|
|
28
|
+
string :name, label: 'Name </script> & <markup>'
|
|
29
|
+
string :private_ticket
|
|
30
|
+
end
|
|
31
|
+
output(:hash) { string :label }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
RSpec.describe HaveAPI::DocumentationSearch::Helpers do
|
|
38
|
+
before { DocumentationSearchIndexSpec.private_visible = false }
|
|
39
|
+
|
|
40
|
+
def server
|
|
41
|
+
@server ||= HaveAPI::Server.new(DocumentationSearchIndexSpec::API).tap do |api|
|
|
42
|
+
api.use_version(1)
|
|
43
|
+
api.auth_chain << DocumentationSearchIndexSpec::Provider
|
|
44
|
+
api.locale_header = 'X-Language'
|
|
45
|
+
api.connect_hook(:pre_mount) do |ret, _server, sinatra|
|
|
46
|
+
sinatra.before { response.set_cookie('fixture', 'changed') if request.env['HTTP_X_SET_COOKIE'] }
|
|
47
|
+
ret
|
|
48
|
+
end
|
|
49
|
+
api.mount('/api/')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def request(path, **headers)
|
|
54
|
+
environment = { 'HTTP_HOST' => 'example.test' }.merge(headers)
|
|
55
|
+
Rack::MockRequest.new(server.app).get("/api/v1/#{path}", environment)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def page_key(**headers)
|
|
59
|
+
response = request('', **headers)
|
|
60
|
+
expect(response.status).to eq(200)
|
|
61
|
+
response.body.match(/data-search-view-key="([0-9a-f]{64})"/)[1]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def envelope(response)
|
|
65
|
+
body = response.body.match(%r{\A<script type="application/json" data-documentation-search-index>(.*?)</script>\s*\z}m)[1]
|
|
66
|
+
JSON.parse(body)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'builds one projection from one description for the version sidebar form' do
|
|
70
|
+
allow(server).to receive(:describe_version).and_call_original
|
|
71
|
+
allow(HaveAPI::DocumentationSearch::Index).to receive(:new).and_call_original
|
|
72
|
+
response = request('?page=resources')
|
|
73
|
+
keys = response.body.scan(/data-search-view-key="([0-9a-f]{64})"/).flatten
|
|
74
|
+
expect(keys.length).to eq(1)
|
|
75
|
+
expect(keys.uniq.length).to eq(1)
|
|
76
|
+
expect(server).to have_received(:describe_version).once
|
|
77
|
+
expect(HaveAPI::DocumentationSearch::Index).to have_received(:new).once
|
|
78
|
+
expect(response['cache-control']).to eq('private, no-store')
|
|
79
|
+
expect(response.body).not_to include('data-documentation-search-index')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'serves only an escaped inert envelope for matching keys with private cache headers and merged Vary' do
|
|
83
|
+
key = page_key
|
|
84
|
+
response = request("?page=resources&search_index=#{key}")
|
|
85
|
+
expect(response.status).to eq(200)
|
|
86
|
+
expect(response.content_type).to match(%r{\Atext/html;\s*charset=utf-8\z})
|
|
87
|
+
expect(response['cache-control']).to eq('private, max-age=86400, immutable')
|
|
88
|
+
expect(response['etag']).to eq(%("#{key}"))
|
|
89
|
+
expect(response['vary'].split(/,\s*/)).to contain_exactly('X-Language', 'Cookie', 'Authorization')
|
|
90
|
+
expect(envelope(response)).to include('key' => key)
|
|
91
|
+
expect(response.body).to include('\\u003c/script\\u003e', '\\u0026')
|
|
92
|
+
expect(response.body).not_to include('<!DOCTYPE', 'docs-sidebar', '<script src=', 'fixture')
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'keeps bootstrap output, mismatches and Set-Cookie changes no-store without conditional success' do
|
|
96
|
+
key = page_key
|
|
97
|
+
bootstrap = request('?page=resources&search_index=current', 'HTTP_IF_NONE_MATCH' => '*')
|
|
98
|
+
expect(bootstrap.status).to eq(200)
|
|
99
|
+
expect(bootstrap['cache-control']).to eq('private, no-store')
|
|
100
|
+
expect(bootstrap['etag']).to be_nil
|
|
101
|
+
expect(envelope(bootstrap)['key']).to eq(key)
|
|
102
|
+
mismatch = request("?page=resources&search_index=#{'0' * 64}", 'HTTP_IF_NONE_MATCH' => '*')
|
|
103
|
+
expect(mismatch.status).to eq(409)
|
|
104
|
+
expect(mismatch['cache-control']).to eq('private, no-store')
|
|
105
|
+
expect(mismatch.body).not_to include('data-documentation-search-index', 'private_ticket')
|
|
106
|
+
cookie = request("?page=resources&search_index=#{key}", 'HTTP_X_SET_COOKIE' => '1', 'HTTP_IF_NONE_MATCH' => '*')
|
|
107
|
+
expect(cookie.status).to eq(200)
|
|
108
|
+
expect(cookie['set-cookie']).not_to be_nil
|
|
109
|
+
expect(cookie['cache-control']).to eq('private, no-store')
|
|
110
|
+
expect(cookie['etag']).to be_nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'rejects malformed selectors and conflicting query/page fields before describing while OPTIONS stays unchanged' do
|
|
114
|
+
allow(server).to receive(:describe_version).and_call_original
|
|
115
|
+
["search_index=#{'A' * 64}", 'search_index=short', 'search_index[]=current',
|
|
116
|
+
'search_index=current&search_index=current', 'page=overview&search_index=current',
|
|
117
|
+
'page=resources&q=&search_index=current', 'page=resources&resource=widget&search_index=current'].each do |query|
|
|
118
|
+
expect(request("?#{query}").status).to eq(400), query
|
|
119
|
+
end
|
|
120
|
+
expect(server).not_to have_received(:describe_version)
|
|
121
|
+
response = Rack::MockRequest.new(server.app).options('/api/v1/?search_index[]=current', 'HTTP_HOST' => 'example.test')
|
|
122
|
+
expect(response.status).to eq(200)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'keeps neutral pages index-free and validates root default-version representations' do
|
|
126
|
+
allow(HaveAPI::DocumentationSearch::Index).to receive(:new).and_call_original
|
|
127
|
+
client = Rack::MockRequest.new(server.app)
|
|
128
|
+
%w[/api/ /api/doc/protocol].each do |path|
|
|
129
|
+
page = client.get(path, 'HTTP_HOST' => 'example.test')
|
|
130
|
+
expect(page.status).to eq(200)
|
|
131
|
+
expect(page.body).to include('data-search-view-key=""', 'search_index=current')
|
|
132
|
+
end
|
|
133
|
+
expect(HaveAPI::DocumentationSearch::Index).not_to have_received(:new)
|
|
134
|
+
invalid = client.get('/api/?search_index=current', 'HTTP_HOST' => 'example.test')
|
|
135
|
+
expect(invalid.status).to eq(400)
|
|
136
|
+
expect(invalid['cache-control']).to eq('private, no-store')
|
|
137
|
+
current = client.get('/api/?page=resources&search_index=current', 'HTTP_HOST' => 'example.test')
|
|
138
|
+
expect(current.status).to eq(200)
|
|
139
|
+
expect(envelope(current)['index']).to include('version' => '1', 'base_path' => '/api/v1/')
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'binds the projection to resolved locale and canonical Rack/API mounts' do
|
|
143
|
+
server.available_locales = %i[en cs]
|
|
144
|
+
english = envelope(request('?page=resources&search_index=current', 'HTTP_X_LANGUAGE' => 'en'))
|
|
145
|
+
czech = envelope(request('?page=resources&search_index=current', 'HTTP_X_LANGUAGE' => 'cs'))
|
|
146
|
+
expect(english['key']).not_to eq(czech['key'])
|
|
147
|
+
expect(czech['index']['locale']).to eq('cs')
|
|
148
|
+
mounted = envelope(request('?page=resources&search_index=current', 'SCRIPT_NAME' => '/rack'))
|
|
149
|
+
expect(mounted['index']['base_path']).to eq('/rack/api/v1/')
|
|
150
|
+
expect(mounted['key']).not_to eq(english['key'])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
context 'with OAuth documentation sessions' do
|
|
154
|
+
let(:documentation_server) do
|
|
155
|
+
HaveAPI::Server.new(DocumentationSearchIndexSpec::API).tap do |api|
|
|
156
|
+
api.use_version(1)
|
|
157
|
+
api.auth_chain << DocumentationSearchIndexSpec::Provider
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
let(:documentation_fixture) do
|
|
161
|
+
require_relative '../../test_support/documentation_oauth_fixture'
|
|
162
|
+
DocumentationOAuthFixture.configure(documentation_server, users: {
|
|
163
|
+
1 => { 'member-token' => DocumentationSearchIndexSpec::User.new('member'),
|
|
164
|
+
'admin-token' => DocumentationSearchIndexSpec::User.new('admin') }
|
|
165
|
+
}, mount_path: '/api/')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
before do
|
|
169
|
+
documentation_fixture
|
|
170
|
+
server.mount('/api/')
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def server
|
|
174
|
+
documentation_server
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def request(path, user: nil, **headers)
|
|
178
|
+
environment = { 'HTTP_HOST' => URI(documentation_fixture.origin).host }.merge(headers)
|
|
179
|
+
environment['HTTP_COOKIE'] = documentation_fixture.header_for(server, version: 1, token: "#{user}-token") if user
|
|
180
|
+
Rack::MockRequest.new(server.app).get("#{documentation_fixture.origin}/api/v1/#{path}", environment)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'rechecks cookie authentication and permission changes before any 304 response' do
|
|
184
|
+
member = page_key(user: 'member')
|
|
185
|
+
admin = page_key(user: 'admin')
|
|
186
|
+
anonymous = page_key
|
|
187
|
+
expect([member, admin, anonymous].uniq.length).to eq(3)
|
|
188
|
+
original = request("?page=resources&search_index=#{member}", user: 'member')
|
|
189
|
+
expect(envelope(original)['index']['parameters'].map { |parameter| parameter['name'] }).not_to include('private_ticket')
|
|
190
|
+
expect(request("?page=resources&search_index=#{admin}", user: 'member', 'HTTP_IF_NONE_MATCH' => '*').status).to eq(409)
|
|
191
|
+
documentation_fixture.lookups.clear
|
|
192
|
+
expect(request("?page=resources&search_index=#{member}", user: 'member', 'HTTP_IF_NONE_MATCH' => %("#{member}")).status).to eq(304)
|
|
193
|
+
expect(documentation_fixture.lookups).to eq([[1, 'member-token']])
|
|
194
|
+
DocumentationSearchIndexSpec.private_visible = true
|
|
195
|
+
changed = page_key(user: 'member')
|
|
196
|
+
expect(changed).not_to eq(member)
|
|
197
|
+
stale = request("?page=resources&search_index=#{member}", user: 'member', 'HTTP_IF_NONE_MATCH' => '*')
|
|
198
|
+
expect(stale.status).to eq(409)
|
|
199
|
+
expect(stale.body).not_to include('data-documentation-search-index')
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe HaveAPI::DocumentationSearch::Index do
|
|
4
|
+
let(:description) do
|
|
5
|
+
{ resources: { widget: { label: 'Widget label', resources: {}, actions: {
|
|
6
|
+
show: { method: 'GET', path: '/v1/widgets/{id}', aliases: ['find'], label: 'Show widget',
|
|
7
|
+
input: { parameters: { name: { type: 'String', label: 'Name' } } },
|
|
8
|
+
output: { parameters: { id: { type: 'Integer', label: 'Identifier' } } } }
|
|
9
|
+
} } }, authentication: {} }
|
|
10
|
+
end
|
|
11
|
+
let(:options) { { base_path: '/v1/', version: '1', locale: 'en', visibility: 'anonymous' } }
|
|
12
|
+
|
|
13
|
+
def index(source = description, **changes)
|
|
14
|
+
described_class.new(description: source, **options, **changes)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'fingerprints deterministic owned payload bytes and preserves parent references without source mutation' do
|
|
18
|
+
before = Marshal.dump(description)
|
|
19
|
+
first = index
|
|
20
|
+
serialized = JSON.parse(JSON.generate(description))
|
|
21
|
+
expect(index(serialized).payload).to eq(first.payload)
|
|
22
|
+
expect(index(serialized).key).to eq(first.key)
|
|
23
|
+
expect(first.key).to eq(Digest::SHA256.hexdigest(JSON.generate(first.payload)))
|
|
24
|
+
expect(first.payload).to be_frozen
|
|
25
|
+
expect(first.payload[:parameters]).to all(be_frozen)
|
|
26
|
+
expect(first.payload[:actions].first[:resource]).to eq(0)
|
|
27
|
+
expect(first.payload[:parameters].map { |parameter| parameter[:action] }).to eq([0, 0])
|
|
28
|
+
expect(Marshal.dump(description)).to eq(before)
|
|
29
|
+
expect(description[:resources][:widget][:label]).not_to be_frozen
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'sorts each metadata collection independently of the caller hash order' do
|
|
33
|
+
source = Marshal.load(Marshal.dump(description))
|
|
34
|
+
source[:resources][:another] = { actions: {}, resources: {} }
|
|
35
|
+
reversed = source.merge(resources: source[:resources].to_a.reverse.to_h)
|
|
36
|
+
expect(index(source).key).to eq(index(reversed).key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'changes keys for every supported visible input and version, mount, locale or visibility boundary' do
|
|
40
|
+
original = index.key
|
|
41
|
+
mutations = [
|
|
42
|
+
->(source) { source[:resources][:extra] = { actions: {} } },
|
|
43
|
+
->(source) { source[:resources][:widget][:label] = 'Changed label' },
|
|
44
|
+
->(source) { source[:resources][:widget][:actions][:extra] = { method: 'POST' } },
|
|
45
|
+
->(source) { source[:resources][:widget][:actions][:show][:aliases] = ['lookup'] },
|
|
46
|
+
->(source) { source[:resources][:widget][:actions][:show][:path] = '/changed' },
|
|
47
|
+
->(source) { source[:resources][:widget][:actions][:show][:input][:parameters][:extra] = { type: 'Boolean' } },
|
|
48
|
+
->(source) { source[:resources][:widget][:actions][:show][:output][:parameters][:id][:label] = 'Changed identifier' }
|
|
49
|
+
]
|
|
50
|
+
mutations.each do |mutate|
|
|
51
|
+
source = Marshal.load(Marshal.dump(description))
|
|
52
|
+
mutate.call(source)
|
|
53
|
+
expect(index(source).key).not_to eq(original)
|
|
54
|
+
end
|
|
55
|
+
[{ version: '2' }, { base_path: '/mounted/v1/' }, { locale: 'cs' }, { visibility: 'authenticated' }].each do |change|
|
|
56
|
+
expect(index(**change).key).not_to eq(original)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'projects only whitelisted strings and no prose, defaults, examples, validator values, identities or private fields' do
|
|
61
|
+
source = Marshal.load(Marshal.dump(description))
|
|
62
|
+
source[:private_generator_model] = 'PRIVATE'
|
|
63
|
+
source[:current_user] = { login: 'ACCOUNT' }
|
|
64
|
+
source[:csrf] = 'CSRF'
|
|
65
|
+
source[:resources][:widget][:description] = 'PROSE'
|
|
66
|
+
action = source[:resources][:widget][:actions][:show]
|
|
67
|
+
action[:examples] = [{ request: 'EXAMPLE' }]
|
|
68
|
+
action[:input][:parameters][:name].merge!(default: 'DEFAULT', validators: { include: { values: ['VALIDATOR'] } })
|
|
69
|
+
expect(index(source).key).to eq(index.key)
|
|
70
|
+
expect(JSON.generate(index(source).payload)).not_to include('PRIVATE', 'ACCOUNT', 'CSRF', 'PROSE', 'EXAMPLE', 'DEFAULT', 'VALIDATOR')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'keeps the full index beyond the result cap and produces the shared Ruby/browser ordering fixture' do
|
|
74
|
+
fixture = JSON.parse(File.read(File.join(__dir__, 'fixtures/search_index.json')))
|
|
75
|
+
expect(fixture.fetch('index').fetch('parameters').length).to be > 20
|
|
76
|
+
fixture.fetch('queries').each do |query|
|
|
77
|
+
result = HaveAPI::DocumentationSearch.new(index: fixture.fetch('index'), query: query.fetch('query')).call
|
|
78
|
+
identities = result[:results].map do |record|
|
|
79
|
+
[record[:kind], record[:authentication], record[:resource_path].join('.'), record[:action_name], record[:direction], record[:name]].join(':')
|
|
80
|
+
end
|
|
81
|
+
expect(identities).to eq(query.fetch('expected'))
|
|
82
|
+
expect(result[:more]).to eq(query.fetch('more'))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|