haveapi 0.29.12 → 0.30.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 +1 -0
- data/Rakefile +1 -0
- data/doc/client-examples.md +96 -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 +126 -0
- data/lib/haveapi/documentation_context.rb +47 -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 +34 -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 +195 -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/profiles_spec.rb +95 -0
- data/spec/client_examples/rust_client_spec.rb +75 -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 +82 -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 +34 -4
- data/lib/haveapi/client_examples/fs_client.rb +0 -119
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'rack/mock'
|
|
3
|
+
require_relative 'dynamic_acceptance'
|
|
4
|
+
|
|
5
|
+
RSpec.describe DynamicDocumentationAcceptance do
|
|
6
|
+
it 'extracts actual six-language HTML panels from the mounted fixture' do
|
|
7
|
+
request = Rack::MockRequest.new(DynamicDocumentationAPI.build.app)
|
|
8
|
+
response = request.get('http://example.test/mount/v2/', 'HTTP_HOST' => 'example.test')
|
|
9
|
+
expect(response.status).to eq(200), response.body
|
|
10
|
+
panels = described_class.extract(response.body)
|
|
11
|
+
described_class::VARIANTS.each do |variant|
|
|
12
|
+
selected = panels.select { |panel| panel[:variant] == variant }
|
|
13
|
+
expect(selected.map { |panel| panel[:id].split('-').first }).to include('init', 'auth', 'example')
|
|
14
|
+
expect(selected.count { |panel| panel[:id].start_with?('example-') }).to eq(2)
|
|
15
|
+
expect(selected.last[:source]).to include('http://example.test/mount/')
|
|
16
|
+
expect(selected.last[:source]).not_to include('"')
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'records real success and failure request values without source rewriting' do
|
|
21
|
+
request = Rack::MockRequest.new(DynamicDocumentationAPI.build.app)
|
|
22
|
+
payload = { widget: { name: '', enabled: false, count: 0, nullable: nil, payload: DynamicDocumentationAPI::PAYLOAD } }
|
|
23
|
+
headers = {
|
|
24
|
+
'CONTENT_TYPE' => 'application/json',
|
|
25
|
+
'HTTP_AUTHORIZATION' => "Basic #{['fixture-user:fixture-password'].pack('m0')}",
|
|
26
|
+
input: JSON.generate(payload)
|
|
27
|
+
}
|
|
28
|
+
path = 'http://example.test/mount/v2/parents/7/children/name%20with%20space'
|
|
29
|
+
response = request.post(path, headers)
|
|
30
|
+
expect(response.status).to eq(200), response.body
|
|
31
|
+
expect(JSON.parse(response.body).fetch('status')).to be(true)
|
|
32
|
+
expect(DynamicDocumentationAPI.receipts.last[:input][:payload]).to eq(DynamicDocumentationAPI::PAYLOAD)
|
|
33
|
+
payload[:widget][:count] = -1
|
|
34
|
+
response = request.post(path, headers.merge(input: JSON.generate(payload)))
|
|
35
|
+
expect(response.status).to eq(400), response.body
|
|
36
|
+
expect(JSON.parse(response.body).fetch('message')).to eq('DOC_FAILURE')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'preserves subprocess output before reporting a nonzero execution failure' do
|
|
40
|
+
Dir.mktmpdir('haveapi-doc-output-') do |directory|
|
|
41
|
+
path = File.join(directory, 'example-12.log')
|
|
42
|
+
expect do
|
|
43
|
+
described_class.run!('ruby', '-e', 'warn "Discovery or authentication failed"; exit 1', chdir: directory, log_path: path)
|
|
44
|
+
end.to raise_error(RuntimeError, /Discovery or authentication failed/)
|
|
45
|
+
expect(File.read(path)).to include('Discovery or authentication failed')
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'preserves separate existing-token and challenge output before completion assertions' do
|
|
50
|
+
Dir.mktmpdir('haveapi-doc-output-') do |directory|
|
|
51
|
+
command = ['ruby', '-e', 'puts ENV.key?("HAVEAPI_TOKEN") ? "Authenticated" : "Discovery or authentication failed"']
|
|
52
|
+
path = File.join(directory, 'example-12.log')
|
|
53
|
+
challenge_path = File.join(directory, 'example-12-challenge.log')
|
|
54
|
+
result = described_class.run!(*command, chdir: directory, env: { 'HAVEAPI_TOKEN' => 'fixture-token' }, log_path: path)
|
|
55
|
+
challenge = described_class.run!(*command, chdir: directory, env: { 'HAVEAPI_TOKEN' => nil }, log_path: challenge_path)
|
|
56
|
+
expect(File.read(path)).to eq(result)
|
|
57
|
+
expect(File.read(challenge_path)).to eq(challenge)
|
|
58
|
+
expect(result).to include('Authenticated')
|
|
59
|
+
expect(challenge).to include('Discovery or authentication failed')
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'traces failed mounted routes without headers, query strings or request bodies' do
|
|
64
|
+
output = StringIO.new
|
|
65
|
+
app = DynamicDocumentationAPI::RequestTrace.new(DynamicDocumentationAPI.build.app, output:)
|
|
66
|
+
request = Rack::MockRequest.new(app)
|
|
67
|
+
response = request.post('http://example.test/mount/mount/_auth/token/tokens?credential=query-secret',
|
|
68
|
+
'HTTP_AUTHORIZATION' => 'Bearer header-secret', 'CONTENT_TYPE' => 'application/json',
|
|
69
|
+
input: JSON.generate({ password: 'body-secret' }))
|
|
70
|
+
expect(response.status).to eq(404)
|
|
71
|
+
prefix = 'DYNAMIC_DOCUMENTATION_REQUEST '
|
|
72
|
+
trace = JSON.parse(output.string.delete_prefix(prefix))
|
|
73
|
+
expect(trace).to eq('method' => 'POST', 'path' => '/mount/mount/_auth/token/tokens', 'status' => 404)
|
|
74
|
+
expect(output.string).not_to include('secret', 'credential', 'password', 'Bearer')
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module DynamicExampleFixture
|
|
2
|
+
def dynamic_action
|
|
3
|
+
{
|
|
4
|
+
method: 'POST', path: '/mount/v2/parents/{parent_id}/widgets/{widget_id}',
|
|
5
|
+
input: { layout: :hash, namespace: :widget,
|
|
6
|
+
parameters: { name: { type: 'String' }, enabled: { type: 'Boolean' }, count: { type: 'Integer' },
|
|
7
|
+
nullable: { type: 'String', nullable: true }, metadata: { type: 'Custom' } } },
|
|
8
|
+
output: { layout: :hash, namespace: :widget, parameters: { id: { type: 'Integer' } } }
|
|
9
|
+
}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def dynamic_sample
|
|
13
|
+
{
|
|
14
|
+
path_params: [7, 'name/with space'],
|
|
15
|
+
request: { name: "Unicode žluťoučký \"quote\"\\\n</script>", enabled: false, count: 0,
|
|
16
|
+
nullable: nil, metadata: { '__proto__' => { 'constructor' => [false, 0, nil, ''] } } },
|
|
17
|
+
response: { id: 0 }, status: true
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def dynamic_context(**changes)
|
|
22
|
+
HaveAPI::ClientExample::Context.new(
|
|
23
|
+
version: '2', host: 'api.example.test', base_url: 'https://api.example.test/mount/',
|
|
24
|
+
version_url: 'https://api.example.test/mount/v2/',
|
|
25
|
+
description: { authentication: { basic: {}, token: {}, oauth2: {} }, resources: {} },
|
|
26
|
+
resource_path: %w[constructor resources], resource: {}, action_name: "new\"\n</script>",
|
|
27
|
+
action: dynamic_action, sample: dynamic_sample, **changes
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def token_description
|
|
32
|
+
{ resources: { token: { actions: { 'request' => { input: { parameters: {
|
|
33
|
+
'login-name' => { type: 'String' }, password: { type: 'String' }, lifetime: { type: 'String' }
|
|
34
|
+
} } } } } } }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require_relative 'dynamic_fixture'
|
|
5
|
+
|
|
6
|
+
RSpec.describe HaveAPI::ClientExamples::JsClient do
|
|
7
|
+
include DynamicExampleFixture
|
|
8
|
+
|
|
9
|
+
let(:renderers) do
|
|
10
|
+
[described_class, HaveAPI::ClientExamples::JsAsyncClient,
|
|
11
|
+
HaveAPI::ClientExamples::TsClient, HaveAPI::ClientExamples::TsAsyncClient]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def syntax(renderer, source)
|
|
15
|
+
if renderer.code == :typescript
|
|
16
|
+
compiler = File.expand_path('../../../../clients/js/node_modules/typescript', __dir__)
|
|
17
|
+
script = <<~'JS'
|
|
18
|
+
const ts = require(process.argv[1]);
|
|
19
|
+
const source = require('fs').readFileSync(0, 'utf8');
|
|
20
|
+
const result = ts.transpileModule(source, {reportDiagnostics: true, compilerOptions: {target: ts.ScriptTarget.ES2020}});
|
|
21
|
+
for (const diagnostic of result.diagnostics || []) console.error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'));
|
|
22
|
+
process.exit((result.diagnostics || []).length ? 1 : 0);
|
|
23
|
+
JS
|
|
24
|
+
Open3.capture2e('node', '-e', script, compiler, stdin_data: source)
|
|
25
|
+
else
|
|
26
|
+
Tempfile.create(['haveapi-doc-js-', '.mjs']) do |file|
|
|
27
|
+
file.write(source)
|
|
28
|
+
file.flush
|
|
29
|
+
Open3.capture2e('node', '--check', file.path)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'keeps all four stable dynamic variants available without profiles' do
|
|
35
|
+
expect(renderers.map(&:id)).to eq(%w[javascript-callback javascript-async typescript-callback typescript-async])
|
|
36
|
+
expect(renderers.map(&:order)).to eq([10, 11, 12, 13])
|
|
37
|
+
expect(renderers).to all(be_available(dynamic_context))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'renders complete syntax-valid initialization, authentication and hostile action programs' do
|
|
41
|
+
renderers.each do |renderer|
|
|
42
|
+
results = [renderer.render(:init, context: dynamic_context), renderer.render(:example, context: dynamic_context)]
|
|
43
|
+
%i[basic token oauth2].each do |method|
|
|
44
|
+
results << renderer.render(:auth, context: dynamic_context, auth_method: method, auth_description: token_description)
|
|
45
|
+
end
|
|
46
|
+
results.each do |result|
|
|
47
|
+
expect(result[:status]).to eq(:code), result.inspect
|
|
48
|
+
expect(result).to be_frozen
|
|
49
|
+
expect(result[:code]).to include('https://api.example.test/mount/', 'version: "2"')
|
|
50
|
+
output, status = syntax(renderer, result[:code])
|
|
51
|
+
expect(status).to be_success, output
|
|
52
|
+
expect(result[:code]).not_to match(/\bas any\b|\bas unknown\b|\._private|api\.constructor/)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'uses exact public lookups and retains false, zero, null and hostile Custom keys' do
|
|
58
|
+
renderers.each do |renderer|
|
|
59
|
+
source = renderer.render(:example, context: dynamic_context).fetch(:code)
|
|
60
|
+
expect(source).to include('["enabled"]: false', '["count"]: 0', '["nullable"]: null', '["__proto__"]:')
|
|
61
|
+
expect(source).to include('new\\"\\n</script>')
|
|
62
|
+
if renderer.id.end_with?('-async')
|
|
63
|
+
expect(source).to include('.resource("constructor").resource("resources").action(', 'result.value', 'result.response.meta')
|
|
64
|
+
else
|
|
65
|
+
expect(source).to include('CallbackClient.findResource', 'CallbackClient.findAction', 'action.directInvoke', 'reply.message()', 'reply.envelope.errors')
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'checks TypeScript examples against the real emitted public declarations with strict types' do
|
|
71
|
+
root = File.expand_path('../../../..', __dir__)
|
|
72
|
+
Dir.mktmpdir('haveapi-ts-docs-') do |directory|
|
|
73
|
+
files = []
|
|
74
|
+
[HaveAPI::ClientExamples::TsClient, HaveAPI::ClientExamples::TsAsyncClient].each_with_index do |renderer, index|
|
|
75
|
+
results = [renderer.render(:init, context: dynamic_context), renderer.render(:example, context: dynamic_context)]
|
|
76
|
+
%i[basic token oauth2].each do |method|
|
|
77
|
+
results << renderer.render(:auth, context: dynamic_context, auth_method: method, auth_description: token_description)
|
|
78
|
+
end
|
|
79
|
+
results.each_with_index do |result, number|
|
|
80
|
+
path = File.join(directory, "#{index}-#{number}.ts")
|
|
81
|
+
File.write(path, result.fetch(:code))
|
|
82
|
+
files << path
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
options = { strict: true, noEmit: true, target: 'ES2020', module: 'ESNext', moduleResolution: 'node',
|
|
86
|
+
typeRoots: [File.join(root, 'clients/js/node_modules/@types')], types: ['node'],
|
|
87
|
+
baseUrl: directory, paths: { 'haveapi-client' => [File.join(root, 'clients/js/dist/types/haveapi')] } }
|
|
88
|
+
config = File.join(directory, 'tsconfig.json')
|
|
89
|
+
File.write(config, JSON.generate({ compilerOptions: options, files: }))
|
|
90
|
+
output, status = Open3.capture2e('node', File.join(root, 'clients/js/node_modules/typescript/bin/tsc'), '-p', config)
|
|
91
|
+
expect(status).to be_success, output
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'never drops list input, unknown fields or unresolved path arguments' do
|
|
96
|
+
invalid = [
|
|
97
|
+
dynamic_context(sample: dynamic_sample.merge(request: [{ name: 'row' }])),
|
|
98
|
+
dynamic_context(sample: dynamic_sample.merge(request: { hidden: 'not visible' })),
|
|
99
|
+
dynamic_context(sample: dynamic_sample.merge(path_params: [7])),
|
|
100
|
+
dynamic_context(sample: dynamic_sample.merge(request: { count: 9_007_199_254_740_992 })),
|
|
101
|
+
dynamic_context(sample: { request: nil, response: nil, comment: 'prose only' })
|
|
102
|
+
]
|
|
103
|
+
renderers.each do |renderer|
|
|
104
|
+
invalid.each do |context|
|
|
105
|
+
result = renderer.render(:example, context:)
|
|
106
|
+
expect(result[:status]).to eq(:limitation)
|
|
107
|
+
expect(result[:reason]).not_to be_empty
|
|
108
|
+
expect(result).not_to have_key(:code)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'limits unsupported or unadvertised authentication instead of inventing a flow' do
|
|
114
|
+
renderers.each do |renderer|
|
|
115
|
+
result = renderer.render(:auth, context: dynamic_context(description: { authentication: {} }), auth_method: :basic, auth_description: {})
|
|
116
|
+
expect(result[:status]).to eq(:limitation)
|
|
117
|
+
result = renderer.render(:auth, context: dynamic_context, auth_method: :custom, auth_description: {})
|
|
118
|
+
expect(result[:status]).to eq(:limitation)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'uses actual operation completion support only for the async client' do
|
|
123
|
+
renderers.each do |renderer|
|
|
124
|
+
source = renderer.render(:example, context: dynamic_context(action: dynamic_action.merge(blocking: true))).fetch(:code)
|
|
125
|
+
if renderer.id.end_with?('-async')
|
|
126
|
+
expect(source).to include('await api.waitForCompletion(result, {timeoutMs: 300000})')
|
|
127
|
+
else
|
|
128
|
+
expect(source).not_to include('waitForCompletion(')
|
|
129
|
+
expect(source).to include('initial response')
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'limits nested GET and OPTIONS query input while preserving scalar queries and HEAD bodies' do
|
|
135
|
+
renderers.each do |renderer|
|
|
136
|
+
%w[GET OPTIONS HEAD].each do |method|
|
|
137
|
+
action = dynamic_action.merge(method:)
|
|
138
|
+
result = renderer.render(:example, context: dynamic_context(action:))
|
|
139
|
+
expect(result[:status]).to eq(method == 'HEAD' ? :code : :limitation)
|
|
140
|
+
params = { name: '', enabled: false, count: 0, nullable: nil }
|
|
141
|
+
scalar = renderer.render(:example, context: dynamic_context(action:, sample: dynamic_sample.merge(request: params)))
|
|
142
|
+
expect(scalar[:status]).to eq(:code), scalar.inspect
|
|
143
|
+
expect(scalar[:code]).to include('["enabled"]: false', '["count"]: 0', '["nullable"]: null')
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'cross-checks query loss and HEAD bodies against both native JavaScript clients' do
|
|
149
|
+
helper = File.expand_path('../../../../clients/js/test/helpers/async_bundle.cjs', __dir__)
|
|
150
|
+
script = <<~JS
|
|
151
|
+
const assert = require('node:assert/strict');
|
|
152
|
+
const {harness, action, description} = require(process.argv[1]);
|
|
153
|
+
async function check() {
|
|
154
|
+
for (const method of ['GET', 'OPTIONS', 'HEAD']) {
|
|
155
|
+
for (const params of [{payload: {enabled: false, count: 0}}, {enabled: false, count: 0, nullable: null}]) {
|
|
156
|
+
const {HaveAPI, requests} = harness();
|
|
157
|
+
const callback = new HaveAPI.CallbackClient('https://api.example.test');
|
|
158
|
+
const model = description();
|
|
159
|
+
model.resources.widgets.actions.validate = action({payload: {type: 'Custom'}, enabled: {type: 'Boolean'},
|
|
160
|
+
count: {type: 'Integer'}, nullable: {type: 'String', nullable: true}}, {method});
|
|
161
|
+
callback.setup((api, ready) => {
|
|
162
|
+
assert.equal(ready, true);
|
|
163
|
+
const resource = HaveAPI.CallbackClient.findResource(api, ['widgets']);
|
|
164
|
+
HaveAPI.CallbackClient.findAction(resource, 'validate').directInvoke({params});
|
|
165
|
+
});
|
|
166
|
+
requests[0].respond({status: true, version: '2.0', response: model});
|
|
167
|
+
const asyncClient = new HaveAPI.AsyncClient('https://api.example.test');
|
|
168
|
+
const pending = asyncClient.request({method, path: '/v1/validate', inputNamespace: 'widget', params});
|
|
169
|
+
assert.equal(callback.sendAsQueryParams(method), method !== 'HEAD');
|
|
170
|
+
for (const wire of requests.slice(1)) {
|
|
171
|
+
if (method === 'HEAD') {
|
|
172
|
+
assert.deepEqual(JSON.parse(wire.body), {widget: params});
|
|
173
|
+
assert.equal(new URL(wire.url).search, '');
|
|
174
|
+
} else {
|
|
175
|
+
const query = new URL(wire.url).searchParams;
|
|
176
|
+
assert.equal(wire.body, undefined);
|
|
177
|
+
if (params.payload) assert.equal(query.get('widget[payload]'), '[object Object]');
|
|
178
|
+
else {
|
|
179
|
+
assert.equal(query.get('widget[enabled]'), 'false');
|
|
180
|
+
assert.equal(query.get('widget[count]'), '0');
|
|
181
|
+
assert.equal(query.get('widget[nullable]'), '');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
wire.respond({status: true, response: {}});
|
|
185
|
+
}
|
|
186
|
+
await pending;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
check().catch(error => { console.error(error); process.exitCode = 1; });
|
|
191
|
+
JS
|
|
192
|
+
output, status = Open3.capture2e('node', '-e', script, helper)
|
|
193
|
+
expect(status).to be_success, output
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require_relative 'dynamic_fixture'
|
|
3
|
+
|
|
4
|
+
RSpec.describe HaveAPI::ClientExamples::DynamicHelpers do
|
|
5
|
+
include DynamicExampleFixture
|
|
6
|
+
|
|
7
|
+
let(:renderers) do
|
|
8
|
+
[HaveAPI::ClientExamples::JsClient, HaveAPI::ClientExamples::JsAsyncClient,
|
|
9
|
+
HaveAPI::ClientExamples::TsClient, HaveAPI::ClientExamples::TsAsyncClient,
|
|
10
|
+
HaveAPI::ClientExamples::PythonClient, HaveAPI::ClientExamples::PythonAsyncClient]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'limits all sample outcomes that need required global or object input metadata' do
|
|
14
|
+
%i[global object].each do |scope|
|
|
15
|
+
metadata = { scope => { input: { layout: :hash, namespace: false,
|
|
16
|
+
parameters: { private_confirmation: { type: 'Boolean', required: true, default: false } } } } }
|
|
17
|
+
action = dynamic_action.merge(meta: metadata)
|
|
18
|
+
[true, false].each do |status|
|
|
19
|
+
renderers.each do |renderer|
|
|
20
|
+
result = renderer.render(:example, context: dynamic_context(action:, sample: dynamic_sample.merge(status:)))
|
|
21
|
+
expect(result[:status]).to eq(:limitation)
|
|
22
|
+
expect(result[:reason]).to eq('This action requires input metadata that the sample format cannot supply.')
|
|
23
|
+
expect(result[:reason]).not_to include('private_confirmation', 'default')
|
|
24
|
+
expect(result).not_to have_key(:code)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'keeps absent, optional and output-only metadata executable without invented values' do
|
|
31
|
+
[nil, {}, { global: { input: { parameters: { optional: { type: 'Boolean', required: false } } } } },
|
|
32
|
+
{ object: { input: { parameters: { optional: { type: 'Boolean' } } } } },
|
|
33
|
+
{ global: { output: { parameters: { required_output: { type: 'String', required: true } } } } }].each do |meta|
|
|
34
|
+
renderers.each do |renderer|
|
|
35
|
+
result = renderer.render(:example, context: dynamic_context(action: dynamic_action.merge(meta:)))
|
|
36
|
+
expect(result[:status]).to eq(:code), result.inspect
|
|
37
|
+
expect(result[:code]).not_to include('optional', 'required_output')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'checks string-keyed metadata and does not apply object metadata without action input' do
|
|
43
|
+
action = dynamic_action.merge(meta: { 'object' => { 'input' => { 'parameters' => {
|
|
44
|
+
'confirmation' => { 'type' => 'Boolean', 'required' => true }
|
|
45
|
+
} } } })
|
|
46
|
+
renderers.each do |renderer|
|
|
47
|
+
expect(renderer.render(:example, context: dynamic_context(action:))[:status]).to eq(:limitation)
|
|
48
|
+
context = dynamic_context(action: action.merge(input: nil), sample: dynamic_sample.merge(request: {}))
|
|
49
|
+
expect(renderer.render(:example, context:)[:status]).to eq(:code)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'rack/mock'
|
|
4
|
+
require_relative '../../test_support/dynamic_documentation_api'
|
|
5
|
+
|
|
6
|
+
RSpec.describe DynamicDocumentationAPI do
|
|
7
|
+
it 'uses real mounted descriptor routes for callback and async token challenges and nested actions' do
|
|
8
|
+
request = Rack::MockRequest.new(described_class.build.app)
|
|
9
|
+
headers = { 'HTTP_AUTHORIZATION' => "Basic #{['fixture-user:fixture-password'].pack('m0')}" }
|
|
10
|
+
envelope = JSON.parse(request.options('http://example.test/mount/v2/', headers).body)
|
|
11
|
+
model = envelope.fetch('response')
|
|
12
|
+
token = model.fetch('authentication').fetch('token').fetch('resources').fetch('token').fetch('actions')
|
|
13
|
+
action = model.fetch('resources').fetch('constructor').fetch('resources').fetch('resources').fetch('actions').fetch('new')
|
|
14
|
+
expect(token.fetch('request').fetch('path')).to eq('/mount/_auth/token/tokens')
|
|
15
|
+
expect(action.fetch('path')).to eq('/mount/v2/parents/{parent_id}/children/{widget_id}')
|
|
16
|
+
params = action.fetch('examples').first.fetch('request')
|
|
17
|
+
post = lambda do |path, body, extra = {}|
|
|
18
|
+
response = request.post("http://example.test#{path}", extra.merge('CONTENT_TYPE' => 'application/json', input: JSON.generate(body)))
|
|
19
|
+
expect(response.status).to eq(200), response.body
|
|
20
|
+
JSON.parse(response.body)
|
|
21
|
+
end
|
|
22
|
+
challenge = post.call(token.fetch('request').fetch('path'), { token: {
|
|
23
|
+
user: 'fixture-user', password: 'fixture-password', scope: 'all', lifetime: 'renewable_auto'
|
|
24
|
+
} })
|
|
25
|
+
completed = post.call(token.fetch('verify').fetch('path'), { token: { token: 'fixture-challenge', code: '123456' } })
|
|
26
|
+
action_path = action.fetch('path').sub('{parent_id}', '7').sub('{widget_id}', 'name%20with%20space')
|
|
27
|
+
result = post.call(action_path, { widget: params }, { 'HTTP_X_HAVEAPI_AUTH_TOKEN' => 'fixture-token' })
|
|
28
|
+
data = { envelope:, challenge:, completed:, result:, params:, action_path: }
|
|
29
|
+
script = <<~JS
|
|
30
|
+
const assert = require('node:assert/strict');
|
|
31
|
+
const {harness, tick} = require(process.argv[1]);
|
|
32
|
+
const {HaveAPI, requests} = harness();
|
|
33
|
+
const data = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));
|
|
34
|
+
const model = data.envelope.response;
|
|
35
|
+
const auth = model.authentication.token.resources.token.actions;
|
|
36
|
+
async function check() {
|
|
37
|
+
const callback = new HaveAPI.CallbackClient('http://example.test/mount/', {version:'2'});
|
|
38
|
+
let ready = false;
|
|
39
|
+
callback.authenticate('token', {user:'fixture-user', password:'fixture-password', scope:'all', callback: () => ({code:'123456'})},
|
|
40
|
+
(_, ok) => { ready = ok; });
|
|
41
|
+
// Parse actual discovery JSON inside the bundle's VM, matching the real XHR boundary.
|
|
42
|
+
assert.equal(requests[0].url, 'http://example.test/mount/v2/'); requests[0].respond(data.envelope);
|
|
43
|
+
assert.equal(requests[1].url, 'http://example.test' + auth.request.path); requests[1].respond(data.challenge);
|
|
44
|
+
assert.equal(requests[2].url, 'http://example.test' + auth.verify.path); requests[2].respond(data.completed);
|
|
45
|
+
assert.equal(requests[3].url, 'http://example.test/mount/v2/'); requests[3].respond(data.envelope); assert.equal(ready, true);
|
|
46
|
+
const resource = HaveAPI.CallbackClient.findResource(callback, ['constructor','resources']);
|
|
47
|
+
HaveAPI.CallbackClient.findAction(resource, 'new').directInvoke(7, 'name with space', {params:data.params});
|
|
48
|
+
assert.equal(requests[4].url, 'http://example.test' + data.action_path); requests[4].respond(data.result);
|
|
49
|
+
const api = new HaveAPI.AsyncClient('http://example.test/mount/', {version:'2'}); api.useDescription(model);
|
|
50
|
+
const authenticating = api.authenticate('token', {user:'fixture-user', password:'fixture-password', scope:'all', onChallenge: () => ({code:'123456'})});
|
|
51
|
+
await tick(); assert.equal(requests[5].url, 'http://example.test' + auth.request.path); requests[5].respond(data.challenge);
|
|
52
|
+
await tick(); assert.equal(requests[6].url, 'http://example.test' + auth.verify.path); requests[6].respond(data.completed);
|
|
53
|
+
await tick(); assert.equal(requests[7].url, 'http://example.test/mount/v2/'); requests[7].respond(data.envelope); await authenticating;
|
|
54
|
+
const invoked = api.resource('constructor').resource('resources').action('new')(7, 'name with space', {params:data.params});
|
|
55
|
+
assert.equal(requests[8].url, 'http://example.test' + data.action_path); requests[8].respond(data.result);
|
|
56
|
+
assert.equal((await invoked).value.marker, 'DOC_SUCCESS');
|
|
57
|
+
assert.equal(requests.length, 9);
|
|
58
|
+
}
|
|
59
|
+
check().catch(error => {console.error(error); process.exitCode = 1;});
|
|
60
|
+
JS
|
|
61
|
+
helper = File.expand_path('../../../../clients/js/test/helpers/async_bundle.cjs', __dir__)
|
|
62
|
+
output, status = Open3.capture2e('node', '-e', script, helper, stdin_data: JSON.generate(data))
|
|
63
|
+
expect(status).to be_success, output
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -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
|