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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Rakefile +1 -0
  4. data/doc/client-examples.md +198 -0
  5. data/haveapi.gemspec +1 -1
  6. data/lib/haveapi/client_example.rb +47 -1
  7. data/lib/haveapi/client_examples/dynamic_helpers.rb +129 -0
  8. data/lib/haveapi/client_examples/go_client.rb +37 -0
  9. data/lib/haveapi/client_examples/js_async_client.rb +14 -0
  10. data/lib/haveapi/client_examples/js_client.rb +179 -135
  11. data/lib/haveapi/client_examples/python_async_client.rb +14 -0
  12. data/lib/haveapi/client_examples/python_client.rb +172 -0
  13. data/lib/haveapi/client_examples/rust_client.rb +59 -0
  14. data/lib/haveapi/client_examples/ts_async_client.rb +14 -0
  15. data/lib/haveapi/client_examples/ts_client.rb +14 -0
  16. data/lib/haveapi/documentation_clients.rb +249 -0
  17. data/lib/haveapi/documentation_context.rb +47 -0
  18. data/lib/haveapi/documentation_schema.rb +73 -0
  19. data/lib/haveapi/public/js/highlight-LICENSE +29 -0
  20. data/lib/haveapi/public/js/highlight.pack.js +2 -2
  21. data/lib/haveapi/public/js/highlighter.js +16 -6
  22. data/lib/haveapi/public/js/main.js +9 -1
  23. data/lib/haveapi/public/js/nojs-tabs.js +37 -3
  24. data/lib/haveapi/server.rb +39 -0
  25. data/lib/haveapi/version.rb +1 -1
  26. data/lib/haveapi/views/main_layout.erb +11 -0
  27. data/lib/haveapi/views/version_page/auth_body.erb +3 -3
  28. data/lib/haveapi/views/version_page/client_auth.erb +2 -2
  29. data/lib/haveapi/views/version_page/client_example.erb +2 -11
  30. data/lib/haveapi/views/version_page/client_init.erb +2 -2
  31. data/lib/haveapi/views/version_page/client_result.erb +5 -0
  32. data/lib/haveapi/views/version_page/resource_body.erb +3 -7
  33. data/lib/haveapi/views/version_page.erb +13 -9
  34. data/spec/client_examples/assets/highlighter_test.cjs +26 -0
  35. data/spec/client_examples/assets/tabs_test.cjs +54 -0
  36. data/spec/client_examples/context_spec.rb +68 -0
  37. data/spec/client_examples/dynamic_acceptance.md +83 -0
  38. data/spec/client_examples/dynamic_acceptance.rb +151 -0
  39. data/spec/client_examples/dynamic_acceptance_spec.rb +76 -0
  40. data/spec/client_examples/dynamic_fixture.rb +36 -0
  41. data/spec/client_examples/dynamic_js_spec.rb +196 -0
  42. data/spec/client_examples/dynamic_metadata_spec.rb +52 -0
  43. data/spec/client_examples/dynamic_mounted_paths_spec.rb +65 -0
  44. data/spec/client_examples/dynamic_python_spec.rb +123 -0
  45. data/spec/client_examples/go_spec.rb +57 -0
  46. data/spec/client_examples/profile_mount_spec.rb +215 -0
  47. data/spec/client_examples/profiles_spec.rb +265 -0
  48. data/spec/client_examples/rust_client_spec.rb +75 -0
  49. data/spec/client_examples/structural_schema_spec.rb +48 -0
  50. data/spec/client_examples/syntax_spec.rb +2 -1
  51. data/spec/documentation/auth_filtering_spec.rb +12 -0
  52. data/spec/documentation/client_examples_spec.rb +92 -0
  53. data/spec/packaging_spec.rb +13 -4
  54. data/test_support/dynamic_documentation_api.rb +171 -0
  55. data/test_support/dynamic_protocol_spec.rb +133 -0
  56. data/test_support/dynamic_python_login_spec.rb +75 -0
  57. metadata +37 -4
  58. 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('&quot;')
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