haveapi 0.29.12 → 0.30.1
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 +1 -0
- data/Rakefile +1 -0
- data/doc/client-examples.md +198 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/client_example.rb +47 -1
- data/lib/haveapi/client_examples/dynamic_helpers.rb +129 -0
- data/lib/haveapi/client_examples/go_client.rb +37 -0
- data/lib/haveapi/client_examples/js_async_client.rb +14 -0
- data/lib/haveapi/client_examples/js_client.rb +179 -135
- data/lib/haveapi/client_examples/python_async_client.rb +14 -0
- data/lib/haveapi/client_examples/python_client.rb +172 -0
- data/lib/haveapi/client_examples/rust_client.rb +59 -0
- data/lib/haveapi/client_examples/ts_async_client.rb +14 -0
- data/lib/haveapi/client_examples/ts_client.rb +14 -0
- data/lib/haveapi/documentation_clients.rb +249 -0
- data/lib/haveapi/documentation_context.rb +47 -0
- data/lib/haveapi/documentation_schema.rb +73 -0
- data/lib/haveapi/public/js/highlight-LICENSE +29 -0
- data/lib/haveapi/public/js/highlight.pack.js +2 -2
- data/lib/haveapi/public/js/highlighter.js +16 -6
- data/lib/haveapi/public/js/main.js +9 -1
- data/lib/haveapi/public/js/nojs-tabs.js +37 -3
- data/lib/haveapi/server.rb +39 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/main_layout.erb +11 -0
- data/lib/haveapi/views/version_page/auth_body.erb +3 -3
- data/lib/haveapi/views/version_page/client_auth.erb +2 -2
- data/lib/haveapi/views/version_page/client_example.erb +2 -11
- data/lib/haveapi/views/version_page/client_init.erb +2 -2
- data/lib/haveapi/views/version_page/client_result.erb +5 -0
- data/lib/haveapi/views/version_page/resource_body.erb +3 -7
- data/lib/haveapi/views/version_page.erb +13 -9
- data/spec/client_examples/assets/highlighter_test.cjs +26 -0
- data/spec/client_examples/assets/tabs_test.cjs +54 -0
- data/spec/client_examples/context_spec.rb +68 -0
- data/spec/client_examples/dynamic_acceptance.md +83 -0
- data/spec/client_examples/dynamic_acceptance.rb +151 -0
- data/spec/client_examples/dynamic_acceptance_spec.rb +76 -0
- data/spec/client_examples/dynamic_fixture.rb +36 -0
- data/spec/client_examples/dynamic_js_spec.rb +196 -0
- data/spec/client_examples/dynamic_metadata_spec.rb +52 -0
- data/spec/client_examples/dynamic_mounted_paths_spec.rb +65 -0
- data/spec/client_examples/dynamic_python_spec.rb +123 -0
- data/spec/client_examples/go_spec.rb +57 -0
- data/spec/client_examples/profile_mount_spec.rb +215 -0
- data/spec/client_examples/profiles_spec.rb +265 -0
- data/spec/client_examples/rust_client_spec.rb +75 -0
- data/spec/client_examples/structural_schema_spec.rb +48 -0
- data/spec/client_examples/syntax_spec.rb +2 -1
- data/spec/documentation/auth_filtering_spec.rb +12 -0
- data/spec/documentation/client_examples_spec.rb +92 -0
- data/spec/packaging_spec.rb +13 -4
- data/test_support/dynamic_documentation_api.rb +171 -0
- data/test_support/dynamic_protocol_spec.rb +133 -0
- data/test_support/dynamic_python_login_spec.rb +75 -0
- metadata +37 -4
- data/lib/haveapi/client_examples/fs_client.rb +0 -119
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require_relative 'dynamic_fixture'
|
|
4
|
+
|
|
5
|
+
RSpec.describe HaveAPI::ClientExamples::PythonClient do
|
|
6
|
+
include DynamicExampleFixture
|
|
7
|
+
|
|
8
|
+
let(:renderers) { [described_class, HaveAPI::ClientExamples::PythonAsyncClient] }
|
|
9
|
+
|
|
10
|
+
it 'keeps both variants available without a generated SDK profile' do
|
|
11
|
+
expect(renderers.map(&:id)).to eq(%w[python-sync python-async])
|
|
12
|
+
expect(renderers.map(&:order)).to eq([14, 15])
|
|
13
|
+
expect(renderers).to all(be_available(dynamic_context))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'renders complete parseable init, auth, action and async ownership' do
|
|
17
|
+
renderers.each do |renderer|
|
|
18
|
+
results = [renderer.render(:init, context: dynamic_context), renderer.render(:example, context: dynamic_context)]
|
|
19
|
+
%i[basic token oauth2].each do |method|
|
|
20
|
+
results << renderer.render(:auth, context: dynamic_context, auth_method: method, auth_description: token_description)
|
|
21
|
+
end
|
|
22
|
+
results.each do |result|
|
|
23
|
+
expect(result[:status]).to eq(:code), result.inspect
|
|
24
|
+
expect(result).to be_frozen
|
|
25
|
+
output, status = Open3.capture2e('python', '-c', 'import ast, sys; ast.parse(sys.stdin.read())', stdin_data: result[:code])
|
|
26
|
+
expect(status).to be_success, output
|
|
27
|
+
expect(result[:code]).to include('https://api.example.test/mount/', 'version="2"')
|
|
28
|
+
expect(result[:code]).to include('except ActionError', 'except HaveAPIError')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'preserves exact mapping names, positional path order and every input value' do
|
|
34
|
+
renderers.each do |renderer|
|
|
35
|
+
source = renderer.render(:example, context: dynamic_context).fetch(:code)
|
|
36
|
+
expect(source).to include('.resources["constructor"].resources["resources"].actions[',
|
|
37
|
+
'path_params={"parent_id": 7, "widget_id": "name/with space"}',
|
|
38
|
+
'"enabled": False', '"count": 0', '"nullable": None', '"__proto__":',
|
|
39
|
+
'print(response.data)', 'print(response.meta)')
|
|
40
|
+
if renderer.id == 'python-async'
|
|
41
|
+
expect(source).to include('async with AsyncClient', 'response = await ', 'asyncio.run(main())')
|
|
42
|
+
else
|
|
43
|
+
expect(source).to include('with Client')
|
|
44
|
+
expect(source).not_to include('await ')
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'retains list rows and exposes a limitation for an unnamespaced list' do
|
|
50
|
+
action = dynamic_action.merge(input: { layout: :hash_list, namespace: :widgets, parameters: { name: { type: 'String' } } })
|
|
51
|
+
context = dynamic_context(action:, sample: dynamic_sample.merge(request: [{ name: '' }, { name: 'second' }]))
|
|
52
|
+
renderers.each do |renderer|
|
|
53
|
+
source = renderer.render(:example, context:).fetch(:code)
|
|
54
|
+
expect(source).to include('params=[{"name": ""}, {"name": "second"}]')
|
|
55
|
+
result = renderer.render(:example, context: context.with(action: action.merge(input: action[:input].merge(namespace: nil))))
|
|
56
|
+
expect(result[:status]).to eq(:limitation)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'does not turn a prose-only or incomplete example into an executable call' do
|
|
61
|
+
renderers.each do |renderer|
|
|
62
|
+
[dynamic_sample.merge(path_params: []), { request: nil, response: nil }].each do |sample|
|
|
63
|
+
result = renderer.render(:example, context: dynamic_context(sample:))
|
|
64
|
+
expect(result[:status]).to eq(:limitation)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'uses real token challenge APIs and advertised login fields without printing credentials' do
|
|
70
|
+
renderers.each do |renderer|
|
|
71
|
+
source = renderer.render(:auth, context: dynamic_context, auth_method: :token, auth_description: token_description).fetch(:code)
|
|
72
|
+
expect(source).to include('begin_token_login', 'step.input', 'step.submit(values)', 'HAVEAPI_AUTH_login-name')
|
|
73
|
+
expect(source).not_to include('print(step.token)', 'qwertyuiop')
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'keeps advertised required lifecycle fields while omitting optional interval in both modes' do
|
|
78
|
+
description = token_description
|
|
79
|
+
parameters = description[:resources][:token][:actions]['request'][:input][:parameters]
|
|
80
|
+
parameters[:lifetime][:required] = true
|
|
81
|
+
parameters[:interval] = { type: 'Integer', required: false }
|
|
82
|
+
renderers.each do |renderer|
|
|
83
|
+
source = renderer.render(:auth, context: dynamic_context, auth_method: :token, auth_description: description).fetch(:code)
|
|
84
|
+
expect(source).to include('"lifetime": json.loads(os.environ["HAVEAPI_AUTH_lifetime"])')
|
|
85
|
+
expect(source).not_to include('HAVEAPI_AUTH_interval', 'HAVEAPI_CHALLENGE_token', 'renewable_auto')
|
|
86
|
+
expect(source).to include('if "HAVEAPI_TOKEN" in os.environ:', 'set_auth(TokenAuth(os.environ["HAVEAPI_TOKEN"]))',
|
|
87
|
+
'for name in step.input if name != "token"', 'step.submit(values)')
|
|
88
|
+
end
|
|
89
|
+
parameters[:interval][:required] = true
|
|
90
|
+
renderers.each do |renderer|
|
|
91
|
+
source = renderer.render(:auth, context: dynamic_context, auth_method: :token, auth_description: description).fetch(:code)
|
|
92
|
+
expect(source).to include('"interval": json.loads(os.environ["HAVEAPI_AUTH_interval"])')
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'limits GET examples to flat scalars and safe query names in both modes' do
|
|
97
|
+
action = dynamic_action.merge(method: 'GET', input: {
|
|
98
|
+
layout: :hash, namespace: :widget, parameters: { payload: { type: 'Custom', nullable: true } }
|
|
99
|
+
})
|
|
100
|
+
renderers.each do |renderer|
|
|
101
|
+
[{ enabled: false, count: 0 }, [], {}, { nested: 'string' }, ['string'], false, 0].each do |payload|
|
|
102
|
+
result = renderer.render(:example, context: dynamic_context(action:, sample: dynamic_sample.merge(request: { payload: })))
|
|
103
|
+
expect(result[:status]).to eq(:limitation)
|
|
104
|
+
expect(result[:reason]).to include('flat scalar')
|
|
105
|
+
end
|
|
106
|
+
[nil, '', 'safe'].each do |payload|
|
|
107
|
+
result = renderer.render(:example, context: dynamic_context(action:, sample: dynamic_sample.merge(request: { payload: })))
|
|
108
|
+
expect(result[:status]).to eq(:code), result.inspect
|
|
109
|
+
end
|
|
110
|
+
unsafe = action.merge(input: action[:input].merge(namespace: 'bad[name]'))
|
|
111
|
+
expect(renderer.render(:example, context: dynamic_context(action: unsafe, sample: dynamic_sample.merge(request: {})))[:status]).to eq(:limitation)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'keeps JSON input intact and limits methods unsupported by Python' do
|
|
116
|
+
renderers.each do |renderer|
|
|
117
|
+
%w[POST PUT DELETE HEAD OPTIONS].each do |method|
|
|
118
|
+
result = renderer.render(:example, context: dynamic_context(action: dynamic_action.merge(method:)))
|
|
119
|
+
expect(result[:status]).to eq(%w[POST PUT DELETE].include?(method) ? :code : :limitation)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require 'haveapi/documentation_context'
|
|
2
|
+
require 'haveapi/documentation_clients'
|
|
3
|
+
require 'haveapi/client_examples/go_client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe HaveAPI::ClientExamples::GoClient do
|
|
6
|
+
let(:result) { { status: :code, code: 'package main' }.freeze }
|
|
7
|
+
let(:adapter) do
|
|
8
|
+
contract = Class.new do
|
|
9
|
+
def render(**); end
|
|
10
|
+
end
|
|
11
|
+
instance_double(contract, render: result)
|
|
12
|
+
end
|
|
13
|
+
let(:profile) { Struct.new(:language, :adapter).new(:go, adapter) }
|
|
14
|
+
let(:context) do
|
|
15
|
+
HaveAPI::ClientExample::Context.new(
|
|
16
|
+
version: '7', host: 'api.example.test', base_url: 'https://api.example.test/mounted/',
|
|
17
|
+
version_url: 'https://api.example.test/mounted/v7/', description: {},
|
|
18
|
+
resource_path: %w[widget], action_name: 'update', action: {},
|
|
19
|
+
sample: { status: true, request: { enabled: false } }, profile:
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'registers the frozen Go ID and requires its configured profile' do
|
|
24
|
+
expect(described_class.id).to eq('go')
|
|
25
|
+
expect(described_class.code).to eq(:go)
|
|
26
|
+
expect(described_class.order).to eq(16)
|
|
27
|
+
expect(described_class).to be_available(context)
|
|
28
|
+
expect(described_class).not_to be_available(context.with(profile: nil))
|
|
29
|
+
expect(described_class).not_to be_available(context.with(profile: Struct.new(:language).new(:rust)))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'delegates initialization without requiring the optional generator gem' do
|
|
33
|
+
expect(described_class.render(:init, context:)).to eq(result)
|
|
34
|
+
expect(adapter).to have_received(:render).with(kind: :init, context:, mode: :sync)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'delegates advertised authentication with its filtered descriptor' do
|
|
38
|
+
descriptor = { description: 'HTTP Basic authentication' }
|
|
39
|
+
expect(described_class.render(:auth, context:, auth_method: :basic, auth_description: descriptor)).to eq(result)
|
|
40
|
+
expect(adapter).to have_received(:render).with(kind: :auth, context:, mode: :sync,
|
|
41
|
+
auth_method: :basic, auth_description: descriptor)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'binds the selected filtered example and preserves plain limitation results' do
|
|
45
|
+
limitation = { status: :limitation, reason: 'This Go SDK input is unsupported.' }.freeze
|
|
46
|
+
allow(adapter).to receive(:render).and_return(limitation)
|
|
47
|
+
expect(described_class.render(:example, context:)).to eq(limitation)
|
|
48
|
+
expect(adapter).to have_received(:render) do |**arguments|
|
|
49
|
+
selected = arguments.fetch(:context)
|
|
50
|
+
expect(arguments[:kind]).to eq(:example)
|
|
51
|
+
expect(arguments[:mode]).to eq(:sync)
|
|
52
|
+
expect(selected.sample).to eq(context.sample)
|
|
53
|
+
expect(selected.sample).to be_frozen
|
|
54
|
+
expect(selected.description).to eq(context.description)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'base64'
|
|
3
|
+
|
|
4
|
+
module ProfileMountSpec
|
|
5
|
+
User = Struct.new(:login)
|
|
6
|
+
|
|
7
|
+
module AuthenticationResources
|
|
8
|
+
class Session < HaveAPI::Resource
|
|
9
|
+
auth false
|
|
10
|
+
version :all
|
|
11
|
+
|
|
12
|
+
class Show < HaveAPI::Action
|
|
13
|
+
route ''
|
|
14
|
+
http_method :get
|
|
15
|
+
output(:hash) { string :id }
|
|
16
|
+
authorize { allow }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Provider < HaveAPI::Authentication::Basic::Provider
|
|
22
|
+
def resource_module
|
|
23
|
+
AuthenticationResources
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
protected
|
|
27
|
+
|
|
28
|
+
def find_user(_request, username, password)
|
|
29
|
+
User.new(username) if %w[member admin].include?(username) && password == 'pass'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Added < HaveAPI::Resource
|
|
34
|
+
auth false
|
|
35
|
+
version :all
|
|
36
|
+
|
|
37
|
+
class Index < HaveAPI::Action
|
|
38
|
+
route ''
|
|
39
|
+
http_method :get
|
|
40
|
+
output(:hash) { string :late }
|
|
41
|
+
authorize { allow }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe HaveAPI::Server, '#mount' do
|
|
47
|
+
api do
|
|
48
|
+
define_resource(:Widget) do
|
|
49
|
+
version :all
|
|
50
|
+
auth false
|
|
51
|
+
|
|
52
|
+
define_action(:Show) do
|
|
53
|
+
route '{widget_id}'
|
|
54
|
+
http_method :get
|
|
55
|
+
desc HaveAPI.message('profile_mount_spec.action')
|
|
56
|
+
input(:hash) do
|
|
57
|
+
string :visible
|
|
58
|
+
string :secret, default: 'PRIVATE_DEFAULT'
|
|
59
|
+
end
|
|
60
|
+
output(:hash) do
|
|
61
|
+
string :visible
|
|
62
|
+
string :secret
|
|
63
|
+
end
|
|
64
|
+
authorize do |user|
|
|
65
|
+
unless user.login == 'admin'
|
|
66
|
+
input blacklist: [:secret]
|
|
67
|
+
output blacklist: [:secret]
|
|
68
|
+
end
|
|
69
|
+
allow
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# rubocop:disable RSpec/NoExpectationExample
|
|
73
|
+
example 'visible sample' do
|
|
74
|
+
path_params 7
|
|
75
|
+
request({ visible: 'public sample' })
|
|
76
|
+
response({ visible: 'public response' })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
example 'privileged sample' do
|
|
80
|
+
authorize { |user| user.login == 'admin' }
|
|
81
|
+
path_params 7
|
|
82
|
+
request({ secret: 'PRIVATE_SAMPLE' })
|
|
83
|
+
response({ secret: 'PRIVATE_RESPONSE' })
|
|
84
|
+
end
|
|
85
|
+
# rubocop:enable RSpec/NoExpectationExample
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
define_action(:Secret) do
|
|
89
|
+
route 'secret'
|
|
90
|
+
http_method :get
|
|
91
|
+
output(:hash) { string :admin_only }
|
|
92
|
+
authorize { |user| user.login == 'admin' ? allow : deny }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
let(:server) { described_class.new(get_opt(:api_module)) }
|
|
98
|
+
let(:descriptions) { [] }
|
|
99
|
+
let(:contexts) { [] }
|
|
100
|
+
let(:documentation) { Class.new }
|
|
101
|
+
let(:adapter) do
|
|
102
|
+
selected_contexts = contexts
|
|
103
|
+
Class.new do
|
|
104
|
+
define_method(:resolve) { |**| nil }
|
|
105
|
+
define_method(:render) do |kind:, context:, **|
|
|
106
|
+
selected_contexts << context
|
|
107
|
+
{ status: :code, code: JSON.generate(kind == :example ? context.sample : { url: context.version_url }) }
|
|
108
|
+
end
|
|
109
|
+
end.new
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
before do
|
|
113
|
+
generator = Module.new
|
|
114
|
+
generator.const_set(:VERSION, HaveAPI::VERSION)
|
|
115
|
+
generator.const_set(:Documentation, documentation)
|
|
116
|
+
stub_const('HaveAPI::GoClient', generator)
|
|
117
|
+
allow(registry).to receive(:require).with('haveapi/go_client/documentation').and_return(true)
|
|
118
|
+
allow(documentation).to receive(:new) do |description:, **|
|
|
119
|
+
descriptions << description
|
|
120
|
+
adapter
|
|
121
|
+
end
|
|
122
|
+
server.use_version([1, 2])
|
|
123
|
+
server.default_version = 1
|
|
124
|
+
server.auth_chain << ProfileMountSpec::Provider
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def registry
|
|
128
|
+
server.instance_variable_get(:@documentation_clients)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def register(version = 1)
|
|
132
|
+
server.documentation_clients(version, go: { import_path: 'example.test/widget/client' })
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def request_as(user, locale)
|
|
136
|
+
Rack::MockRequest.new(server.app).get(
|
|
137
|
+
'/mounted/api/v1/', 'HTTP_HOST' => 'example.org', 'HTTP_ACCEPT_LANGUAGE' => locale,
|
|
138
|
+
'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:pass")}"
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'captures the complete selected version after auth, action-state and post-mount routes exist' do
|
|
143
|
+
register
|
|
144
|
+
server.action_state = Object.new
|
|
145
|
+
server.connect_hook(:post_mount) do |_ret, api_server, _sinatra|
|
|
146
|
+
expect(api_server.documentation_client_profiles(1)).to be_empty
|
|
147
|
+
api_server.mount_resource('/mounted/api/v1/', 1, ProfileMountSpec::Added, api_server.routes[1][:resources])
|
|
148
|
+
{}
|
|
149
|
+
end
|
|
150
|
+
allow(server).to receive(:describe_version).and_call_original
|
|
151
|
+
server.mount('/mounted/api/')
|
|
152
|
+
expect(server).to have_received(:describe_version).once
|
|
153
|
+
expect(descriptions.size).to eq(1)
|
|
154
|
+
private_schema = descriptions.first
|
|
155
|
+
expect(private_schema['help']).to eq('/mounted/api/v1/')
|
|
156
|
+
expect(private_schema['resources'].keys).to include('widget', 'action_state', 'added')
|
|
157
|
+
expect(private_schema.dig('resources', 'widget', 'actions').keys).to include('show', 'secret')
|
|
158
|
+
expect(private_schema.dig('authentication', 'basic', 'resources')).to have_key('session')
|
|
159
|
+
expect(private_schema.dig('resources', 'widget', 'actions', 'show', 'input', 'parameters')).to have_key('secret')
|
|
160
|
+
expect(JSON.generate(private_schema)).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE')
|
|
161
|
+
expect(server.documentation_client_profiles(1).fetch(:go).package_version).to be_nil
|
|
162
|
+
expect(server.documentation_client_profiles(2)).to be_empty
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'captures each configured version once and does not initialize from requests' do
|
|
166
|
+
register(1)
|
|
167
|
+
register(2)
|
|
168
|
+
server.mount('/mounted/api/')
|
|
169
|
+
expect(descriptions.map { |description| description['help'] }).to eq(['/mounted/api/v1/', '/mounted/api/v2/'])
|
|
170
|
+
request_as('member', 'en')
|
|
171
|
+
request_as('admin', 'cs')
|
|
172
|
+
expect(descriptions.size).to eq(2)
|
|
173
|
+
expect { register }.to raise_error(HaveAPI::ClientExample::Profiles::Invalid, /already mounted/)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'keeps private schema separate from sequential users, locales, HTML and samples' do
|
|
177
|
+
register
|
|
178
|
+
server.available_locales = %i[en cs]
|
|
179
|
+
::I18n.backend.store_translations(:en, profile_mount_spec: { action: 'Visible English action' })
|
|
180
|
+
::I18n.backend.store_translations(:cs, profile_mount_spec: { action: 'Viditelna ceska akce' })
|
|
181
|
+
server.mount('/mounted/api/')
|
|
182
|
+
privileged = request_as('admin', 'en')
|
|
183
|
+
expect(privileged.status).to eq(200)
|
|
184
|
+
expect(privileged.body).to include('PRIVATE_SAMPLE', 'Visible English action')
|
|
185
|
+
contexts.clear
|
|
186
|
+
public_response = request_as('member', 'cs')
|
|
187
|
+
expect(public_response.status).to eq(200)
|
|
188
|
+
expect(public_response.body).to include('Viditelna ceska akce', 'init-go', 'example.test/widget/client')
|
|
189
|
+
expect(public_response.body).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE', 'admin_only', 'haveapi.invalid', 'StructuralSchema')
|
|
190
|
+
selected = contexts.find { |context| context.action_name == 'show' }
|
|
191
|
+
expect(selected.action.dig(:input, :parameters).keys.map(&:to_s)).to eq(['visible'])
|
|
192
|
+
expect(selected.description[:resources]['widget'][:actions]).not_to have_key('secret')
|
|
193
|
+
expect(selected.sample[:request]).to eq(visible: 'public sample')
|
|
194
|
+
expect(selected.action[:description]).to eq('Viditelna ceska akce')
|
|
195
|
+
expect(request_as('member', 'en').body).to include('Visible English action')
|
|
196
|
+
expect(descriptions.size).to eq(1)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'makes the server unusable and publishes no profiles after an adapter failure' do
|
|
200
|
+
register
|
|
201
|
+
allow(documentation).to receive(:new).and_raise('PRIVATE_SCHEMA_DIAGNOSTIC')
|
|
202
|
+
expect { server.mount('/') }.to raise_error(HaveAPI::ClientExample::Profiles::Invalid, 'Configured documentation client schema or configuration is invalid')
|
|
203
|
+
expect(server.app).to be_nil
|
|
204
|
+
expect(server.documentation_client_profiles(1)).to be_empty
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it 'makes the server unusable when a post-mount hook fails before finalization' do
|
|
208
|
+
register
|
|
209
|
+
server.connect_hook(:post_mount) { raise 'fixture mount failure' }
|
|
210
|
+
expect { server.mount('/') }.to raise_error(RuntimeError, 'fixture mount failure')
|
|
211
|
+
expect(documentation).not_to have_received(:new)
|
|
212
|
+
expect(server.app).to be_nil
|
|
213
|
+
expect(server.documentation_client_profiles(1)).to be_empty
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'tmpdir'
|
|
3
|
+
|
|
4
|
+
describe HaveAPI::ClientExample::Profiles do
|
|
5
|
+
let(:profiles) { described_class.new }
|
|
6
|
+
let(:adapter) do
|
|
7
|
+
Class.new do
|
|
8
|
+
def resolve(**); end
|
|
9
|
+
def render(**); end
|
|
10
|
+
end.new
|
|
11
|
+
end
|
|
12
|
+
let(:directory) { Dir.mktmpdir('haveapi-doc-profiles-') }
|
|
13
|
+
let(:description) { { resources: {}, authentication: {}, meta: { namespace: '_meta' } } }
|
|
14
|
+
let(:server) { instance_double(HaveAPI::Server, versions: [1], root: '/', describe_version: description) }
|
|
15
|
+
|
|
16
|
+
def description_path
|
|
17
|
+
File.join(directory, 'description.json')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def config_path
|
|
21
|
+
File.join(directory, 'generator.toml')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def go_config
|
|
25
|
+
{ import_path: 'example.test/widget/client', module_version: 'v1.2.3', description_path:, generator_version: HaveAPI::VERSION }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def rust_config
|
|
29
|
+
{ snapshot_path: description_path, generator_config_path: config_path, generator_version: HaveAPI::VERSION }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def generator_config
|
|
33
|
+
{ 'crate_name' => 'widget-client', 'crate_version' => '1.2.3' }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
before do
|
|
37
|
+
File.write(description_path, JSON.generate(description))
|
|
38
|
+
File.write(config_path, 'fixture config')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
after { FileUtils.remove_entry(directory) }
|
|
42
|
+
|
|
43
|
+
def install_go_adapter(version: HaveAPI::VERSION)
|
|
44
|
+
generator = Module.new
|
|
45
|
+
generator.const_set(:VERSION, version)
|
|
46
|
+
documentation = Class.new
|
|
47
|
+
generator.const_set(:Documentation, documentation)
|
|
48
|
+
stub_const('HaveAPI::GoClient', generator)
|
|
49
|
+
allow(profiles).to receive(:require).with('haveapi/go_client/documentation').and_return(true)
|
|
50
|
+
allow(documentation).to receive(:new).and_return(adapter)
|
|
51
|
+
documentation
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def install_rust_adapter(factories: true)
|
|
55
|
+
generator = Module.new
|
|
56
|
+
generator.const_set(:VERSION, HaveAPI::VERSION)
|
|
57
|
+
documentation = Class.new
|
|
58
|
+
generator.const_set(:Documentation, documentation)
|
|
59
|
+
generator.const_set(:Generator, Class.new)
|
|
60
|
+
generator.const_set(:Model, Module.new)
|
|
61
|
+
generator.const_set(:Snapshot, Class.new)
|
|
62
|
+
stub_const('HaveAPI::RustClient', generator)
|
|
63
|
+
stub_const('Tomlrb', Module.new)
|
|
64
|
+
allow(Tomlrb).to receive(:load_file).with(config_path).and_return(generator_config)
|
|
65
|
+
allow(profiles).to receive(:require).with('haveapi/rust_client/documentation').and_return(true)
|
|
66
|
+
allow(documentation).to receive(:new).and_return(adapter)
|
|
67
|
+
if factories
|
|
68
|
+
allow(documentation).to receive_messages(from_description: adapter, from_snapshot: adapter)
|
|
69
|
+
allow(documentation).to receive(:validate_source) { |source| source }
|
|
70
|
+
allow(HaveAPI::RustClient::Generator).to receive(:validate_config)
|
|
71
|
+
allow(HaveAPI::RustClient::Model).to receive(:validate_package)
|
|
72
|
+
end
|
|
73
|
+
documentation
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def finalize
|
|
77
|
+
profiles.mount([1])
|
|
78
|
+
profiles.finalize(server)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'needs no optional generator or schema when profiles are absent' do
|
|
82
|
+
allow(profiles).to receive(:require).and_raise('Unexpected optional dependency')
|
|
83
|
+
finalize
|
|
84
|
+
expect(profiles).not_to have_received(:require)
|
|
85
|
+
expect(server).not_to have_received(:describe_version)
|
|
86
|
+
expect(profiles.for_version(1)).to eq({})
|
|
87
|
+
expect(profiles.for_version(1)).to be_frozen
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'loads an explicit saved adapter once without rereading it during finalization' do
|
|
91
|
+
documentation = install_go_adapter
|
|
92
|
+
profiles.register(1, go: go_config)
|
|
93
|
+
expect(profiles.for_version(1)).to be_empty
|
|
94
|
+
File.unlink(description_path)
|
|
95
|
+
finalize
|
|
96
|
+
profile = profiles.for_version(1).fetch(:go)
|
|
97
|
+
expect(profile).to have_attributes(language: :go, package_name: 'example.test/widget/client', package_version: 'v1.2.3')
|
|
98
|
+
expect(profile).to be_frozen
|
|
99
|
+
expect(profile.adapter).to equal(adapter)
|
|
100
|
+
expect(documentation).to have_received(:new).once.with(description: JSON.parse(JSON.generate(description)), import_path: 'example.test/widget/client', module_version: 'v1.2.3')
|
|
101
|
+
expect(server).not_to have_received(:describe_version)
|
|
102
|
+
expect(profile).not_to respond_to(:description_path)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'defaults the generator version and defers automatic Go without inventing an SDK version' do
|
|
106
|
+
documentation = install_go_adapter
|
|
107
|
+
config = { import_path: 'example.test/widget/client' }
|
|
108
|
+
profiles.register(1, go: config)
|
|
109
|
+
config[:import_path].replace('changed.test/client')
|
|
110
|
+
expect(documentation).not_to have_received(:new)
|
|
111
|
+
finalize
|
|
112
|
+
profile = profiles.for_version(1).fetch(:go)
|
|
113
|
+
expect(profile.package_name).to eq('example.test/widget/client')
|
|
114
|
+
expect(profile.package_version).to be_nil
|
|
115
|
+
expect(documentation).to have_received(:new).with(description: JSON.parse(JSON.generate(description)), import_path: 'example.test/widget/client', module_version: nil)
|
|
116
|
+
expect(server).to have_received(:describe_version).once
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'permits nil SDK versions and retains explicitly pinned generator versions' do
|
|
120
|
+
documentation = install_go_adapter(version: '0.29.0')
|
|
121
|
+
profiles.register(1, go: go_config.merge(module_version: nil, generator_version: '0.29.0'))
|
|
122
|
+
finalize
|
|
123
|
+
expect(profiles.for_version(1).fetch(:go).package_version).to be_nil
|
|
124
|
+
expect(documentation).to have_received(:new).with(description: anything, import_path: anything, module_version: nil)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'rejects unknown languages, malformed fields and unsafe package configuration' do
|
|
128
|
+
expect { profiles.register(1, python: {}) }.to raise_error(described_class::Invalid, /Unknown/)
|
|
129
|
+
expect { profiles.register(1, go: go_config.merge(unexpected: 'value')) }.to raise_error(described_class::Invalid, /fields/)
|
|
130
|
+
expect { profiles.register(1, go: go_config.merge(import_path: "example.test/\nsource")) }.to raise_error(described_class::Invalid, /package/)
|
|
131
|
+
['latest', '', 1, false].each do |value|
|
|
132
|
+
expect { profiles.register(1, go: go_config.merge(module_version: value)) }.to raise_error(described_class::Invalid, /package/)
|
|
133
|
+
end
|
|
134
|
+
expect { profiles.register(1, go: go_config.merge(generator_version: nil)) }.to raise_error(described_class::Invalid)
|
|
135
|
+
expect { profiles.register(1, go: go_config.merge(generator_version: 'latest')) }.to raise_error(described_class::Invalid, /generator version/)
|
|
136
|
+
expect { profiles.register(1, go: {}) }.to raise_error(described_class::Invalid, /package/)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'rejects missing adapters, mismatched generator versions and missing files at registration' do
|
|
140
|
+
allow(profiles).to receive(:require).and_raise(LoadError)
|
|
141
|
+
expect { profiles.register(1, go: go_config) }.to raise_error(described_class::Invalid, /not installed/)
|
|
142
|
+
install_go_adapter
|
|
143
|
+
expect { profiles.register(1, go: go_config.merge(generator_version: '0.29.0')) }.to raise_error(described_class::Invalid, /version mismatch/)
|
|
144
|
+
expect { profiles.register(1, go: go_config.merge(description_path: 'relative.json')) }.to raise_error(described_class::Invalid, /absolute local/)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'checks the configured adapter interface' do
|
|
148
|
+
documentation = install_go_adapter
|
|
149
|
+
allow(documentation).to receive(:new).and_return(Object.new)
|
|
150
|
+
expect { profiles.register(1, go: go_config) }.to raise_error(described_class::Invalid, /adapter interface/)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'does not leave a partially registered language set after an invalid second profile' do
|
|
154
|
+
install_go_adapter
|
|
155
|
+
expect { profiles.register(1, go: go_config, rust: { unknown: 'field' }) }.to raise_error(described_class::Invalid)
|
|
156
|
+
profiles.register(1, go: go_config)
|
|
157
|
+
finalize
|
|
158
|
+
expect(profiles.for_version(1).keys).to eq([:go])
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'rejects duplicates and registration after mounting starts' do
|
|
162
|
+
install_go_adapter
|
|
163
|
+
profiles.register(1, go: go_config)
|
|
164
|
+
expect { profiles.register('1', go: go_config) }.to raise_error(described_class::Invalid, /Duplicate/)
|
|
165
|
+
profiles.mount([1])
|
|
166
|
+
expect { profiles.register(2, go: go_config) }.to raise_error(described_class::Invalid, /already mounted/)
|
|
167
|
+
profiles.finalize(server)
|
|
168
|
+
expect { profiles.finalize(server) }.to raise_error(described_class::Invalid, /not ready/)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it 'rejects profiles for an unavailable version' do
|
|
172
|
+
install_go_adapter
|
|
173
|
+
profiles.register(1, go: go_config)
|
|
174
|
+
expect { profiles.mount([2]) }.to raise_error(described_class::Invalid, /unavailable API version/)
|
|
175
|
+
expect(profiles.for_version(1)).to be_empty
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'preserves legacy Rust construction even without the new factories' do
|
|
179
|
+
documentation = install_rust_adapter(factories: false)
|
|
180
|
+
File.write(description_path, JSON.generate({ api_version: '1' }))
|
|
181
|
+
profiles.register(1, rust: rust_config)
|
|
182
|
+
finalize
|
|
183
|
+
expect(documentation).to have_received(:new).with(snapshot: { 'api_version' => '1' }, config: generator_config)
|
|
184
|
+
expect(profiles.for_version(1).fetch(:rust)).to have_attributes(package_name: 'widget-client', package_version: '1.2.3')
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'rejects a saved Rust snapshot for a different selected API version at mount' do
|
|
188
|
+
install_rust_adapter
|
|
189
|
+
File.write(description_path, JSON.generate({ api_version: '2' }))
|
|
190
|
+
profiles.register(1, rust: rust_config)
|
|
191
|
+
expect { profiles.mount([1]) }.to raise_error(described_class::Invalid, /snapshot API version mismatch/)
|
|
192
|
+
expect(profiles.for_version(1)).to be_empty
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'validates inline Rust identity and source before building from the mounted description' do
|
|
196
|
+
documentation = install_rust_adapter
|
|
197
|
+
source = { git: 'https://example.test/widget-client', rev: 'a' * 40 }
|
|
198
|
+
profiles.register(1, rust: { crate_name: 'widget-client', source: })
|
|
199
|
+
expect(HaveAPI::RustClient::Model).to have_received(:validate_package).with(crate_name: 'widget-client', crate_version: nil)
|
|
200
|
+
expect(documentation).to have_received(:validate_source).with(source)
|
|
201
|
+
expect(documentation).not_to have_received(:from_description)
|
|
202
|
+
finalize
|
|
203
|
+
expect(documentation).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: HaveAPI::PROTOCOL_VERSION,
|
|
204
|
+
mount_path: '/', crate_name: 'widget-client', crate_version: nil, source:)
|
|
205
|
+
expect(profiles.for_version(1).fetch(:rust).package_version).to be_nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'supports an inline Rust identity with a saved snapshot and optional explicit SDK version' do
|
|
209
|
+
documentation = install_rust_adapter
|
|
210
|
+
snapshot = { 'api_version' => '1' }
|
|
211
|
+
File.write(description_path, JSON.generate(snapshot))
|
|
212
|
+
profiles.register(1, rust: { snapshot_path: description_path, crate_name: 'widget-client', crate_version: '1.2.3' })
|
|
213
|
+
finalize
|
|
214
|
+
expect(documentation).to have_received(:from_snapshot).with(snapshot:, crate_name: 'widget-client', crate_version: '1.2.3', source: nil)
|
|
215
|
+
expect(server).not_to have_received(:describe_version)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it 'validates full legacy TOML before using it with an automatic Rust description' do
|
|
219
|
+
documentation = install_rust_adapter
|
|
220
|
+
snapshot = { 'api_version' => '1' }
|
|
221
|
+
allow(HaveAPI::RustClient::Snapshot).to receive(:from_description).and_return(snapshot)
|
|
222
|
+
profiles.register(1, rust: { generator_config_path: config_path })
|
|
223
|
+
expect(HaveAPI::RustClient::Generator).to have_received(:validate_config).with(generator_config)
|
|
224
|
+
finalize
|
|
225
|
+
expect(HaveAPI::RustClient::Snapshot).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: HaveAPI::PROTOCOL_VERSION,
|
|
226
|
+
mount_path: '/', source_url: 'https://haveapi.invalid/v1/', profile: 'server-documentation')
|
|
227
|
+
expect(documentation).to have_received(:new).with(snapshot:, config: generator_config)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it 'rejects automatic and inline modes when an older Rust generator lacks their factories' do
|
|
231
|
+
install_rust_adapter(factories: false)
|
|
232
|
+
expect { profiles.register(1, rust: { generator_config_path: config_path }) }.to raise_error(described_class::Invalid, /does not support/)
|
|
233
|
+
expect { profiles.register(1, rust: { crate_name: 'widget-client', snapshot_path: description_path }) }.to raise_error(described_class::Invalid, /does not support/)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'rejects conflicting Rust identities and unknown source profile fields' do
|
|
237
|
+
install_rust_adapter
|
|
238
|
+
expect { profiles.register(1, rust: rust_config.merge(crate_name: 'widget-client')) }.to raise_error(described_class::Invalid, /Conflicting/)
|
|
239
|
+
expect { profiles.register(1, rust: rust_config.merge(crate_version: nil)) }.to raise_error(described_class::Invalid, /Conflicting/)
|
|
240
|
+
expect { profiles.register(1, rust: { crate_name: 'widget-client', branch: 'main' }) }.to raise_error(described_class::Invalid, /fields/)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'captures one private structural schema per version shared by automatic profiles' do
|
|
244
|
+
go = install_go_adapter
|
|
245
|
+
rust = install_rust_adapter
|
|
246
|
+
profiles.register(1, go: { import_path: 'example.test/client' }, rust: { crate_name: 'widget-client' })
|
|
247
|
+
finalize
|
|
248
|
+
expect(server).to have_received(:describe_version).once do |context|
|
|
249
|
+
expect(context).to have_attributes(server:, version: 1, current_user: nil, endpoint: false, doc: true)
|
|
250
|
+
end
|
|
251
|
+
expect(go).to have_received(:new).with(description: anything, import_path: anything, module_version: nil)
|
|
252
|
+
expect(rust).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: anything, mount_path: '/', crate_name: anything, crate_version: nil, source: nil)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
it 'publishes no partial profiles and hides private diagnostics when finalization fails' do
|
|
256
|
+
install_go_adapter
|
|
257
|
+
rust = install_rust_adapter
|
|
258
|
+
allow(rust).to receive(:from_description).and_raise('HIDDEN_SCHEMA_DEFAULT')
|
|
259
|
+
profiles.register(1, go: { import_path: 'example.test/client' }, rust: { crate_name: 'widget-client' })
|
|
260
|
+
profiles.mount([1])
|
|
261
|
+
expect { profiles.finalize(server) }.to raise_error(described_class::Invalid, 'Configured documentation client schema or configuration is invalid')
|
|
262
|
+
expect(profiles.for_version(1)).to be_empty
|
|
263
|
+
expect { profiles.register(1, go: go_config) }.to raise_error(described_class::Invalid, /already mounted/)
|
|
264
|
+
end
|
|
265
|
+
end
|