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,75 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'haveapi/documentation_context'
|
|
3
|
+
require 'haveapi/client_examples/rust_client'
|
|
4
|
+
|
|
5
|
+
class RustDocumentationAdapterFixture
|
|
6
|
+
def render(kind:, context:, mode:, auth_method: nil, auth_description: nil); end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
describe HaveAPI::ClientExamples::RustClient do
|
|
10
|
+
let(:adapter) { instance_spy(RustDocumentationAdapterFixture) }
|
|
11
|
+
let(:profile) { Struct.new(:language, :adapter).new(:rust, adapter).freeze }
|
|
12
|
+
let(:context) do
|
|
13
|
+
HaveAPI::ClientExample::Context.new(
|
|
14
|
+
version: '1.0', host: 'example.test', base_url: 'https://example.test/mount/',
|
|
15
|
+
version_url: 'https://example.test/mount/v1.0/', description: {},
|
|
16
|
+
resource_path: ['widget'], action_name: 'update', action: {}, sample: { request: {} }, profile:
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
let(:code) { { status: :code, code: 'generated Rust' }.freeze }
|
|
20
|
+
|
|
21
|
+
it 'registers separate stable variants sharing the Rust grammar' do
|
|
22
|
+
expect(HaveAPI::ClientExamples::RustAsyncClient.id).to eq('rust-async')
|
|
23
|
+
expect(HaveAPI::ClientExamples::RustBlockingClient.id).to eq('rust-blocking')
|
|
24
|
+
expect(HaveAPI::ClientExamples::RustAsyncClient.code).to eq(:rust)
|
|
25
|
+
expect(HaveAPI::ClientExamples::RustBlockingClient.code).to eq(:rust)
|
|
26
|
+
expect(HaveAPI::ClientExamples::RustAsyncClient.order).to eq(17)
|
|
27
|
+
expect(HaveAPI::ClientExamples::RustBlockingClient.order).to eq(18)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'hides all Rust variants without the matching configured profile' do
|
|
31
|
+
[HaveAPI::ClientExamples::RustAsyncClient, HaveAPI::ClientExamples::RustBlockingClient].each do |renderer|
|
|
32
|
+
expect(renderer.available?(context.with(profile: nil))).to be(false)
|
|
33
|
+
expect(renderer.available?(context.with(profile: Struct.new(:language).new(:go)))).to be(false)
|
|
34
|
+
expect(renderer.available?(context)).to be(true)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'passes immutable authorized init context to the pure adapter' do
|
|
39
|
+
allow(adapter).to receive(:render).and_return(code)
|
|
40
|
+
expect(HaveAPI::ClientExamples::RustAsyncClient.render(:init, context:)).to eq(code)
|
|
41
|
+
expect(adapter).to have_received(:render).with(kind: :init, context:, mode: :async)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'keeps authentication provider and description exact for blocking rendering' do
|
|
45
|
+
allow(adapter).to receive(:render).and_return(code)
|
|
46
|
+
expect(HaveAPI::ClientExamples::RustBlockingClient.render(
|
|
47
|
+
:auth, context:, auth_method: :token, auth_description: { http_header: 'X-Token' }
|
|
48
|
+
)).to eq(code)
|
|
49
|
+
expect(adapter).to have_received(:render).with(kind: :auth, context:, mode: :blocking,
|
|
50
|
+
auth_method: :token, auth_description: { http_header: 'X-Token' })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'binds the exact filtered sample when using the retained positional API' do
|
|
54
|
+
sample = { request: { name: 'authorized' } }
|
|
55
|
+
renderer = HaveAPI::ClientExamples::RustAsyncClient.new('example.test', 'https://example.test/', '1.0')
|
|
56
|
+
renderer.with_context(context)
|
|
57
|
+
allow(adapter).to receive(:render).and_return(code)
|
|
58
|
+
expect(renderer.example(sample)).to eq(code)
|
|
59
|
+
expect(adapter).to have_received(:render).with(kind: :example, mode: :async, context: have_attributes(sample: sample))
|
|
60
|
+
expect(renderer.context.sample).to be_frozen
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'preserves a concise adapter limitation without fabricating source' do
|
|
64
|
+
result = { status: :limitation, reason: 'This sample needs an unsupported SDK capability.' }.freeze
|
|
65
|
+
allow(adapter).to receive(:render).and_return(result)
|
|
66
|
+
expect(HaveAPI::ClientExamples::RustAsyncClient.render(:example, context:)).to eq(result)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'retains callable legacy constructor methods even without a profile' do
|
|
70
|
+
renderer = HaveAPI::ClientExamples::RustAsyncClient.new('example.test', 'https://example.test/', '1.0')
|
|
71
|
+
expect(renderer.init).to include(status: :limitation)
|
|
72
|
+
expect(renderer.auth(:basic, {})).to include(status: :limitation)
|
|
73
|
+
expect(renderer.example({})).to include(status: :limitation)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe HaveAPI::ClientExample::Profiles do
|
|
4
|
+
let(:schema) { HaveAPI::ClientExample.const_get(:StructuralSchema) }
|
|
5
|
+
let(:data) { { description: 'data description', default: { examples: ['data example'] }, message: 'data message' } }
|
|
6
|
+
let(:parameter) do
|
|
7
|
+
{ type: 'Custom', default: data, description: 'private prose', label: 'private label',
|
|
8
|
+
validators: { include: { values: [data], message: 'private validation message' }, custom: data }, format: data }
|
|
9
|
+
end
|
|
10
|
+
let(:description) do
|
|
11
|
+
action = { path: '/v1/widgets/', input: { layout: :hash, parameters: { default: parameter } },
|
|
12
|
+
output: { parameters: {} }, meta: { global: { input: { parameters: { example: parameter } } } },
|
|
13
|
+
description: 'private action prose', examples: [{ request: data }] }
|
|
14
|
+
resource = { description: 'private resource prose', actions: { index: action }, resources: {} }
|
|
15
|
+
{ resources: { widget: resource }, authentication: { token: { description: 'private auth prose', resources: { token: resource } } } }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'copies structural locations and strips defaults and prose without changing the server description' do
|
|
19
|
+
before = JSON.generate(description)
|
|
20
|
+
result = schema.copy(description)
|
|
21
|
+
action = result.dig('resources', 'widget', 'actions', 'index')
|
|
22
|
+
expect(action).not_to have_key('description')
|
|
23
|
+
expect(action).not_to have_key('examples')
|
|
24
|
+
expect(result.dig('resources', 'widget')).not_to have_key('description')
|
|
25
|
+
expect(result.dig('authentication', 'token')).not_to have_key('description')
|
|
26
|
+
expect(action.dig('input', 'parameters', 'default')).not_to have_key('default')
|
|
27
|
+
expect(action.dig('meta', 'global', 'input', 'parameters', 'example')).not_to have_key('default')
|
|
28
|
+
expect(result.dig('authentication', 'token', 'resources', 'token', 'actions', 'index')).not_to have_key('examples')
|
|
29
|
+
expect(JSON.generate(description)).to eq(before)
|
|
30
|
+
expect(description).not_to be_frozen
|
|
31
|
+
expect(result).to be_frozen
|
|
32
|
+
expect(action.dig('input', 'parameters', 'default')).to be_frozen
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'retains parameter names, layouts, associations and arbitrary validator/Custom data verbatim' do
|
|
36
|
+
parameter[:resource] = %w[widget]
|
|
37
|
+
parameter[:choices] = { path: '/v1/widgets/', method: 'GET' }
|
|
38
|
+
result = schema.copy(description)
|
|
39
|
+
copied = result.dig('resources', 'widget', 'actions', 'index', 'input', 'parameters', 'default')
|
|
40
|
+
expect(copied).to include('type' => 'Custom', 'resource' => ['widget'], 'choices' => { 'path' => '/v1/widgets/', 'method' => 'GET' })
|
|
41
|
+
expect(copied['format']).to eq(JSON.parse(JSON.generate(data)))
|
|
42
|
+
expect(copied.dig('validators', 'include', 'values')).to eq([JSON.parse(JSON.generate(data))])
|
|
43
|
+
expect(copied.dig('validators', 'custom')).to eq(JSON.parse(JSON.generate(data)))
|
|
44
|
+
expect(copied.dig('validators', 'include')).not_to have_key('message')
|
|
45
|
+
expect(result.dig('resources', 'widget', 'actions', 'index', 'input', 'layout')).to eq('hash')
|
|
46
|
+
expect(result.dig('resources', 'widget', 'actions', 'index', 'path')).to eq('/v1/widgets/')
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -71,7 +71,8 @@ describe HaveAPI::ClientExamples do
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
it 'generates valid JavaScript' do
|
|
74
|
-
|
|
74
|
+
result = HaveAPI::ClientExamples::JsClient.new(*client_args).example(sample)
|
|
75
|
+
source = result.is_a?(Hash) ? result.fetch(:code) : result
|
|
75
76
|
status, output = syntax_check(%w[node --check], source, '.mjs')
|
|
76
77
|
|
|
77
78
|
expect(status).to be_success, output
|
|
@@ -276,6 +276,18 @@ describe DocAuthFilteringSpec do
|
|
|
276
276
|
expect(examples.size).to eq(1)
|
|
277
277
|
expect(examples.first[:response][:msg]).to eq('ADMIN_ONLY_RESULT')
|
|
278
278
|
end
|
|
279
|
+
|
|
280
|
+
it 'does not retain privileged sample data across HTML documentation requests' do
|
|
281
|
+
login('admin', 'pass')
|
|
282
|
+
get '/v1/'
|
|
283
|
+
expect(last_response.status).to eq(200)
|
|
284
|
+
expect(last_response.body).to include('ADMIN_ONLY_RESULT')
|
|
285
|
+
|
|
286
|
+
login('user', 'pass')
|
|
287
|
+
get '/v1/'
|
|
288
|
+
expect(last_response.status).to eq(200)
|
|
289
|
+
expect(last_response.body).not_to include('ADMIN_ONLY_RESULT')
|
|
290
|
+
end
|
|
279
291
|
end
|
|
280
292
|
end
|
|
281
293
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe HaveAPI::ClientExample do
|
|
4
|
+
api do
|
|
5
|
+
define_resource(:Widget) do
|
|
6
|
+
version 1
|
|
7
|
+
auth false
|
|
8
|
+
|
|
9
|
+
define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
|
|
10
|
+
authorize { allow }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
define_action(:Show) do
|
|
14
|
+
route '{widget_id}'
|
|
15
|
+
http_method :get
|
|
16
|
+
output(:hash) { string :name }
|
|
17
|
+
authorize { allow }
|
|
18
|
+
|
|
19
|
+
# rubocop:disable RSpec/NoExpectationExample
|
|
20
|
+
example 'sample' do
|
|
21
|
+
path_params 1
|
|
22
|
+
response({ name: 'documented widget' })
|
|
23
|
+
end
|
|
24
|
+
# rubocop:enable RSpec/NoExpectationExample
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
default_version 1
|
|
30
|
+
|
|
31
|
+
it 'keeps maintained renderers registered and removes File system' do
|
|
32
|
+
labels = described_class.clients.map(&:label)
|
|
33
|
+
expect(labels).to include('Ruby', 'PHP', 'CLI', 'curl', 'HTTP')
|
|
34
|
+
expect(labels).not_to include('File system')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'omits the unmaintained client tab and overview link from generated HTML' do
|
|
38
|
+
get '/v1/'
|
|
39
|
+
|
|
40
|
+
expect(last_response.status).to eq(200)
|
|
41
|
+
expect(last_response.body).not_to include('File system', 'haveapi-fs', 'FUSE-based file system')
|
|
42
|
+
expect(last_response.body).to include('Initialization', '<h4>Ruby</h4>', '<h4>PHP</h4>', '<h4>curl</h4>')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'hides unconfigured Go and Rust SDKs in overview, initialization, authentication and actions' do
|
|
46
|
+
get '/v1/'
|
|
47
|
+
expect(last_response.status).to eq(200)
|
|
48
|
+
expect(last_response.body).not_to include('clients/go', 'clients/rust', 'init-go', 'init-rust', '>Go</h', '>Rust (')
|
|
49
|
+
expect(last_response.body).to include('init-javascript-callback', 'init-typescript-async', 'init-python-sync')
|
|
50
|
+
expect(last_response.body).to include('example-widget-show-0-javascript-callback')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'uses the configured profile consistently for every panel and escapes limitations once' do
|
|
54
|
+
profiles = { go: HaveAPI::ClientExample::Profile.new(language: :go, package_name: 'example.test/client', package_version: 'v1.2.3', adapter: Object.new) }
|
|
55
|
+
allow(app.settings.api_server).to receive(:documentation_client_profiles).and_return(profiles)
|
|
56
|
+
allow(HaveAPI::ClientExamples::GoClient).to receive(:render).and_return({ status: :limitation, reason: 'Unsupported <sample> & value' }.freeze)
|
|
57
|
+
get '/v1/'
|
|
58
|
+
expect(last_response.status).to eq(200)
|
|
59
|
+
expect(last_response.body).to include('clients/go', 'init-go', 'example-widget-show-0-go', 'Unsupported <sample> & value')
|
|
60
|
+
expect(last_response.body).not_to include('Unsupported <sample>', 'clients/rust', 'init-rust')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'passes mounted version-aware URLs and stable canonical action data into renderers' do
|
|
64
|
+
contexts = []
|
|
65
|
+
allow(HaveAPI::ClientExamples::GoClient).to receive(:available?).and_return(true)
|
|
66
|
+
allow(HaveAPI::ClientExamples::GoClient).to receive(:render) do |kind, context:, **_kwargs|
|
|
67
|
+
contexts << [kind, context]
|
|
68
|
+
{ status: :code, code: context.version_url }
|
|
69
|
+
end
|
|
70
|
+
server = HaveAPI::Server.new(get_opt(:api_module))
|
|
71
|
+
server.use_version(1)
|
|
72
|
+
server.mount('/mounted/api/')
|
|
73
|
+
response = Rack::MockRequest.new(server.app).get('/mounted/api/v1/', 'HTTP_HOST' => 'example.org')
|
|
74
|
+
expect(response.status).to eq(200)
|
|
75
|
+
selected = contexts.find { |kind, ctx| kind == :example && ctx.action_name == 'show' }.last
|
|
76
|
+
expect(selected.base_url).to eq('http://example.org/mounted/api/')
|
|
77
|
+
expect(selected.version_url).to eq('http://example.org/mounted/api/v1/')
|
|
78
|
+
expect(selected.resource_path).to eq(['widget'])
|
|
79
|
+
expect(selected.description[:resources]).to have_key('widget')
|
|
80
|
+
expect(selected.sample[:response]).to eq(name: 'documented widget')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'prints a package identity without inventing an omitted version and escapes it once' do
|
|
84
|
+
profile = HaveAPI::ClientExample::Profile.new(language: :go, package_name: 'example.test/<client>&', package_version: nil, adapter: Object.new)
|
|
85
|
+
allow(app.settings.api_server).to receive(:documentation_client_profiles).and_return({ go: profile })
|
|
86
|
+
allow(HaveAPI::ClientExamples::GoClient).to receive(:render).and_return({ status: :code, code: 'example' })
|
|
87
|
+
get '/v1/'
|
|
88
|
+
expect(last_response.status).to eq(200)
|
|
89
|
+
expect(last_response.body).to include('<code>example.test/<client>&</code></li>')
|
|
90
|
+
expect(last_response.body).not_to include('example.test/<client>', '&lt;', 'version = "latest"')
|
|
91
|
+
end
|
|
92
|
+
end
|
data/spec/packaging_spec.rb
CHANGED
|
@@ -9,7 +9,7 @@ require 'tmpdir'
|
|
|
9
9
|
|
|
10
10
|
describe HaveAPI::Server do
|
|
11
11
|
def required_docs
|
|
12
|
-
%w[json-schema.html protocol.md create-client.md typed-input-validation.md protocol.png]
|
|
12
|
+
%w[json-schema.html protocol.md create-client.md typed-input-validation.md client-examples.md protocol.png]
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
before(:context) do
|
|
@@ -53,6 +53,13 @@ describe HaveAPI::Server do
|
|
|
53
53
|
required_docs.each do |name|
|
|
54
54
|
expect(File.read(File.join(dir, 'doc', name))).not_to be_empty
|
|
55
55
|
end
|
|
56
|
+
%w[
|
|
57
|
+
lib/haveapi/documentation_context.rb lib/haveapi/documentation_clients.rb
|
|
58
|
+
lib/haveapi/views/version_page/client_result.erb
|
|
59
|
+
lib/haveapi/public/js/highlight.pack.js lib/haveapi/public/js/highlight-LICENSE
|
|
60
|
+
].each do |name|
|
|
61
|
+
expect(File.size?(File.join(dir, name))).not_to be_nil
|
|
62
|
+
end
|
|
56
63
|
|
|
57
64
|
script = <<~RUBY
|
|
58
65
|
require 'haveapi'
|
|
@@ -76,7 +83,7 @@ describe HaveAPI::Server do
|
|
|
76
83
|
server.use_version(1)
|
|
77
84
|
server.mount('/')
|
|
78
85
|
request = Rack::MockRequest.new(server.app)
|
|
79
|
-
responses = %w[/doc/json-schema /doc/protocol.md /doc/create-client.md].to_h do |path|
|
|
86
|
+
responses = %w[/doc/json-schema /doc/protocol.md /doc/create-client.md /doc/client-examples.md /js/highlight-LICENSE].to_h do |path|
|
|
80
87
|
response = request.get(path, 'HTTP_HOST' => 'localhost')
|
|
81
88
|
[path, { status: response.status, type: response.content_type, body: response.body }]
|
|
82
89
|
end
|
|
@@ -112,11 +119,13 @@ describe HaveAPI::Server do
|
|
|
112
119
|
{
|
|
113
120
|
'/doc/json-schema' => 'Envelope',
|
|
114
121
|
'/doc/protocol.md' => 'Protocol definition',
|
|
115
|
-
'/doc/create-client.md' => 'Client definition'
|
|
122
|
+
'/doc/create-client.md' => 'Client definition',
|
|
123
|
+
'/doc/client-examples.md' => 'Client examples',
|
|
124
|
+
'/js/highlight-LICENSE' => 'Copyright'
|
|
116
125
|
}.each do |path, heading|
|
|
117
126
|
response = result.fetch('responses').fetch(path)
|
|
118
127
|
expect(response.fetch('status')).to eq(200), "Packaged route failed: #{path}"
|
|
119
|
-
expect(response.fetch('type')).to include('text/html')
|
|
128
|
+
expect(response.fetch('type')).to include('text/html') unless path == '/js/highlight-LICENSE'
|
|
120
129
|
expect(response.fetch('body')).to include(heading)
|
|
121
130
|
end
|
|
122
131
|
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require_relative '../lib/haveapi'
|
|
3
|
+
|
|
4
|
+
module DynamicDocumentationAPI
|
|
5
|
+
PAYLOAD = { 'text' => "DOC_LITERAL žluťoučký \"quote\"\\\n</script>",
|
|
6
|
+
'__proto__' => { 'constructor' => [false, 0, nil, ''] } }.freeze
|
|
7
|
+
def self.receipts
|
|
8
|
+
@receipts ||= []
|
|
9
|
+
end
|
|
10
|
+
User = Struct.new(:login)
|
|
11
|
+
|
|
12
|
+
class RequestTrace
|
|
13
|
+
def initialize(app, output: $stderr)
|
|
14
|
+
@app = app
|
|
15
|
+
@output = output
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(env)
|
|
19
|
+
status, headers, body = @app.call(env)
|
|
20
|
+
@output.puts("DYNAMIC_DOCUMENTATION_REQUEST #{JSON.generate({ method: env['REQUEST_METHOD'], path: env['PATH_INFO'], status: })}")
|
|
21
|
+
[status, headers, body]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class BasicProvider < HaveAPI::Authentication::Basic::Provider
|
|
26
|
+
protected
|
|
27
|
+
|
|
28
|
+
def find_user(_request, username, password)
|
|
29
|
+
User.new(username) if username == 'fixture-user' && password == 'fixture-password'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class TokenConfig < HaveAPI::Authentication::Token::Config
|
|
34
|
+
request do
|
|
35
|
+
handle do |request, result|
|
|
36
|
+
if request.input[:user] == 'fixture-user' && request.input[:password] == 'fixture-password'
|
|
37
|
+
result.token = 'fixture-challenge'
|
|
38
|
+
result.next_action = 'verify'
|
|
39
|
+
result.complete = false
|
|
40
|
+
result.valid_to = Time.now + 3600
|
|
41
|
+
result.ok
|
|
42
|
+
else
|
|
43
|
+
result.error = 'Invalid fixture credentials'
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
action :verify do
|
|
50
|
+
input { string :code, required: true }
|
|
51
|
+
handle do |request, result|
|
|
52
|
+
if request.token == 'fixture-challenge' && request.input[:code] == '123456'
|
|
53
|
+
result.token = 'fixture-token'
|
|
54
|
+
result.complete = true
|
|
55
|
+
result.valid_to = Time.now + 3600
|
|
56
|
+
result.ok
|
|
57
|
+
else
|
|
58
|
+
result.error = 'Invalid fixture challenge'
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
renew { handle { |_request, result| result.ok } }
|
|
65
|
+
revoke { handle { |_request, result| result.ok } }
|
|
66
|
+
|
|
67
|
+
def find_user_by_token(_request, token)
|
|
68
|
+
User.new('fixture-user') if token == 'fixture-token'
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
TokenProvider = HaveAPI::Authentication::Token.with_config(TokenConfig)
|
|
73
|
+
|
|
74
|
+
class OAuth2Provider < HaveAPI::Authentication::Base
|
|
75
|
+
auth_method :oauth2
|
|
76
|
+
|
|
77
|
+
def describe
|
|
78
|
+
{ http_header: 'X-Fixture-OAuth2' }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def authenticate(request)
|
|
82
|
+
value = request.env['HTTP_X_FIXTURE_OAUTH2'] || request.env['HTTP_AUTHORIZATION']&.delete_prefix('Bearer ')
|
|
83
|
+
User.new('fixture-user') if value == 'fixture-access-token'
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
module Resources
|
|
88
|
+
class Constructor < HaveAPI::Resource
|
|
89
|
+
version '2'
|
|
90
|
+
route 'parents'
|
|
91
|
+
|
|
92
|
+
class Resources < HaveAPI::Resource
|
|
93
|
+
route '{parent_id}/children'
|
|
94
|
+
|
|
95
|
+
class New < HaveAPI::Action
|
|
96
|
+
route '{widget_id}'
|
|
97
|
+
http_method :post
|
|
98
|
+
auth true
|
|
99
|
+
authorize { |user| user ? allow : deny }
|
|
100
|
+
|
|
101
|
+
input(:hash, namespace: :widget) do
|
|
102
|
+
string :name
|
|
103
|
+
bool :enabled
|
|
104
|
+
integer :count
|
|
105
|
+
string :nullable, nullable: true
|
|
106
|
+
string :omitted
|
|
107
|
+
custom :payload
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
output(:hash, namespace: :widget) do
|
|
111
|
+
string :marker
|
|
112
|
+
custom :payload
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
example 'literal success' do
|
|
116
|
+
path_params 7, 'name with space'
|
|
117
|
+
request(name: '', enabled: false, count: 0, nullable: nil, payload: DynamicDocumentationAPI::PAYLOAD)
|
|
118
|
+
response(marker: 'DOC_SUCCESS', payload: DynamicDocumentationAPI::PAYLOAD)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
example 'literal failure' do
|
|
122
|
+
path_params 7, 'name with space'
|
|
123
|
+
request(name: '', enabled: false, count: -1, nullable: nil, payload: DynamicDocumentationAPI::PAYLOAD)
|
|
124
|
+
status false
|
|
125
|
+
message 'DOC_FAILURE'
|
|
126
|
+
errors count: ['DOC_FAILURE']
|
|
127
|
+
end
|
|
128
|
+
def exec
|
|
129
|
+
DynamicDocumentationAPI.receipts << { path: path_params.values_at('parent_id', 'widget_id'), input: }
|
|
130
|
+
error!('DOC_FAILURE', { count: ['DOC_FAILURE'] }, http_status: 400) if input[:count] < 0
|
|
131
|
+
|
|
132
|
+
{ marker: 'DOC_SUCCESS', payload: input[:payload] }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.build
|
|
140
|
+
HaveAPI.implicit_version = '2'
|
|
141
|
+
api = HaveAPI::Server.new(Resources)
|
|
142
|
+
api.use_version('2')
|
|
143
|
+
api.default_version = '2'
|
|
144
|
+
api.auth_chain << BasicProvider
|
|
145
|
+
api.auth_chain << TokenProvider
|
|
146
|
+
api.auth_chain << OAuth2Provider
|
|
147
|
+
api.connect_hook(:pre_mount) do |ret, _server, sinatra|
|
|
148
|
+
sinatra.get '/__acceptance_receipts' do
|
|
149
|
+
content_type 'application/json'
|
|
150
|
+
JSON.generate(DynamicDocumentationAPI.receipts)
|
|
151
|
+
end
|
|
152
|
+
ret
|
|
153
|
+
end
|
|
154
|
+
api.mount('/mount/')
|
|
155
|
+
api
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if $0 == __FILE__
|
|
160
|
+
require 'webrick'
|
|
161
|
+
require 'rackup'
|
|
162
|
+
server = nil
|
|
163
|
+
trap('TERM') { server&.shutdown }
|
|
164
|
+
app = DynamicDocumentationAPI::RequestTrace.new(DynamicDocumentationAPI.build.app)
|
|
165
|
+
Rackup::Handler::WEBrick.run(app, Host: '127.0.0.1', Port: 0,
|
|
166
|
+
AccessLog: [], Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN)) do |instance|
|
|
167
|
+
server = instance
|
|
168
|
+
puts "DYNAMIC_DOCUMENTATION_READY http://127.0.0.1:#{server.listeners.first.addr[1]}"
|
|
169
|
+
$stdout.flush
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require_relative '../spec/client_examples/dynamic_fixture'
|
|
4
|
+
|
|
5
|
+
RSpec.describe HaveAPI::ClientExamples::PythonClient do
|
|
6
|
+
include DynamicExampleFixture
|
|
7
|
+
|
|
8
|
+
let(:renderers) { [described_class, HaveAPI::ClientExamples::PythonAsyncClient] }
|
|
9
|
+
|
|
10
|
+
def python_request(action, params)
|
|
11
|
+
root = File.expand_path('../../..', __dir__)
|
|
12
|
+
source = <<~PY
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from haveapi_client import HaveAPIError
|
|
16
|
+
from haveapi_client._protocol import CallContext, build_request, lookup_action, parse_description
|
|
17
|
+
|
|
18
|
+
fixture = json.load(sys.stdin)
|
|
19
|
+
try:
|
|
20
|
+
description = parse_description({
|
|
21
|
+
"status": True, "version": "2.0",
|
|
22
|
+
"response": {"resources": {"widget": {"actions": {"show": fixture["action"]}}}}
|
|
23
|
+
})
|
|
24
|
+
action = lookup_action(description, ("widget",), "show")
|
|
25
|
+
context = CallContext(action, (), dict(zip(action.path_parameters, fixture["path_params"])), 1)
|
|
26
|
+
result = build_request(description, context, base_url="https://api.example.test/", params=fixture["params"])
|
|
27
|
+
print(json.dumps({"query": result.query, "body": json.loads(result.content) if result.content else None}))
|
|
28
|
+
except HaveAPIError as error:
|
|
29
|
+
print(json.dumps({"error": type(error).__name__, "message": str(error)}))
|
|
30
|
+
PY
|
|
31
|
+
output, status = Open3.capture2e(
|
|
32
|
+
{ 'PYTHONPATH' => File.join(root, 'clients/python/src'), 'PYTHONDONTWRITEBYTECODE' => '1' },
|
|
33
|
+
File.join(root, 'clients/python/.venv/bin/python'), '-c', source,
|
|
34
|
+
stdin_data: JSON.generate({ action:, params:, path_params: dynamic_sample[:path_params] })
|
|
35
|
+
)
|
|
36
|
+
expect(status).to be_success, output
|
|
37
|
+
JSON.parse(output)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'limits GET Custom structures rejected by the real Python request builder in both modes' do
|
|
41
|
+
action = dynamic_action.merge(method: 'GET', input: {
|
|
42
|
+
layout: :hash, namespace: :widget, parameters: { payload: { type: 'Custom' } }
|
|
43
|
+
})
|
|
44
|
+
[{ enabled: false, count: 0 }, [], {}, { nested: [] }, [{ a: 'one' }, { b: 'two' }], false, 0].each do |value|
|
|
45
|
+
sample = dynamic_sample.merge(request: { payload: value })
|
|
46
|
+
expect(python_request(action, sample[:request])).to include('error' => 'ValidationError')
|
|
47
|
+
renderers.each do |renderer|
|
|
48
|
+
result = renderer.render(:example, context: dynamic_context(action:, sample:))
|
|
49
|
+
expect(result[:status]).to eq(:limitation)
|
|
50
|
+
expect(result[:reason]).to include('Python GET', 'flat scalar')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'preserves flat GET scalars and nullable Custom values supported by the real builder' do
|
|
56
|
+
action = dynamic_action.merge(method: 'GET', input: {
|
|
57
|
+
layout: :hash, namespace: :widget,
|
|
58
|
+
parameters: { name: { type: 'String' }, enabled: { type: 'Boolean' }, count: { type: 'Integer' },
|
|
59
|
+
nullable: { type: 'String', nullable: true }, payload: { type: 'Custom', nullable: true } }
|
|
60
|
+
})
|
|
61
|
+
[nil, ''].each do |payload|
|
|
62
|
+
params = { name: 'a&ž', enabled: false, count: 0, nullable: nil, payload: }
|
|
63
|
+
expect(python_request(action, params)).to include('query' => [
|
|
64
|
+
['widget[name]', 'a&ž'], ['widget[enabled]', 'false'], ['widget[count]', '0'],
|
|
65
|
+
['widget[nullable]', ''], ['widget[payload]', '']
|
|
66
|
+
])
|
|
67
|
+
renderers.each do |renderer|
|
|
68
|
+
result = renderer.render(:example, context: dynamic_context(action:, sample: dynamic_sample.merge(request: params)))
|
|
69
|
+
expect(result[:status]).to eq(:code), result.inspect
|
|
70
|
+
expect(result[:code]).to include('"enabled": False', '"count": 0', '"nullable": None')
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'documents a conservative GET subset and rejects ambiguous query key components' do
|
|
76
|
+
cases = [
|
|
77
|
+
[{ payload: { nested: 'supported by client' } }, :widget, { payload: { type: 'Custom' } }, false],
|
|
78
|
+
[{ payload: ['supported by client'] }, :widget, { payload: { type: 'Custom' } }, false],
|
|
79
|
+
[{ payload: 'text' }, 'bad[name]', { payload: { type: 'String' } }, true],
|
|
80
|
+
[{ 'bad[name]' => 'text' }, :widget, { 'bad[name]' => { type: 'String' } }, true],
|
|
81
|
+
[{ '' => 'text' }, :widget, { '' => { type: 'String' } }, true]
|
|
82
|
+
]
|
|
83
|
+
cases.each do |params, namespace, parameters, rejected|
|
|
84
|
+
action = dynamic_action.merge(method: 'GET', input: { layout: :hash, namespace:, parameters: })
|
|
85
|
+
expect(python_request(action, params).has_key?('error')).to eq(rejected)
|
|
86
|
+
renderers.each do |renderer|
|
|
87
|
+
expect(renderer.render(:example, context: dynamic_context(action:, sample: dynamic_sample.merge(request: params)))[:status]).to eq(:limitation)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'leaves JSON methods intact and limits HEAD and OPTIONS as the real parser does' do
|
|
93
|
+
%w[POST PUT DELETE HEAD OPTIONS].each do |method|
|
|
94
|
+
action = dynamic_action.merge(method:)
|
|
95
|
+
result = python_request(action, dynamic_sample[:request])
|
|
96
|
+
supported = %w[POST PUT DELETE].include?(method)
|
|
97
|
+
if supported
|
|
98
|
+
expect(result.fetch('body').fetch('widget')).to eq(JSON.parse(JSON.generate(dynamic_sample[:request])))
|
|
99
|
+
else
|
|
100
|
+
expect(result).to include('error' => 'ProtocolError', 'message' => "unsupported action method: #{method}")
|
|
101
|
+
end
|
|
102
|
+
renderers.each do |renderer|
|
|
103
|
+
example = renderer.render(:example, context: dynamic_context(action:))
|
|
104
|
+
expect(example[:status]).to eq(supported ? :code : :limitation)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'cross-checks missing global and per-object metadata against the real Python builder' do
|
|
110
|
+
%i[global object].each do |scope|
|
|
111
|
+
%i[hash hash_list].each do |layout|
|
|
112
|
+
[true, false].each do |required|
|
|
113
|
+
action = dynamic_action.merge(input: { layout:, namespace: :widget, parameters: {} }, meta: {
|
|
114
|
+
scope => { input: { layout: :hash, namespace: false, parameters: { confirm: { type: 'Boolean', required: } } } }
|
|
115
|
+
})
|
|
116
|
+
params = layout == :hash ? {} : [{}]
|
|
117
|
+
native = python_request(action, params)
|
|
118
|
+
if required
|
|
119
|
+
expect(native).to include('error' => 'ValidationError', 'message' => 'input parameters are not valid')
|
|
120
|
+
else
|
|
121
|
+
expect(native).not_to have_key('error')
|
|
122
|
+
end
|
|
123
|
+
[true, false].each do |status|
|
|
124
|
+
renderers.each do |renderer|
|
|
125
|
+
result = renderer.render(:example, context: dynamic_context(action:, sample: dynamic_sample.merge(request: params, status:)))
|
|
126
|
+
expect(result[:status]).to eq(required ? :limitation : :code), result.inspect
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|