haveapi 0.29.9 → 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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Rakefile +1 -0
  4. data/doc/client-examples.md +96 -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 +126 -0
  17. data/lib/haveapi/documentation_context.rb +47 -0
  18. data/lib/haveapi/public/js/highlight-LICENSE +29 -0
  19. data/lib/haveapi/public/js/highlight.pack.js +2 -2
  20. data/lib/haveapi/public/js/highlighter.js +16 -6
  21. data/lib/haveapi/public/js/main.js +9 -1
  22. data/lib/haveapi/public/js/nojs-tabs.js +37 -3
  23. data/lib/haveapi/server.rb +34 -0
  24. data/lib/haveapi/version.rb +1 -1
  25. data/lib/haveapi/views/main_layout.erb +11 -0
  26. data/lib/haveapi/views/version_page/auth_body.erb +3 -3
  27. data/lib/haveapi/views/version_page/client_auth.erb +2 -2
  28. data/lib/haveapi/views/version_page/client_example.erb +2 -11
  29. data/lib/haveapi/views/version_page/client_init.erb +2 -2
  30. data/lib/haveapi/views/version_page/client_result.erb +5 -0
  31. data/lib/haveapi/views/version_page/resource_body.erb +3 -7
  32. data/lib/haveapi/views/version_page.erb +13 -9
  33. data/spec/client_examples/assets/highlighter_test.cjs +26 -0
  34. data/spec/client_examples/assets/tabs_test.cjs +54 -0
  35. data/spec/client_examples/context_spec.rb +68 -0
  36. data/spec/client_examples/dynamic_acceptance.md +83 -0
  37. data/spec/client_examples/dynamic_acceptance.rb +151 -0
  38. data/spec/client_examples/dynamic_acceptance_spec.rb +76 -0
  39. data/spec/client_examples/dynamic_fixture.rb +36 -0
  40. data/spec/client_examples/dynamic_js_spec.rb +195 -0
  41. data/spec/client_examples/dynamic_metadata_spec.rb +52 -0
  42. data/spec/client_examples/dynamic_mounted_paths_spec.rb +65 -0
  43. data/spec/client_examples/dynamic_python_spec.rb +123 -0
  44. data/spec/client_examples/go_spec.rb +57 -0
  45. data/spec/client_examples/profiles_spec.rb +95 -0
  46. data/spec/client_examples/rust_client_spec.rb +75 -0
  47. data/spec/client_examples/syntax_spec.rb +2 -1
  48. data/spec/documentation/auth_filtering_spec.rb +12 -0
  49. data/spec/documentation/client_examples_spec.rb +82 -0
  50. data/spec/packaging_spec.rb +13 -4
  51. data/test_support/dynamic_documentation_api.rb +171 -0
  52. data/test_support/dynamic_protocol_spec.rb +133 -0
  53. data/test_support/dynamic_python_login_spec.rb +75 -0
  54. metadata +34 -4
  55. 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