haveapi 0.29.12 → 0.30.0

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