haveapi 0.31.1 → 0.32.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/benchmarks/documentation.rb +29 -6
  4. data/benchmarks/endpoints.rb +153 -24
  5. data/benchmarks/spec/client_examples_spec.rb +109 -37
  6. data/doc/client-examples.md +218 -45
  7. data/doc/documentation-oauth.md +157 -0
  8. data/doc/documentation-ui.md +192 -0
  9. data/doc/index.md +3 -6
  10. data/doc/performance.md +39 -12
  11. data/haveapi.gemspec +1 -1
  12. data/lib/haveapi/authentication/chain.rb +19 -0
  13. data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
  14. data/lib/haveapi/client_example.rb +143 -15
  15. data/lib/haveapi/client_examples/curl.rb +30 -20
  16. data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
  17. data/lib/haveapi/client_examples/go_client.rb +9 -2
  18. data/lib/haveapi/client_examples/http.rb +9 -4
  19. data/lib/haveapi/client_examples/js_async_client.rb +7 -0
  20. data/lib/haveapi/client_examples/js_client.rb +47 -36
  21. data/lib/haveapi/client_examples/php_client.rb +161 -75
  22. data/lib/haveapi/client_examples/python_async_client.rb +3 -0
  23. data/lib/haveapi/client_examples/python_client.rb +61 -47
  24. data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
  25. data/lib/haveapi/client_examples/ruby_client.rb +80 -31
  26. data/lib/haveapi/client_examples/rust_client.rb +20 -9
  27. data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
  28. data/lib/haveapi/client_examples/ts_client.rb +3 -0
  29. data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
  30. data/lib/haveapi/documentation_context.rb +7 -2
  31. data/lib/haveapi/documentation_helpers.rb +223 -0
  32. data/lib/haveapi/documentation_oauth.rb +399 -0
  33. data/lib/haveapi/documentation_search.rb +225 -0
  34. data/lib/haveapi/documentation_search_index.rb +79 -0
  35. data/lib/haveapi/documentation_validators.rb +179 -0
  36. data/lib/haveapi/public/css/documentation.css +343 -0
  37. data/lib/haveapi/public/js/documentation.js +992 -0
  38. data/lib/haveapi/server.rb +144 -86
  39. data/lib/haveapi/shared_documentation_helpers.rb +54 -0
  40. data/lib/haveapi/version.rb +1 -1
  41. data/lib/haveapi/views/doc_layout.erb +16 -0
  42. data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
  43. data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
  44. data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
  45. data/lib/haveapi/views/documentation/search_form.erb +10 -0
  46. data/lib/haveapi/views/documentation/search_index.erb +1 -0
  47. data/lib/haveapi/views/documentation/search_results.erb +20 -0
  48. data/lib/haveapi/views/index.erb +2 -4
  49. data/lib/haveapi/views/main_layout.erb +12 -6
  50. data/lib/haveapi/views/shared_documentation.erb +12 -0
  51. data/lib/haveapi/views/shared_sidebar.erb +25 -0
  52. data/lib/haveapi/views/version_page/auth_body.erb +12 -33
  53. data/lib/haveapi/views/version_page/client_overview.erb +40 -0
  54. data/lib/haveapi/views/version_page/example_panels.erb +18 -0
  55. data/lib/haveapi/views/version_page/resource_body.erb +91 -132
  56. data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
  57. data/lib/haveapi/views/version_page/usage.erb +7 -0
  58. data/lib/haveapi/views/version_page.erb +36 -117
  59. data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
  60. data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
  61. data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
  62. data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
  63. data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
  64. data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
  65. data/lib/haveapi/views/version_sidebar.erb +59 -48
  66. data/spec/authentication/basic_spec.rb +10 -10
  67. data/spec/authentication/oauth2_chain_spec.rb +72 -0
  68. data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
  69. data/spec/client_examples/assets/documentation_test.cjs +817 -0
  70. data/spec/client_examples/context_performance_spec.rb +5 -4
  71. data/spec/client_examples/context_spec.rb +23 -2
  72. data/spec/client_examples/detail_spec.rb +175 -0
  73. data/spec/client_examples/dynamic_acceptance.md +59 -26
  74. data/spec/client_examples/dynamic_acceptance.rb +164 -44
  75. data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
  76. data/spec/client_examples/dynamic_fixture.rb +4 -3
  77. data/spec/client_examples/dynamic_js_spec.rb +20 -11
  78. data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
  79. data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
  80. data/spec/client_examples/dynamic_python_spec.rb +62 -11
  81. data/spec/client_examples/go_spec.rb +14 -1
  82. data/spec/client_examples/presentation_spec.rb +247 -0
  83. data/spec/client_examples/profile_mount_spec.rb +40 -13
  84. data/spec/client_examples/rust_client_spec.rb +35 -1
  85. data/spec/client_examples/shell_spec.rb +268 -0
  86. data/spec/client_examples/syntax_spec.rb +192 -0
  87. data/spec/client_examples/ts_generated_client_spec.rb +15 -2
  88. data/spec/client_examples/typescript_profiles_spec.rb +1 -1
  89. data/spec/documentation/auth_filtering_spec.rb +9 -4
  90. data/spec/documentation/canonical_routes_spec.rb +89 -0
  91. data/spec/documentation/client_examples_spec.rb +39 -5
  92. data/spec/documentation/client_overview_spec.rb +145 -0
  93. data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
  94. data/spec/documentation/example_variants_spec.rb +93 -0
  95. data/spec/documentation/fixtures/search_index.json +476 -0
  96. data/spec/documentation/identity_and_guides_spec.rb +130 -0
  97. data/spec/documentation/identity_context_spec.rb +180 -0
  98. data/spec/documentation/navigation_spec.rb +296 -0
  99. data/spec/documentation/oauth_fixture_spec.rb +39 -0
  100. data/spec/documentation/oauth_spec.rb +912 -0
  101. data/spec/documentation/oauth_transport_spec.rb +45 -0
  102. data/spec/documentation/performance_spec.rb +47 -6
  103. data/spec/documentation/reference_headings_spec.rb +190 -0
  104. data/spec/documentation/search_index_routes_spec.rb +202 -0
  105. data/spec/documentation/search_index_spec.rb +85 -0
  106. data/spec/documentation/search_routes_spec.rb +199 -0
  107. data/spec/documentation/search_spec.rb +134 -0
  108. data/spec/documentation/shared_pages_spec.rb +240 -0
  109. data/spec/documentation/validators_spec.rb +123 -0
  110. data/spec/documentation_spec.rb +3 -3
  111. data/spec/packaging_spec.rb +21 -2
  112. data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
  113. data/spec/performance/documentation_shapes_spec.rb +97 -0
  114. data/spec/performance/json_activation_spec.rb +78 -1
  115. data/spec/performance/release_lineage_spec.rb +4 -3
  116. data/spec/performance/runner_spec.rb +1 -0
  117. data/test_support/all_client_performance_fixture.rb +6 -10
  118. data/test_support/documentation_oauth_fixture.rb +54 -0
  119. data/test_support/documentation_performance_fixture.rb +19 -1
  120. data/test_support/dynamic_documentation_api.rb +35 -10
  121. data/test_support/dynamic_python_login_spec.rb +3 -11
  122. metadata +52 -3
