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,151 @@
|
|
|
1
|
+
# Run after committed-deliverable review, with built npm and Python artifacts.
|
|
2
|
+
# The only code executed below comes from the real server's rendered HTML.
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
require 'open3'
|
|
8
|
+
require 'tmpdir'
|
|
9
|
+
require 'timeout'
|
|
10
|
+
require_relative '../../test_support/dynamic_documentation_api'
|
|
11
|
+
|
|
12
|
+
module DynamicDocumentationAcceptance
|
|
13
|
+
VARIANTS = %w[javascript-callback javascript-async typescript-callback typescript-async python-sync python-async].freeze
|
|
14
|
+
|
|
15
|
+
def self.run!(*command, chdir:, env: {}, log_path: nil)
|
|
16
|
+
output, status = Open3.capture2e(env, *command, chdir:)
|
|
17
|
+
File.write(log_path, output) if log_path
|
|
18
|
+
raise "#{command.first} failed (#{status.exitstatus}):\n#{output}" unless status.success?
|
|
19
|
+
|
|
20
|
+
output
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.request(url)
|
|
24
|
+
uri = URI(url)
|
|
25
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 10, read_timeout: 30) { |http| http.get(uri.request_uri) }
|
|
26
|
+
raise "Fixture HTTP #{response.code}: #{response.body[0, 200]}" unless response.is_a?(Net::HTTPSuccess)
|
|
27
|
+
|
|
28
|
+
response.body
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.extract(html)
|
|
32
|
+
html.scan(%r{<div id="([^"]+)">\s*<h[46]>[^<]*</h[46]>\s*<pre><code class="([^"]+)">(.*?)</code></pre>}m).filter_map do |id, language, escaped|
|
|
33
|
+
variant = VARIANTS.find { |name| id.end_with?("-#{name}") }
|
|
34
|
+
next unless variant
|
|
35
|
+
|
|
36
|
+
{ id:, variant:, language:, source: CGI.unescapeHTML(escaped) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.run(npm_artifact:, python_artifact:, typescript:, node_types:, output:)
|
|
41
|
+
root = File.expand_path('../../../..', __dir__)
|
|
42
|
+
FileUtils.mkdir_p(output)
|
|
43
|
+
tools = { typescript: File.expand_path(typescript), node_types: File.expand_path(node_types) }
|
|
44
|
+
npm_artifact = File.expand_path(npm_artifact)
|
|
45
|
+
python_artifact = File.expand_path(python_artifact)
|
|
46
|
+
Dir.mktmpdir('haveapi-dynamic-acceptance-') do |consumer|
|
|
47
|
+
File.write(File.join(consumer, 'package.json'), JSON.generate({ private: true, type: 'module' }))
|
|
48
|
+
run!('npm', 'install', '--ignore-scripts', '--no-audit', '--no-fund', npm_artifact, chdir: consumer,
|
|
49
|
+
env: { 'NODE_PATH' => nil })
|
|
50
|
+
run!('uv', 'venv', '--python', '3.11', 'venv', chdir: consumer)
|
|
51
|
+
python = File.join(consumer, 'venv/bin/python')
|
|
52
|
+
run!('uv', 'pip', 'install', '--python', python, python_artifact, chdir: consumer)
|
|
53
|
+
server_script = File.join(root, 'servers/ruby/test_support/dynamic_documentation_api.rb')
|
|
54
|
+
Open3.popen2e('ruby', server_script, chdir: File.join(root, 'servers/ruby')) do |stdin, server_output, wait|
|
|
55
|
+
stdin.close
|
|
56
|
+
reader = nil
|
|
57
|
+
begin
|
|
58
|
+
base = Timeout.timeout(30) do
|
|
59
|
+
loop do
|
|
60
|
+
line = server_output.gets
|
|
61
|
+
raise 'Documentation fixture exited before readiness' unless line
|
|
62
|
+
break line.split.last if line.start_with?('DYNAMIC_DOCUMENTATION_READY ')
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
reader = Thread.new { File.write(File.join(output, 'fixture.log'), server_output.read) }
|
|
66
|
+
html = request("#{base}/mount/v2/")
|
|
67
|
+
File.write(File.join(output, 'documentation.html'), html)
|
|
68
|
+
panels = extract(html)
|
|
69
|
+
raise 'No actual HTML code panels extracted' if panels.empty?
|
|
70
|
+
|
|
71
|
+
VARIANTS.each do |variant|
|
|
72
|
+
selected = panels.select { |panel| panel[:variant] == variant }
|
|
73
|
+
raise "Incomplete rendered coverage for #{variant}" unless selected.length >= 4 &&
|
|
74
|
+
selected.any? { |panel| panel[:id].start_with?('init-') } &&
|
|
75
|
+
selected.any? { |panel| panel[:id].start_with?('auth-basic-') } &&
|
|
76
|
+
selected.count { |panel| panel[:id].start_with?('example-') } >= 2
|
|
77
|
+
end
|
|
78
|
+
panels.each_with_index do |panel, index|
|
|
79
|
+
extension = { 'typescript' => '.ts', 'javascript' => '.mjs', 'python' => '.py' }.fetch(panel[:language])
|
|
80
|
+
name = "example-#{index}#{extension}"
|
|
81
|
+
path = File.join(consumer, name)
|
|
82
|
+
File.write(path, panel[:source])
|
|
83
|
+
File.write(File.join(output, name), panel[:source])
|
|
84
|
+
env = { 'NODE_PATH' => nil, 'PYTHONPATH' => nil, 'PYTHONHOME' => nil,
|
|
85
|
+
'HAVEAPI_USER' => 'fixture-user', 'HAVEAPI_PASSWORD' => 'fixture-password',
|
|
86
|
+
'HAVEAPI_TOKEN' => 'fixture-token', 'HAVEAPI_ACCESS_TOKEN' => 'fixture-access-token',
|
|
87
|
+
'HAVEAPI_AUTH_user' => JSON.generate('fixture-user'),
|
|
88
|
+
'HAVEAPI_AUTH_password' => JSON.generate('fixture-password'),
|
|
89
|
+
'HAVEAPI_AUTH_lifetime' => JSON.generate('fixed'),
|
|
90
|
+
'HAVEAPI_AUTH_scope' => JSON.generate('all'), 'HAVEAPI_CHALLENGE_code' => JSON.generate('123456') }
|
|
91
|
+
if extension == '.ts'
|
|
92
|
+
config = {
|
|
93
|
+
compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true,
|
|
94
|
+
types: ['node'], typeRoots: [tools[:node_types]], outDir: 'compiled', skipLibCheck: false },
|
|
95
|
+
files: [name]
|
|
96
|
+
}
|
|
97
|
+
File.write(File.join(consumer, 'tsconfig.json'), JSON.generate(config))
|
|
98
|
+
file_list = run!('node', tools[:typescript], '-p', 'tsconfig.json', '--listFiles', chdir: consumer)
|
|
99
|
+
raise 'Checkout HaveAPI declarations leaked into installed-package check' if file_list.lines.any? do |line|
|
|
100
|
+
line.include?('/clients/js/src/') || (line.include?('haveapi.d.ts') && !line.start_with?(consumer))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
path = File.join(consumer, 'compiled', "example-#{index}.js")
|
|
104
|
+
end
|
|
105
|
+
before = JSON.parse(request("#{base}/__acceptance_receipts"))
|
|
106
|
+
command = panel[:language] == 'python' ? [python, path] : ['node', path]
|
|
107
|
+
result = run!(*command, chdir: consumer, env:, log_path: File.join(output, "example-#{index}.log"))
|
|
108
|
+
if panel[:id].start_with?('auth-token-')
|
|
109
|
+
challenge_result = run!(*command, chdir: consumer, env: env.merge('HAVEAPI_TOKEN' => nil),
|
|
110
|
+
log_path: File.join(output, "example-#{index}-challenge.log"))
|
|
111
|
+
raise "Token challenge did not complete: #{panel[:id]}\n#{challenge_result}" unless challenge_result.include?('Authenticated')
|
|
112
|
+
end
|
|
113
|
+
after = JSON.parse(request("#{base}/__acceptance_receipts"))
|
|
114
|
+
if panel[:id].start_with?('example-')
|
|
115
|
+
raise "Rendered action did not execute: #{panel[:id]}\n#{result}" unless after.length == before.length + 1
|
|
116
|
+
|
|
117
|
+
receipt = after.last
|
|
118
|
+
expected = { 'name' => '', 'enabled' => false, 'nullable' => nil, 'payload' => DynamicDocumentationAPI::PAYLOAD }
|
|
119
|
+
raise "Changed request values: #{receipt.inspect}" unless receipt['input'].except('count') == expected
|
|
120
|
+
raise 'Changed mounted path arguments' unless receipt['path'].map(&:to_s) == ['7', 'name with space']
|
|
121
|
+
|
|
122
|
+
marker = receipt['input']['count'] < 0 ? 'DOC_FAILURE' : 'DOC_SUCCESS'
|
|
123
|
+
raise "Wrong result/error handling: #{panel[:id]}\n#{result}" unless result.include?(marker)
|
|
124
|
+
else
|
|
125
|
+
marker = panel[:id].start_with?('init-') ? 'Discovery complete' : 'Authenticated'
|
|
126
|
+
raise "Discovery/authentication did not complete: #{panel[:id]}\n#{result}" unless result.include?(marker)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
File.write(File.join(output, 'result.json'), JSON.pretty_generate({ panels: panels.length, variants: VARIANTS, status: 'passed' }))
|
|
130
|
+
ensure
|
|
131
|
+
Process.kill('TERM', wait.pid) if wait.alive?
|
|
132
|
+
wait.value
|
|
133
|
+
reader&.join
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if $0 == __FILE__
|
|
141
|
+
require 'optparse'
|
|
142
|
+
options = {}
|
|
143
|
+
OptionParser.new do |parser|
|
|
144
|
+
%i[npm_artifact python_artifact typescript node_types output].each do |key|
|
|
145
|
+
parser.on("--#{key.to_s.tr('_', '-')} PATH") { |path| options[key] = path }
|
|
146
|
+
end
|
|
147
|
+
end.parse!
|
|
148
|
+
raise 'Supply --npm-artifact, --python-artifact, --typescript, --node-types and --output' unless options.length == 5
|
|
149
|
+
|
|
150
|
+
DynamicDocumentationAcceptance.run(**options)
|
|
151
|
+
end
|
|
@@ -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,196 @@
|
|
|
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
|
+
ignoreDeprecations: '6.0',
|
|
87
|
+
typeRoots: [File.join(root, 'clients/js/node_modules/@types')], types: ['node'],
|
|
88
|
+
baseUrl: directory, paths: { 'haveapi-client' => [File.join(root, 'clients/js/dist/types/haveapi')] } }
|
|
89
|
+
config = File.join(directory, 'tsconfig.json')
|
|
90
|
+
File.write(config, JSON.generate({ compilerOptions: options, files: }))
|
|
91
|
+
output, status = Open3.capture2e('node', File.join(root, 'clients/js/node_modules/typescript/bin/tsc'), '-p', config)
|
|
92
|
+
expect(status).to be_success, output
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'never drops list input, unknown fields or unresolved path arguments' do
|
|
97
|
+
invalid = [
|
|
98
|
+
dynamic_context(sample: dynamic_sample.merge(request: [{ name: 'row' }])),
|
|
99
|
+
dynamic_context(sample: dynamic_sample.merge(request: { hidden: 'not visible' })),
|
|
100
|
+
dynamic_context(sample: dynamic_sample.merge(path_params: [7])),
|
|
101
|
+
dynamic_context(sample: dynamic_sample.merge(request: { count: 9_007_199_254_740_992 })),
|
|
102
|
+
dynamic_context(sample: { request: nil, response: nil, comment: 'prose only' })
|
|
103
|
+
]
|
|
104
|
+
renderers.each do |renderer|
|
|
105
|
+
invalid.each do |context|
|
|
106
|
+
result = renderer.render(:example, context:)
|
|
107
|
+
expect(result[:status]).to eq(:limitation)
|
|
108
|
+
expect(result[:reason]).not_to be_empty
|
|
109
|
+
expect(result).not_to have_key(:code)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'limits unsupported or unadvertised authentication instead of inventing a flow' do
|
|
115
|
+
renderers.each do |renderer|
|
|
116
|
+
result = renderer.render(:auth, context: dynamic_context(description: { authentication: {} }), auth_method: :basic, auth_description: {})
|
|
117
|
+
expect(result[:status]).to eq(:limitation)
|
|
118
|
+
result = renderer.render(:auth, context: dynamic_context, auth_method: :custom, auth_description: {})
|
|
119
|
+
expect(result[:status]).to eq(:limitation)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'uses actual operation completion support only for the async client' do
|
|
124
|
+
renderers.each do |renderer|
|
|
125
|
+
source = renderer.render(:example, context: dynamic_context(action: dynamic_action.merge(blocking: true))).fetch(:code)
|
|
126
|
+
if renderer.id.end_with?('-async')
|
|
127
|
+
expect(source).to include('await api.waitForCompletion(result, {timeoutMs: 300000})')
|
|
128
|
+
else
|
|
129
|
+
expect(source).not_to include('waitForCompletion(')
|
|
130
|
+
expect(source).to include('initial response')
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it 'limits nested GET and OPTIONS query input while preserving scalar queries and HEAD bodies' do
|
|
136
|
+
renderers.each do |renderer|
|
|
137
|
+
%w[GET OPTIONS HEAD].each do |method|
|
|
138
|
+
action = dynamic_action.merge(method:)
|
|
139
|
+
result = renderer.render(:example, context: dynamic_context(action:))
|
|
140
|
+
expect(result[:status]).to eq(method == 'HEAD' ? :code : :limitation)
|
|
141
|
+
params = { name: '', enabled: false, count: 0, nullable: nil }
|
|
142
|
+
scalar = renderer.render(:example, context: dynamic_context(action:, sample: dynamic_sample.merge(request: params)))
|
|
143
|
+
expect(scalar[:status]).to eq(:code), scalar.inspect
|
|
144
|
+
expect(scalar[:code]).to include('["enabled"]: false', '["count"]: 0', '["nullable"]: null')
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'cross-checks query loss and HEAD bodies against both native JavaScript clients' do
|
|
150
|
+
helper = File.expand_path('../../../../clients/js/test/helpers/async_bundle.cjs', __dir__)
|
|
151
|
+
script = <<~JS
|
|
152
|
+
const assert = require('node:assert/strict');
|
|
153
|
+
const {harness, action, description} = require(process.argv[1]);
|
|
154
|
+
async function check() {
|
|
155
|
+
for (const method of ['GET', 'OPTIONS', 'HEAD']) {
|
|
156
|
+
for (const params of [{payload: {enabled: false, count: 0}}, {enabled: false, count: 0, nullable: null}]) {
|
|
157
|
+
const {HaveAPI, requests} = harness();
|
|
158
|
+
const callback = new HaveAPI.CallbackClient('https://api.example.test');
|
|
159
|
+
const model = description();
|
|
160
|
+
model.resources.widgets.actions.validate = action({payload: {type: 'Custom'}, enabled: {type: 'Boolean'},
|
|
161
|
+
count: {type: 'Integer'}, nullable: {type: 'String', nullable: true}}, {method});
|
|
162
|
+
callback.setup((api, ready) => {
|
|
163
|
+
assert.equal(ready, true);
|
|
164
|
+
const resource = HaveAPI.CallbackClient.findResource(api, ['widgets']);
|
|
165
|
+
HaveAPI.CallbackClient.findAction(resource, 'validate').directInvoke({params});
|
|
166
|
+
});
|
|
167
|
+
requests[0].respond({status: true, version: '2.0', response: model});
|
|
168
|
+
const asyncClient = new HaveAPI.AsyncClient('https://api.example.test');
|
|
169
|
+
const pending = asyncClient.request({method, path: '/v1/validate', inputNamespace: 'widget', params});
|
|
170
|
+
assert.equal(callback.sendAsQueryParams(method), method !== 'HEAD');
|
|
171
|
+
for (const wire of requests.slice(1)) {
|
|
172
|
+
if (method === 'HEAD') {
|
|
173
|
+
assert.deepEqual(JSON.parse(wire.body), {widget: params});
|
|
174
|
+
assert.equal(new URL(wire.url).search, '');
|
|
175
|
+
} else {
|
|
176
|
+
const query = new URL(wire.url).searchParams;
|
|
177
|
+
assert.equal(wire.body, undefined);
|
|
178
|
+
if (params.payload) assert.equal(query.get('widget[payload]'), '[object Object]');
|
|
179
|
+
else {
|
|
180
|
+
assert.equal(query.get('widget[enabled]'), 'false');
|
|
181
|
+
assert.equal(query.get('widget[count]'), '0');
|
|
182
|
+
assert.equal(query.get('widget[nullable]'), '');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
wire.respond({status: true, response: {}});
|
|
186
|
+
}
|
|
187
|
+
await pending;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
check().catch(error => { console.error(error); process.exitCode = 1; });
|
|
192
|
+
JS
|
|
193
|
+
output, status = Open3.capture2e('node', '-e', script, helper)
|
|
194
|
+
expect(status).to be_success, output
|
|
195
|
+
end
|
|
196
|
+
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
|