@@ -3,37 +3,168 @@ require 'rack/mock'
3
3
  require_relative 'dynamic_acceptance'
4
4
 
5
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)
6
+ let(:trace) { StringIO.new }
7
+ let(:request) do
8
+ Rack::MockRequest.new(DynamicDocumentationAPI::RequestTrace.new(DynamicDocumentationAPI.build.app, output: trace))
9
+ end
10
+
11
+ def receipt_snapshot
12
+ JSON.parse(request.get('/__acceptance_receipts').body)
13
+ end
14
+
15
+ def setup_panel(provider = nil, detail: :simple)
16
+ { id: 'fixture-panel', group: provider ? "auth-#{provider}-examples" : 'init-examples', detail: }
17
+ end
18
+
19
+ def json_post(path, payload)
20
+ request.post(path, 'CONTENT_TYPE' => 'application/json', input: JSON.generate(payload))
21
+ end
22
+
23
+ it 'extracts the complete actual matrix with eight singleton and28 requested thorough panels' do
24
+ pages = described_class::PAGES.values.map do |query|
25
+ response = request.get("http://example.test/mount/v2/#{query}", 'HTTP_HOST' => 'example.test')
26
+ expect(response.status).to eq(200), response.body
27
+ response.body
28
+ end
29
+ panels = pages.flat_map { |body| described_class.extract(body) }
30
+ expect { described_class.validate_coverage!(panels) }.not_to raise_error
31
+ expect(panels.length).to eq(36)
32
+ expect(panels.count { |panel| panel[:detail] == :simple }).to eq(8)
33
+ expect(panels.count { |panel| panel[:detail] == :thorough }).to eq(28)
34
+ simple = pages.flat_map { |body| described_class.extract(body, detail: :simple) }
35
+ expect(simple.length).to eq(36)
36
+ expect(simple).to all(include(detail: :simple, requested_detail: :simple))
37
+ overlap = panels.map { |panel| panel[:id] } & simple.map { |panel| panel[:id] }
38
+ expect(overlap.length).to eq(8)
11
39
  described_class::VARIANTS.each do |variant|
12
40
  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)
41
+ expect(selected.map { |panel| panel[:group] }.sort).to eq(described_class::GROUPS.sort)
15
42
  expect(selected.last[:source]).to include('http://example.test/mount/')
16
43
  expect(selected.last[:source]).not_to include('"')
44
+ expect(selected).to all(include(requested_detail: :thorough))
17
45
  end
46
+ expect { described_class.validate_coverage!(panels.drop(1)) }.to raise_error(/Incomplete/)
47
+ expect { described_class.validate_coverage!(panels + [panels.first]) }.to raise_error(/Incomplete/)
48
+ end
49
+
50
+ it 'fails malformed missing duplicate or undeclared groups and selected code instead of skipping coverage' do
51
+ html = request.get('/mount/v2/', 'HTTP_HOST' => 'example.test').body
52
+ map = html.match(/data-example-details="([^"]+)"/)[1]
53
+ missing = JSON.parse(CGI.unescapeHTML(map)).except('javascript-callback')
54
+ variants = [
55
+ html.sub(/data-example-details="[^"]+"/, ''),
56
+ html.sub(map, CGI.escapeHTML('{broken')),
57
+ html.sub(map, CGI.escapeHTML(JSON.generate(missing))),
58
+ html.sub(map, CGI.escapeHTML('{"javascript-callback":["simple"],"javascript-callback":["simple"]}')),
59
+ html.sub(map, map.sub('simple', 'unknown')),
60
+ html.sub(/(data-client="javascript-callback"[^>]*data-detail=")simple"/, '\\1other"'),
61
+ html.sub(%r{(<details[^>]*data-client="javascript-callback"[^>]*>.*?</details>)}m) { |panel| panel * 2 },
62
+ html.sub(%r{(<details[^>]*data-client="javascript-callback"[^>]*>).*?</details>}m,
63
+ '\\1<p class="client-limitation">unsupported</p></details>')
64
+ ]
65
+ variants.each_with_index do |invalid, index|
66
+ expect { described_class.extract(invalid) }.to raise_error(StandardError), "Malformed fixture #{index} was accepted"
67
+ end
68
+ group = html.match(%r{<div[^>]*data-example data-example-details=.*?</div>}m)[0]
69
+ expect { described_class.extract(html + group) }.to raise_error(/Duplicate example group/)
70
+ expect { described_class.extract(html, detail: :other) }.to raise_error(/Invalid requested/)
71
+ end
72
+
73
+ it 'requires authenticated mounted discovery for the correct provider on each independent run' do
74
+ before = receipt_snapshot
75
+ expect(request.options('/mount/v2/').status).to eq(200)
76
+ anonymous = receipt_snapshot
77
+ delta = described_class.receipt_delta(before, anonymous)
78
+ expect { described_class.verify_completion!(setup_panel, '', delta) }.not_to raise_error
79
+ expect { described_class.verify_completion!(setup_panel('basic'), '', delta) }.to raise_error(/did not complete/)
80
+ headers = { 'basic' => { 'HTTP_AUTHORIZATION' => "Basic #{['user:secret'].pack('m0')}" },
81
+ 'token' => { 'HTTP_X_HAVEAPI_AUTH_TOKEN' => 'example-token' },
82
+ 'oauth2' => { 'HTTP_X_FIXTURE_OAUTH2' => 'example-access-token' } }
83
+ headers.each do |provider, credentials|
84
+ before = receipt_snapshot
85
+ expect(request.options('/mount/v2/', credentials).status).to eq(200)
86
+ delta = described_class.receipt_delta(before, receipt_snapshot)
87
+ expect { described_class.verify_completion!(setup_panel(provider), '', delta) }.not_to raise_error unless provider == 'token'
88
+ expect { described_class.verify_completion!(setup_panel(provider), 'Discovery or authentication failed', delta) }
89
+ .to raise_error(/Failed callback/)
90
+ expect { described_class.verify_completion!(setup_panel(provider, detail: :thorough), '', delta) }
91
+ .to raise_error(provider == 'token' ? /Token challenge/ : /Missing completion output/)
92
+ expect { described_class.verify_completion!(setup_panel(provider), '', described_class.receipt_delta(receipt_snapshot, receipt_snapshot)) }
93
+ .to raise_error(/did not complete/)
94
+ end
95
+ before = receipt_snapshot
96
+ request.options('/mount/v2/', 'HTTP_AUTHORIZATION' => "Basic #{['user:wrong-secret'].pack('m0')}")
97
+ rejected = described_class.receipt_delta(before, receipt_snapshot)
98
+ expect { described_class.verify_completion!(setup_panel('basic'), '', rejected) }.to raise_error(/did not complete/)
99
+ expect(rejected['events']).to include(include('type' => 'authentication', 'provider' => 'basic', 'authenticated' => false))
100
+ expect(trace.string).not_to include('wrong-secret', 'secret', 'example-token', 'example-access-token', 'Authorization')
101
+ end
102
+
103
+ it 'requires request then verification then authenticated discovery inside each token-authentication program' do
104
+ before = receipt_snapshot
105
+ response = json_post('/mount/_auth/token/tokens', token: { user: 'user', password: 'secret', lifetime: 'fixed' })
106
+ expect(response.status).to eq(200), response.body
107
+ incomplete = described_class.receipt_delta(before, receipt_snapshot)
108
+ expect { described_class.verify_completion!(setup_panel('token'), '', incomplete) }.to raise_error(/did not complete/)
109
+ response = json_post('/mount/_auth/token/tokens/verify', token: { token: 'fixture-challenge', code: 'code' })
110
+ expect(response.status).to eq(200), response.body
111
+ request.options('/mount/v2/', 'HTTP_X_HAVEAPI_AUTH_TOKEN' => 'example-token')
112
+ delta = described_class.receipt_delta(before, receipt_snapshot)
113
+ expect { described_class.verify_completion!(setup_panel('token'), '', delta) }.not_to raise_error
114
+ expect { described_class.verify_completion!(setup_panel('token'), '', incomplete) }.to raise_error(/did not complete/)
115
+ before = receipt_snapshot
116
+ json_post('/mount/_auth/token/tokens/verify', token: { token: 'fixture-challenge', code: 'rejected-code' })
117
+ request.options('/mount/v2/', 'HTTP_X_HAVEAPI_AUTH_TOKEN' => 'example-token')
118
+ rejected = described_class.receipt_delta(before, receipt_snapshot)
119
+ expect { described_class.verify_completion!(setup_panel('token'), '', rejected) }.to raise_error(/Token challenge did not complete/)
120
+ expect(rejected['events']).to include(include('type' => 'token_verification_rejected'))
121
+ expect(JSON.generate(delta['events'])).not_to include('user', 'secret', 'example-token', 'code')
122
+ expect(trace.string).not_to include('user', 'secret', 'example-token', 'code', 'rejected-code')
18
123
  end
19
124
 
20
125
  it 'records real success and failure request values without source rewriting' do
21
- request = Rack::MockRequest.new(DynamicDocumentationAPI.build.app)
126
+ before = receipt_snapshot
22
127
  payload = { widget: { name: '', enabled: false, count: 0, nullable: nil, payload: DynamicDocumentationAPI::PAYLOAD } }
23
128
  headers = {
24
129
  'CONTENT_TYPE' => 'application/json',
25
- 'HTTP_AUTHORIZATION' => "Basic #{['fixture-user:fixture-password'].pack('m0')}",
130
+ 'HTTP_AUTHORIZATION' => "Basic #{['user:secret'].pack('m0')}",
26
131
  input: JSON.generate(payload)
27
132
  }
28
133
  path = 'http://example.test/mount/v2/parents/7/children/name%20with%20space'
134
+ headers.delete('HTTP_AUTHORIZATION')
135
+ headers['HTTP_X_HAVEAPI_AUTH_TOKEN'] = 'example-token'
136
+ request.options('/mount/v2/', 'HTTP_X_HAVEAPI_AUTH_TOKEN' => 'example-token')
29
137
  response = request.post(path, headers)
30
138
  expect(response.status).to eq(200), response.body
31
139
  expect(JSON.parse(response.body).fetch('status')).to be(true)
32
140
  expect(DynamicDocumentationAPI.receipts.last[:input][:payload]).to eq(DynamicDocumentationAPI::PAYLOAD)
141
+ delta = described_class.receipt_delta(before, receipt_snapshot)
142
+ panel = { id: 'success-panel', group: described_class::GROUPS[-2], detail: :thorough }
143
+ expect { described_class.verify_completion!(panel, 'DOC_SUCCESS', delta) }.not_to raise_error
144
+ expect { described_class.verify_completion!(panel, '', delta) }.to raise_error(/Wrong result/)
145
+ expect { described_class.verify_completion!(panel, 'DOC_SUCCESS', delta.merge('actions' => delta['actions'] * 2)) }
146
+ .to raise_error(/did not execute once/)
147
+ before = receipt_snapshot
148
+ request.options('/mount/v2/', 'HTTP_X_HAVEAPI_AUTH_TOKEN' => 'example-token')
33
149
  payload[:widget][:count] = -1
34
150
  response = request.post(path, headers.merge(input: JSON.generate(payload)))
35
151
  expect(response.status).to eq(400), response.body
36
152
  expect(JSON.parse(response.body).fetch('message')).to eq('DOC_FAILURE')
153
+ delta = described_class.receipt_delta(before, receipt_snapshot)
154
+ panel[:group] = described_class::GROUPS.last
155
+ expect { described_class.verify_completion!(panel, 'DOC_FAILURE', delta) }.not_to raise_error
156
+ end
157
+
158
+ it 'rejects malformed or rewound receipt snapshots and rejected token-login completion' do
159
+ before = receipt_snapshot
160
+ json_post('/mount/_auth/token/tokens', token: { user: 'user', password: 'wrong-secret', lifetime: 'fixed' })
161
+ after = receipt_snapshot
162
+ delta = described_class.receipt_delta(before, after)
163
+ expect(delta['events']).to include(include('type' => 'token_request_rejected'))
164
+ expect { described_class.verify_completion!(setup_panel('token'), '', delta) }.to raise_error(/did not complete/)
165
+ expect { described_class.receipt_delta(after, before) }.to raise_error(/changed before/)
166
+ expect { described_class.receipt_delta([], after) }.to raise_error(/Invalid fixture receipt/)
167
+ expect(trace.string).not_to include('wrong-secret', 'secret')
37
168
  end
38
169
 
39
170
  it 'preserves subprocess output before reporting a nonzero execution failure' do
@@ -46,20 +177,6 @@ RSpec.describe DynamicDocumentationAcceptance do
46
177
  end
47
178
  end
48
179
 
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
180
  it 'traces failed mounted routes without headers, query strings or request bodies' do
64
181
  output = StringIO.new
65
182
  app = DynamicDocumentationAPI::RequestTrace.new(DynamicDocumentationAPI.build.app, output:)
@@ -29,8 +29,9 @@ module DynamicExampleFixture
29
29
  end
30
30
 
31
31
  def token_description
32
- { resources: { token: { actions: { 'request' => { input: { parameters: {
33
- 'login-name' => { type: 'String' }, password: { type: 'String' }, lifetime: { type: 'String' }
34
- } } } } } } }
32
+ request = { 'login-name' => { type: 'String' }, password: { type: 'String' }, lifetime: { type: 'String' } }
33
+ verify = { token: { type: 'String', required: true }, code: { type: 'String', required: true } }
34
+ { resources: { token: { actions: { 'request' => { input: { parameters: request } },
35
+ 'verify' => { input: { parameters: verify } } } } } }
35
36
  end
36
37
  end
@@ -39,9 +39,10 @@ RSpec.describe HaveAPI::ClientExamples::JsClient do
39
39
 
40
40
  it 'renders complete syntax-valid initialization, authentication and hostile action programs' do
41
41
  renderers.each do |renderer|
42
- results = [renderer.render(:init, context: dynamic_context), renderer.render(:example, context: dynamic_context)]
42
+ context = dynamic_context(detail: :thorough)
43
+ results = [renderer.render(:init, context:), renderer.render(:example, context:)]
43
44
  %i[basic token oauth2].each do |method|
44
- results << renderer.render(:auth, context: dynamic_context, auth_method: method, auth_description: token_description)
45
+ results << renderer.render(:auth, context:, auth_method: method, auth_description: token_description)
45
46
  end
46
47
  results.each do |result|
47
48
  expect(result[:status]).to eq(:code), result.inspect
@@ -60,7 +61,7 @@ RSpec.describe HaveAPI::ClientExamples::JsClient do
60
61
  expect(source).to include('["enabled"]: false', '["count"]: 0', '["nullable"]: null', '["__proto__"]:')
61
62
  expect(source).to include('new\\"\\n</script>')
62
63
  if renderer.id.end_with?('-async')
63
- expect(source).to include('.resource("constructor").resource("resources").action(', 'result.value', 'result.response.meta')
64
+ expect(source).to include('.resource("constructor").resource("resources").action(', 'result.value')
64
65
  else
65
66
  expect(source).to include('CallbackClient.findResource', 'CallbackClient.findAction', 'action.directInvoke', 'reply.message()', 'reply.envelope.errors')
66
67
  end
@@ -72,14 +73,22 @@ RSpec.describe HaveAPI::ClientExamples::JsClient do
72
73
  Dir.mktmpdir('haveapi-ts-docs-') do |directory|
73
74
  files = []
74
75
  [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
76
+ %i[simple thorough].each do |detail|
77
+ context = dynamic_context(detail:)
78
+ results = [renderer.render(:init, context:), renderer.render(:example, context:)]
79
+ %i[basic token oauth2].each do |method|
80
+ results << renderer.render(:auth, context:, auth_method: method, auth_description: token_description)
81
+ end
82
+ results.each_with_index do |result, number|
83
+ source = result.fetch(:code)
84
+ if detail == :simple && number == 1
85
+ client = renderer.id.end_with?('-async') ? 'AsyncClient' : 'CallbackClient'
86
+ source = "import { #{client} } from \"haveapi-client\";\ndeclare const api: #{client};\n#{source}"
87
+ end
88
+ path = File.join(directory, "#{index}-#{detail}-#{number}.ts")
89
+ File.write(path, source)
90
+ files << path
91
+ end
83
92
  end
84
93
  end
85
94
  options = { strict: true, noEmit: true, target: 'ES2020', module: 'ESNext', moduleResolution: 'node',
@@ -49,4 +49,73 @@ RSpec.describe HaveAPI::ClientExamples::DynamicHelpers do
49
49
  expect(renderer.render(:example, context:)[:status]).to eq(:code)
50
50
  end
51
51
  end
52
+
53
+ it 'uses only public typed literals and advertised finite machine choices for token inputs' do
54
+ description = token_description
55
+ fields = description[:resources][:token][:actions]['request'][:input][:parameters]
56
+ fields[:user] = { type: 'String', required: true, default: 'PRIVATE_USER' }
57
+ fields[:password][:default] = 'PRIVATE_PASSWORD'
58
+ fields[:count] = { type: 'Integer', required: true, default: 99 }
59
+ fields[:enabled] = { type: 'Boolean', required: true, validators: { accept: { value: true, message: 'PRIVATE_PROSE' } } }
60
+ fields[:scope] = { type: 'String', required: true, validators: { include: { values: { 'all' => 'PRIVATE_LABEL' } } } }
61
+ fields[:resource] = { type: 'Resource', required: true }
62
+ fields[:timestamp] = { type: 'Datetime', required: true }
63
+ fields[:custom] = { type: 'Custom', required: true }
64
+ fields[:interval] = { type: 'Integer', required: true }
65
+ before = Marshal.dump(description)
66
+ renderers.each do |renderer|
67
+ result = renderer.render(:auth, context: dynamic_context, auth_method: :token, auth_description: description)
68
+ expect(result[:status]).to eq(:code), result.inspect
69
+ expect(result[:code]).to include('secret', 'user', 'fixed', 'all', '2026-01-01T00:00:00Z')
70
+ expect(result[:code]).not_to include('PRIVATE_', 'os.environ', 'credential(', 'HAVEAPI_', 'node:process')
71
+ end
72
+ expect(Marshal.dump(description)).to eq(before)
73
+ helper = HaveAPI::ClientExamples::JsClient.new('api.test', 'https://api.test/', '2')
74
+ inputs = helper.send(:auth_token_credentials, description)
75
+ expect(inputs).to include('user' => 'user', 'password' => 'secret', 'count' => 1, 'enabled' => true,
76
+ 'scope' => 'all', 'resource' => 1, 'custom' => {}, 'interval' => 1, 'lifetime' => 'fixed')
77
+ end
78
+
79
+ it 'limits unsupported required types and input shapes instead of guessing defaults or dropping fields' do
80
+ descriptions = []
81
+ description = token_description
82
+ description[:resources][:token][:actions]['request'][:input][:parameters][:hidden] = {
83
+ type: 'Unsupported', required: true, default: 'PRIVATE_DEFAULT'
84
+ }
85
+ descriptions << description
86
+ description = token_description
87
+ description[:resources][:token][:actions]['verify'][:input][:parameters][:hidden] = {
88
+ type: 'Unsupported', required: true, default: 'PRIVATE_DEFAULT'
89
+ }
90
+ descriptions << description
91
+ description = token_description
92
+ description[:resources][:token][:actions]['request'][:input][:layout] = :object_list
93
+ descriptions << description
94
+ renderers.each do |renderer|
95
+ descriptions.each do |desc|
96
+ result = renderer.render(:auth, context: dynamic_context, auth_method: :token, auth_description: desc)
97
+ expect(result).to include(status: :limitation)
98
+ expect(result.inspect).not_to include('PRIVATE_DEFAULT')
99
+ end
100
+ end
101
+ end
102
+
103
+ it 'uses actual continuation action arguments and rejects unknown continuation or input names' do
104
+ renderer = HaveAPI::ClientExamples::JsClient.new('api.test', 'https://api.test/', '2')
105
+ options = renderer.token_login_options(token_description)
106
+ script = <<~JS
107
+ const assert = require('node:assert/strict');
108
+ const options = #{options};
109
+ assert.deepEqual(options.callback('verify', {code: {}}), {code: 'code'});
110
+ assert.throws(() => options.callback('missing', {code: {}}), /Unknown authentication continuation/);
111
+ assert.throws(() => options.callback('verify', {unexpected: {}}), /Unknown authentication input/);
112
+ JS
113
+ output, status = Open3.capture2e('node', '-e', script)
114
+ expect(status).to be_success, output
115
+ renderers.grep(Class).each do |client|
116
+ source = client.render(:auth, context: dynamic_context, auth_method: :token, auth_description: token_description).fetch(:code)
117
+ expect(source).not_to include('os.environ', 'HAVEAPI_', 'step.action')
118
+ expect(source).to include(client.code == :python ? 'step.next_action' : '(action, parameters)')
119
+ end
120
+ end
52
121
  end
@@ -6,7 +6,7 @@ require_relative '../../test_support/dynamic_documentation_api'
6
6
  RSpec.describe DynamicDocumentationAPI do
7
7
  it 'uses real mounted descriptor routes for callback and async token challenges and nested actions' do
8
8
  request = Rack::MockRequest.new(described_class.build.app)
9
- headers = { 'HTTP_AUTHORIZATION' => "Basic #{['fixture-user:fixture-password'].pack('m0')}" }
9
+ headers = { 'HTTP_AUTHORIZATION' => "Basic #{['user:secret'].pack('m0')}" }
10
10
  envelope = JSON.parse(request.options('http://example.test/mount/v2/', headers).body)
11
11
  model = envelope.fetch('response')
12
12
  token = model.fetch('authentication').fetch('token').fetch('resources').fetch('token').fetch('actions')
@@ -17,14 +17,16 @@ RSpec.describe DynamicDocumentationAPI do
17
17
  post = lambda do |path, body, extra = {}|
18
18
  response = request.post("http://example.test#{path}", extra.merge('CONTENT_TYPE' => 'application/json', input: JSON.generate(body)))
19
19
  expect(response.status).to eq(200), response.body
20
- JSON.parse(response.body)
20
+ payload = JSON.parse(response.body)
21
+ expect(payload.fetch('status')).to be(true), response.body
22
+ payload
21
23
  end
22
24
  challenge = post.call(token.fetch('request').fetch('path'), { token: {
23
- user: 'fixture-user', password: 'fixture-password', scope: 'all', lifetime: 'renewable_auto'
25
+ user: 'user', password: 'secret', scope: 'all', lifetime: 'renewable_auto'
24
26
  } })
25
- completed = post.call(token.fetch('verify').fetch('path'), { token: { token: 'fixture-challenge', code: '123456' } })
27
+ completed = post.call(token.fetch('verify').fetch('path'), { token: { token: 'fixture-challenge', code: 'code' } })
26
28
  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' })
29
+ result = post.call(action_path, { widget: params }, { 'HTTP_X_HAVEAPI_AUTH_TOKEN' => 'example-token' })
28
30
  data = { envelope:, challenge:, completed:, result:, params:, action_path: }
29
31
  script = <<~JS
30
32
  const assert = require('node:assert/strict');
@@ -36,7 +38,7 @@ RSpec.describe DynamicDocumentationAPI do
36
38
  async function check() {
37
39
  const callback = new HaveAPI.CallbackClient('http://example.test/mount/', {version:'2'});
38
40
  let ready = false;
39
- callback.authenticate('token', {user:'fixture-user', password:'fixture-password', scope:'all', callback: () => ({code:'123456'})},
41
+ callback.authenticate('token', {user:'user', password:'secret', scope:'all', callback: () => ({code:'code'})},
40
42
  (_, ok) => { ready = ok; });
41
43
  // Parse actual discovery JSON inside the bundle's VM, matching the real XHR boundary.
42
44
  assert.equal(requests[0].url, 'http://example.test/mount/v2/'); requests[0].respond(data.envelope);
@@ -47,7 +49,7 @@ RSpec.describe DynamicDocumentationAPI do
47
49
  HaveAPI.CallbackClient.findAction(resource, 'new').directInvoke(7, 'name with space', {params:data.params});
48
50
  assert.equal(requests[4].url, 'http://example.test' + data.action_path); requests[4].respond(data.result);
49
51
  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'})});
52
+ const authenticating = api.authenticate('token', {user:'user', password:'secret', scope:'all', onChallenge: () => ({code:'code'})});
51
53
  await tick(); assert.equal(requests[5].url, 'http://example.test' + auth.request.path); requests[5].respond(data.challenge);
52
54
  await tick(); assert.equal(requests[6].url, 'http://example.test' + auth.verify.path); requests[6].respond(data.completed);
53
55
  await tick(); assert.equal(requests[7].url, 'http://example.test/mount/v2/'); requests[7].respond(data.envelope); await authenticating;
@@ -7,6 +7,19 @@ RSpec.describe HaveAPI::ClientExamples::PythonClient do
7
7
 
8
8
  let(:renderers) { [described_class, HaveAPI::ClientExamples::PythonAsyncClient] }
9
9
 
10
+ it 'separates imports, executable sync code and top-level async definitions without blank padding' do
11
+ %i[simple thorough].each do |detail|
12
+ context = dynamic_context(detail:)
13
+ sync = described_class.render(:init, context:).fetch(:code)
14
+ async = HaveAPI::ClientExamples::PythonAsyncClient.render(:init, context:).fetch(:code)
15
+ expect(sync).to start_with('from haveapi_client import ')
16
+ expect(sync).to include(detail == :simple ? "Client\n\nwith Client(" : "HaveAPIError\n\ntry:\n")
17
+ expect(async).to start_with("import asyncio\n\nfrom haveapi_client import ")
18
+ expect(async).to include("\n\n\nasync def main() -> None:\n", "\n\n\nasyncio.run(main())\n")
19
+ expect([sync, async]).to all(satisfy { |source| !source.start_with?("\n") && !source.end_with?("\n\n") })
20
+ end
21
+ end
22
+
10
23
  it 'keeps both variants available without a generated SDK profile' do
11
24
  expect(renderers.map(&:id)).to eq(%w[python-sync python-async])
12
25
  expect(renderers.map(&:order)).to eq([14, 15])
@@ -15,15 +28,28 @@ RSpec.describe HaveAPI::ClientExamples::PythonClient do
15
28
 
16
29
  it 'renders complete parseable init, auth, action and async ownership' do
17
30
  renderers.each do |renderer|
18
- results = [renderer.render(:init, context: dynamic_context), renderer.render(:example, context: dynamic_context)]
31
+ context = dynamic_context(detail: :thorough)
32
+ results = [renderer.render(:init, context:), renderer.render(:example, context:)]
19
33
  %i[basic token oauth2].each do |method|
20
- results << renderer.render(:auth, context: dynamic_context, auth_method: method, auth_description: token_description)
34
+ results << renderer.render(:auth, context:, auth_method: method, auth_description: token_description)
21
35
  end
22
36
  results.each do |result|
23
37
  expect(result[:status]).to eq(:code), result.inspect
24
38
  expect(result).to be_frozen
25
- output, status = Open3.capture2e('python', '-c', 'import ast, sys; ast.parse(sys.stdin.read())', stdin_data: result[:code])
39
+ output, status = Open3.capture2e('python', '-c',
40
+ 'import ast, json, sys; print(json.dumps([type(node).__name__ for node in ast.parse(sys.stdin.read()).body]))',
41
+ stdin_data: result[:code])
26
42
  expect(status).to be_success, output
43
+ statements = JSON.parse(output)
44
+ expect(result[:code]).not_to include('__main__')
45
+ if renderer.id == 'python-async'
46
+ expect(statements.count('AsyncFunctionDef')).to eq(1)
47
+ expect(statements.last).to eq('Expr')
48
+ expect(result[:code]).to include("\nasyncio.run(main())\n")
49
+ else
50
+ expect(statements).to include('Try')
51
+ expect(statements).not_to include('FunctionDef', 'AsyncFunctionDef')
52
+ end
27
53
  expect(result[:code]).to include('https://api.example.test/mount/', 'version="2"')
28
54
  expect(result[:code]).to include('except ActionError', 'except HaveAPIError')
29
55
  end
@@ -36,16 +62,42 @@ RSpec.describe HaveAPI::ClientExamples::PythonClient do
36
62
  expect(source).to include('.resources["constructor"].resources["resources"].actions[',
37
63
  'path_params={"parent_id": 7, "widget_id": "name/with space"}',
38
64
  '"enabled": False', '"count": 0', '"nullable": None', '"__proto__":',
39
- 'print(response.data)', 'print(response.meta)')
65
+ 'print(response.data)')
40
66
  if renderer.id == 'python-async'
41
- expect(source).to include('async with AsyncClient', 'response = await ', 'asyncio.run(main())')
67
+ expect(source).to include('response = await ', 'async function')
42
68
  else
43
- expect(source).to include('with Client')
69
+ expect(source).to include('response = api.resources')
44
70
  expect(source).not_to include('await ')
45
71
  end
46
72
  end
47
73
  end
48
74
 
75
+ it 'keeps simple setup and authentication parseable with only the imports they use' do
76
+ renderers.each do |renderer|
77
+ init = renderer.render(:init, context: dynamic_context).fetch(:code)
78
+ sources = [init]
79
+ %i[basic token oauth2].each do |method|
80
+ sources << renderer.render(:auth, context: dynamic_context, auth_method: method, auth_description: token_description).fetch(:code)
81
+ end
82
+ sources.each do |source|
83
+ output, status = Open3.capture2e('python', '-c', 'import ast, sys; ast.parse(sys.stdin.read())', stdin_data: source)
84
+ expect(status).to be_success, output
85
+ expect(source).not_to include('__main__', 'except ', 'ActionError', 'HaveAPIError', 'print("Authenticated")')
86
+ if renderer.id == 'python-async'
87
+ expect(source).to include('async def main()', "\nasyncio.run(main())\n")
88
+ else
89
+ expect(source).not_to include('def main', 'asyncio')
90
+ end
91
+ end
92
+ expect(init).not_to include('import os', 'import json')
93
+ basic = sources[1]
94
+ expect(basic).to include('BasicAuth("user", "secret")')
95
+ expect(basic).not_to include('import json')
96
+ expect(sources[2]).to include('begin_token_login', 'continuations', 'step.next_action')
97
+ expect(sources.join).not_to include('import os', 'import json', 'os.environ')
98
+ end
99
+ end
100
+
49
101
  it 'retains list rows and exposes a limitation for an unnamespaced list' do
50
102
  action = dynamic_action.merge(input: { layout: :hash_list, namespace: :widgets, parameters: { name: { type: 'String' } } })
51
103
  context = dynamic_context(action:, sample: dynamic_sample.merge(request: [{ name: '' }, { name: 'second' }]))
@@ -69,7 +121,7 @@ RSpec.describe HaveAPI::ClientExamples::PythonClient do
69
121
  it 'uses real token challenge APIs and advertised login fields without printing credentials' do
70
122
  renderers.each do |renderer|
71
123
  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')
124
+ expect(source).to include('begin_token_login', 'step.input', 'step.submit(values)', '"login-name": "login-name"')
73
125
  expect(source).not_to include('print(step.token)', 'qwertyuiop')
74
126
  end
75
127
  end
@@ -81,15 +133,14 @@ RSpec.describe HaveAPI::ClientExamples::PythonClient do
81
133
  parameters[:interval] = { type: 'Integer', required: false }
82
134
  renderers.each do |renderer|
83
135
  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"])')
136
+ expect(source).to include('"lifetime": "fixed"')
85
137
  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)')
138
+ expect(source).to include('step.next_action', 'step.submit(values)', 'name not in values')
88
139
  end
89
140
  parameters[:interval][:required] = true
90
141
  renderers.each do |renderer|
91
142
  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"])')
143
+ expect(source).to include('"interval": 1')
93
144
  end
94
145
  end
95
146
 
@@ -7,6 +7,7 @@ RSpec.describe HaveAPI::ClientExamples::GoClient do
7
7
  let(:adapter) do
8
8
  contract = Class.new do
9
9
  def render(**); end
10
+ def detail_levels_for(**); end
10
11
  end
11
12
  instance_double(contract, render: result)
12
13
  end
@@ -22,6 +23,7 @@ RSpec.describe HaveAPI::ClientExamples::GoClient do
22
23
 
23
24
  it 'registers the frozen Go ID and requires its configured profile' do
24
25
  expect(described_class.id).to eq('go')
26
+ expect(described_class.label).to eq('Go API client')
25
27
  expect(described_class.code).to eq(:go)
26
28
  expect(described_class.order).to eq(16)
27
29
  expect(described_class).to be_available(context)
@@ -29,6 +31,17 @@ RSpec.describe HaveAPI::ClientExamples::GoClient do
29
31
  expect(described_class).not_to be_available(context.with(profile: Struct.new(:language).new(:rust)))
30
32
  end
31
33
 
34
+ it 'delegates meaningful-detail queries with the existing rendered result and native mode' do
35
+ allow(adapter).to receive(:detail_levels_for).and_return([:simple].freeze)
36
+ expect(described_class.detail_levels_for(:init, context:, result:)).to eq([:simple])
37
+ expect(adapter).to have_received(:detail_levels_for).with(kind: :init, context:, result:, mode: :sync,
38
+ auth_method: nil, auth_description: nil)
39
+ allow(adapter).to receive(:detail_levels_for).and_return(%i[simple thorough].freeze)
40
+ expect(described_class.detail_levels_for(:example, context:, result:)).to eq(%i[simple thorough])
41
+ allow(adapter).to receive(:detail_levels_for).and_return([:thorough])
42
+ expect { described_class.detail_levels_for(:example, context:, result:) }.to raise_error(ArgumentError)
43
+ end
44
+
32
45
  it 'delegates initialization without requiring the optional generator gem' do
33
46
  expect(described_class.render(:init, context:)).to eq(result)
34
47
  expect(adapter).to have_received(:render).with(kind: :init, context:, mode: :sync)
@@ -42,7 +55,7 @@ RSpec.describe HaveAPI::ClientExamples::GoClient do
42
55
  end
43
56
 
44
57
  it 'binds the selected filtered example and preserves plain limitation results' do
45
- limitation = { status: :limitation, reason: 'This Go SDK input is unsupported.' }.freeze
58
+ limitation = { status: :limitation, reason: 'This Go API client input is unsupported.' }.freeze
46
59
  allow(adapter).to receive(:render).and_return(limitation)
47
60
  expect(described_class.render(:example, context:)).to eq(limitation)
48
61
  expect(adapter).to have_received(:render) do |**arguments|