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
@@ -0,0 +1,268 @@
1
+ require 'spec_helper'
2
+ require 'fileutils'
3
+ require 'json'
4
+ require 'open3'
5
+ require 'tmpdir'
6
+ require_relative 'dynamic_fixture'
7
+
8
+ RSpec.describe HaveAPI::ClientExamples do
9
+ include DynamicExampleFixture
10
+
11
+ let(:action) do
12
+ { method: 'POST', path: '/widgets/{widget_id}', auth: false,
13
+ input: { layout: :hash, namespace: :widget, parameters: { name: { type: 'String' } } },
14
+ output: { layout: :hash, namespace: :widget, parameters: {} } }
15
+ end
16
+ let(:sample) { { path_params: [7], request: { name: 'ordinary' }, response: {}, status: true } }
17
+
18
+ def renderer(type, base_url: 'https://docs.example/api/', version: '1', resource_path: ['widget'], action_name: 'update', contract: action)
19
+ type.new('docs.example', base_url, version, resource_path, {}, action_name, contract)
20
+ end
21
+
22
+ def capture_arguments(source, directory)
23
+ bin = File.join(directory, 'bin')
24
+ FileUtils.mkdir_p(bin)
25
+ %w[haveapi-cli curl].each do |name|
26
+ executable = File.join(bin, name)
27
+ File.write(executable, "#!/bin/sh\nprintf '%s\\0' \"$@\" >> \"$CAPTURE\"\n")
28
+ File.chmod(0o755, executable)
29
+ end
30
+ capture = File.join(directory, 'arguments')
31
+ FileUtils.rm_f(capture)
32
+ stdout, stderr, status = Open3.capture3(
33
+ { 'PATH' => "#{bin}:#{ENV.fetch('PATH')}", 'CAPTURE' => capture },
34
+ 'sh', '-c', source.delete_prefix('$ ')
35
+ )
36
+ expect(status).to be_success, "#{stdout}\n#{stderr}"
37
+ File.binread(capture).force_encoding(Encoding::UTF_8).split("\0", -1)[0...-1]
38
+ end
39
+
40
+ [HaveAPI::ClientExamples::RubyCli, HaveAPI::ClientExamples::Curl].each do |type|
41
+ it "keeps the reported #{type.label} response substitution inert in the whole transcript", :aggregate_failures do
42
+ Dir.mktmpdir('haveapi-shell-response-') do |directory|
43
+ marker = File.join(directory, 'response-executed')
44
+ payload = "$(printf response-confirmed > #{marker})"
45
+ contract = action.merge(output: action[:output].merge(parameters: { name: { type: 'String' } }))
46
+ example = sample.merge(request: {}, response: { name: payload }, message: 'ok', errors: nil)
47
+ context = dynamic_context(base_url: 'https://docs.example/api/', version: '1', resource_path: ['widget'],
48
+ action_name: 'show', action: contract, sample: example)
49
+ source = type.render(:example, context:).fetch(:code)
50
+ argv = capture_arguments(source, directory)
51
+ expect(File.exist?(marker)).to be(false)
52
+ expect(source).to include(payload)
53
+ if type == HaveAPI::ClientExamples::RubyCli
54
+ expect(argv).to eq(['-u', 'https://docs.example/api/', '--version', '1', 'widget', 'show', '7'])
55
+ else
56
+ expect(argv).to eq(['--request', 'POST', '--data-binary', JSON.generate(widget: {}), 'https://docs.example/api/widgets/7'])
57
+ expect(JSON.parse(argv.fetch(3))).to eq('widget' => {})
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ it 'keeps complete success and failure transcripts inert at both direct details without changing request bytes' do
64
+ Dir.mktmpdir('haveapi-shell-transcripts-') do |directory|
65
+ marker = File.join(directory, 'response-executed')
66
+ hostile = "$(touch #{marker})\n`touch #{marker}`\r\n; touch #{marker}\r& echo '雪' \"$value\"\\\ntrailing\\"
67
+ request = "request\r\n雪'\"\\$(touch #{marker})"
68
+ parameters = { name: { type: 'String', label: hostile }, zero: { type: 'Integer' }, empty: { type: 'String' },
69
+ enabled: { type: 'Boolean' }, nullable: { type: 'Custom' }, structured: { type: 'Custom' } }
70
+ contract = action.merge(output: action[:output].merge(parameters:))
71
+ values = { name: hostile, zero: 0, empty: '', enabled: false, nullable: nil, structured: { list: [false, 0, nil] } }
72
+ examples = [sample.merge(request: { name: request }, response: values, message: 'ok', errors: nil),
73
+ sample.merge(request: { name: request }, response: [values, values], message: 'ok', errors: nil),
74
+ sample.merge(request: { name: request }, response: {}, status: false, message: hostile, errors: { hostile => [hostile, 'retained diagnostic'] })]
75
+ [HaveAPI::ClientExamples::RubyCli, HaveAPI::ClientExamples::Curl].each do |type|
76
+ %i[simple thorough].each do |detail|
77
+ examples.each do |example|
78
+ context = dynamic_context(base_url: 'https://docs.example/api/', version: '1', resource_path: ['widget'],
79
+ action_name: 'show', action: contract, sample: example, detail:)
80
+ source = type.render(:example, context:).fetch(:code)
81
+ argv = capture_arguments(source, directory)
82
+ expect(File.exist?(marker)).to be(false)
83
+ expect(source).to end_with("\n")
84
+ if type == HaveAPI::ClientExamples::RubyCli
85
+ expect(argv).to eq(['-u', 'https://docs.example/api/', '--version', '1', 'widget', 'show', '7', '--', '--name', request])
86
+ display = source.split("\n# ", 2).fetch(1)
87
+ expect("# #{display}".lines).to all(start_with('# '))
88
+ expect(display).not_to include("\r")
89
+ expect(display).to include("$(touch #{marker})", "`touch #{marker}`", "; touch #{marker}", 'trailing\\')
90
+ expect(display).to include('Action failed:', 'Errors:', 'retained diagnostic') unless example[:status]
91
+ expect(display).to include('0', 'list') if example[:status]
92
+ else
93
+ expect(argv).to eq(['--request', 'POST', '--data-binary', JSON.generate(widget: { name: request }), 'https://docs.example/api/widgets/7'])
94
+ expect(JSON.parse(argv.fetch(3))).to eq('widget' => { 'name' => request })
95
+ response = source.split("\n\n", 2).fetch(1)
96
+ expect(response.lines).to all(start_with('# '))
97
+ decoded = response.lines.map { |line| line.delete_prefix('# ') }.join
98
+ envelope = { status: example[:status], message: example[:message], response: { widget: example[:response] }, errors: example[:errors] }
99
+ expect(JSON.parse(decoded)).to eq(JSON.parse(JSON.generate(envelope)))
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ it 'preserves direct curl JSON, raw HTTP and inherited shell response composition' do
108
+ example = sample.merge(response: { name: "line\r\n雪$(echo retained)" }, http_status: 200, message: 'ok', errors: nil)
109
+ curl = renderer(HaveAPI::ClientExamples::Curl)
110
+ expect(JSON.parse(curl.response(example))).to eq('status' => true, 'message' => 'ok',
111
+ 'response' => { 'widget' => { 'name' => example[:response][:name] } }, 'errors' => nil)
112
+ expect(curl.response(example)).not_to start_with('# ')
113
+ http = renderer(HaveAPI::ClientExamples::Http)
114
+ expect(http.request(example)).to start_with("POST /widgets/7 HTTP/1.1\nHost: docs.example\nContent-Type: application/json\n\n")
115
+ expect(http.response(example)).to start_with("HTTP/1.1 200 OK\nContent-Type: application/json;charset=utf-8\n")
116
+ context = dynamic_context(action:, sample: example)
117
+ [HaveAPI::ClientExamples::RubyCli, HaveAPI::ClientExamples::Curl].each do |type|
118
+ expect(Class.new(type).render(:example, context:)).to eq(type.render(:example, context:))
119
+ end
120
+ end
121
+
122
+ it 'keeps the reported CLI request-value breakout literal' do
123
+ Dir.mktmpdir('haveapi-shell-') do |directory|
124
+ marker = File.join(directory, 'request-value-executed')
125
+ payload = "'; printf request-value-confirmed > #{marker}; #'"
126
+ source = renderer(HaveAPI::ClientExamples::RubyCli).example(sample.merge(request: { name: payload }))
127
+ argv = capture_arguments(source, directory)
128
+ expect(File.exist?(marker)).to be(false)
129
+ expect(argv).to eq(['-u', 'https://docs.example/api/', '--version', '1', 'widget', 'update', '7', '--', '--name', payload])
130
+ end
131
+ end
132
+
133
+ it 'keeps the reported CLI base-URL separator literal' do
134
+ Dir.mktmpdir('haveapi-shell-') do |directory|
135
+ marker = File.join(directory, 'base-url-executed')
136
+ base_url = "https://docs.example/api; printf host-confirmed > #{marker}; #"
137
+ argv = capture_arguments(renderer(HaveAPI::ClientExamples::RubyCli, base_url:).init, directory)
138
+ expect(File.exist?(marker)).to be(false)
139
+ expect(argv).to eq(['-u', base_url, '--version', '1'])
140
+ end
141
+ end
142
+
143
+ it 'keeps the reported curl JSON substitution literal' do
144
+ Dir.mktmpdir('haveapi-shell-') do |directory|
145
+ marker = File.join(directory, 'request-value-executed')
146
+ payload = "$(printf request-value-confirmed > #{marker})"
147
+ argv = capture_arguments(renderer(HaveAPI::ClientExamples::Curl).request(sample.merge(request: { name: payload })), directory)
148
+ expect(File.exist?(marker)).to be(false)
149
+ expect(argv).to eq(['--request', 'POST', '--data-binary', JSON.generate(widget: { name: payload }), 'https://docs.example/api/widgets/7'])
150
+ expect(JSON.parse(argv.fetch(3))).to eq('widget' => { 'name' => payload })
151
+ end
152
+ end
153
+
154
+ it 'keeps the reported curl URL quote breakout literal' do
155
+ Dir.mktmpdir('haveapi-shell-') do |directory|
156
+ marker = File.join(directory, 'base-url-executed')
157
+ base_url = "https://docs.example/'$(printf host-confirmed > #{marker})'/api/"
158
+ argv = capture_arguments(renderer(HaveAPI::ClientExamples::Curl, base_url:).request(sample), directory)
159
+ expect(File.exist?(marker)).to be(false)
160
+ expect(argv).to eq(['--request', 'POST', '--data-binary', JSON.generate(widget: { name: 'ordinary' }), "#{base_url}widgets/7"])
161
+ expect(JSON.parse(argv.fetch(3))).to eq('widget' => { 'name' => 'ordinary' })
162
+ end
163
+ end
164
+
165
+ it 'preserves CLI argument order, Boolean flags and existing to_s values across shell metacharacters' do
166
+ Dir.mktmpdir('haveapi-shell-') do |directory|
167
+ marker = File.join(directory, 'unintended')
168
+ hostile = "space\tline\n雪'\"\\$value;`touch #{marker}`$(touch #{marker})"
169
+ values = { 'odd_name' => hostile, empty: '', zero: 0, nil_value: nil,
170
+ list: [false, 0, nil], object: { nested: false }, enabled_flag: true, disabled_flag: false,
171
+ at_file: '@example', escaped_at: '@@example' }
172
+ parameters = values.to_h { |key, _| [key, { type: 'String' }] }
173
+ parameters[:enabled_flag] = parameters[:disabled_flag] = { type: 'Boolean' }
174
+ contract = action.merge(input: action[:input].merge(parameters:))
175
+ generator = renderer(HaveAPI::ClientExamples::RubyCli, base_url: hostile, version: hostile,
176
+ resource_path: [hostile, 'nested'], action_name: hostile, contract:)
177
+ request = sample.merge(path_params: [hostile, '', 0, nil], request: values)
178
+ argv = capture_arguments(generator.example(request), directory)
179
+ expect(argv).to eq(['-u', hostile, '--version', hostile, "#{hostile}.nested", hostile, hostile, '', '0', '', '--',
180
+ '--odd-name', hostile, '--empty', '', '--zero', '0', '--nil-value', '',
181
+ '--list', values[:list].to_s, '--object', values[:object].to_s,
182
+ '--enabled-flag', '--no-disabled_flag', '--at-file', '@example', '--escaped-at', '@@example'])
183
+ expect(File.exist?(marker)).to be(false)
184
+ malicious_option = "option'; touch #{marker}; #"
185
+ contract[:input][:parameters][malicious_option] = { type: 'String' }
186
+ argv = capture_arguments(generator.example(sample.merge(request: { malicious_option => hostile })), directory)
187
+ expect(argv.last(3)).to eq(['--', "--#{malicious_option.tr('_', '-')}", hostile])
188
+ expect(File.exist?(marker)).to be(false)
189
+ end
190
+ end
191
+
192
+ it 'preserves curl methods, paths and complete JSON values without shell evaluation' do
193
+ Dir.mktmpdir('haveapi-shell-') do |directory|
194
+ marker = File.join(directory, 'unintended')
195
+ hostile = "\t\n雪'\"\\$value;`touch #{marker}`$(touch #{marker})"
196
+ values = { name: hostile, empty: '', enabled: false, zero: 0, nullable: nil,
197
+ nested: { list: [false, 0, nil, '', hostile] } }
198
+ contract = action.merge(method: hostile, path: "/widgets/{widget_id}#{hostile}")
199
+ argv = capture_arguments(renderer(HaveAPI::ClientExamples::Curl, contract:).request(sample.merge(path_params: [hostile], request: values)), directory)
200
+ expect(argv).to eq(['--request', hostile, '--data-binary', JSON.generate(widget: values),
201
+ "https://docs.example/api/widgets/#{hostile}#{hostile}"])
202
+ expect(JSON.parse(argv.fetch(3))).to eq(JSON.parse(JSON.generate(widget: values)))
203
+ expect(File.exist?(marker)).to be(false)
204
+ end
205
+ end
206
+
207
+ it 'keeps curl GET query construction and body semantics intact' do
208
+ Dir.mktmpdir('haveapi-shell-') do |directory|
209
+ values = { name: 'a &雪', enabled: false, zero: 0, nullable: nil }
210
+ argv = capture_arguments(renderer(HaveAPI::ClientExamples::Curl, contract: action.merge(method: 'GET')).request(sample.merge(request: values)), directory)
211
+ expect(argv).to eq(['--request', 'GET', '--data-binary', JSON.generate(widget: values),
212
+ 'https://docs.example/api/widgets/7?name=a+%26%E9%9B%AA&enabled=false&zero=0&nullable='])
213
+ expect(JSON.parse(argv.fetch(3))).to eq(JSON.parse(JSON.generate(widget: values)))
214
+ end
215
+ end
216
+
217
+ it 'encodes curl initialization and auth URLs, token headers and login values once' do
218
+ Dir.mktmpdir('haveapi-shell-') do |directory|
219
+ marker = File.join(directory, 'unintended')
220
+ hostile = "\n雪'\"\\$value;`touch #{marker}`$(touch #{marker})"
221
+ generator = renderer(HaveAPI::ClientExamples::Curl, base_url: "https://docs.example/#{hostile}/", version: hostile)
222
+ base = generator.base_url
223
+ expect(capture_arguments(generator.init, directory)).to eq(['--request', 'OPTIONS', generator.version_url])
224
+ basic = generator.auth(:basic, {})
225
+ first = basic.split("\n# Password: secret", 2).first.split('$ ', 2).last
226
+ last = basic.split('# Password given on the command line', 2).last.split('$ ', 2).last
227
+ expect(capture_arguments("$ #{first}", directory)).to eq(['--request', 'OPTIONS', '--user', 'user', base])
228
+ expect(capture_arguments("$ #{last}", directory)).to eq(['--request', 'OPTIONS', '--user', 'user:secret', base])
229
+ description = { http_header: hostile, resources: { token: { actions: { request: { input: { parameters: {
230
+ hostile => { type: 'String', required: true }, lifetime: { type: 'String', required: true }
231
+ } } } } } } }
232
+ token = generator.auth(:token, description)
233
+ acquire, reuse = token.split("\n\n# Use a previously acquired token", 2)
234
+ acquire = acquire.split('$ ', 2).last
235
+ reuse = reuse.split('$ ', 2).last
236
+ expect(capture_arguments("$ #{acquire}", directory)).to eq(['--request', 'POST', '--header', 'Content-Type: application/json',
237
+ '--data-binary', JSON.generate(token: { hostile => hostile, lifetime: 'fixed' }),
238
+ File.join(base, '_auth', 'token', 'tokens')])
239
+ expect(capture_arguments("$ #{reuse}", directory)).to eq(['--request', 'OPTIONS', '--header', "#{hostile}: example-token", base])
240
+ expect(File.exist?(marker)).to be(false)
241
+ end
242
+ end
243
+
244
+ it 'encodes CLI Basic and token command spans without changing prompts or token options' do
245
+ Dir.mktmpdir('haveapi-shell-') do |directory|
246
+ marker = File.join(directory, 'unintended')
247
+ hostile = "\n雪'\"\\$value;`touch #{marker}`$(touch #{marker})"
248
+ generator = renderer(HaveAPI::ClientExamples::RubyCli, base_url: hostile, version: hostile)
249
+ basic = generator.auth(:basic, {})
250
+ first, last = basic.split("\n\n# If username", 2)
251
+ first = first.split('$ ', 2).last
252
+ last = last.split('$ ', 2).last.split("\n# Password: secret", 2).first
253
+ prefix = ['-u', hostile, '--version', hostile]
254
+ expect(capture_arguments("$ #{first}", directory)).to eq(prefix + %w[--auth basic --user user --password secret])
255
+ expect(capture_arguments("$ #{last}", directory)).to eq(prefix + %w[--auth basic --user user])
256
+ description = { resources: { token: { actions: { request: { input: { parameters: {
257
+ hostile => { type: 'String', required: true }, lifetime: { type: 'String', required: true }, password: { type: 'String' }
258
+ } } } } } } }
259
+ token = generator.auth(:token, description)
260
+ first = token.split("\n# Password: secret", 2).first.split('$ ', 2).last
261
+ last = token.split('# nor password and be authenticated', 2).last.split('$ ', 2).last
262
+ expect(capture_arguments("$ #{first}", directory)).to eq(prefix + ['--auth', 'token', "--#{hostile}", hostile, '--lifetime', 'fixed', '--save', 'user', 'current'])
263
+ expect(capture_arguments("$ #{last}", directory)).to eq(prefix + %w[user current])
264
+ expect(token).to include('# Password: secret')
265
+ expect(File.exist?(marker)).to be(false)
266
+ end
267
+ end
268
+ end
@@ -9,6 +9,7 @@ require 'haveapi/client_examples/http'
9
9
  require 'haveapi/client_examples/js_client'
10
10
  require 'haveapi/client_examples/php_client'
11
11
  require 'haveapi/client_examples/ruby_client'
12
+ require 'haveapi/client'
12
13
 
13
14
  describe HaveAPI::ClientExamples do
14
15
  let(:action) do
@@ -70,6 +71,14 @@ describe HaveAPI::ClientExamples do
70
71
  end
71
72
  end
72
73
 
74
+ def execute_ruby(source)
75
+ Tempfile.create(['haveapi-handler-', '.rb']) do |file|
76
+ file.write(source)
77
+ file.flush
78
+ load file.path
79
+ end
80
+ end
81
+
73
82
  it 'generates valid JavaScript' do
74
83
  result = HaveAPI::ClientExamples::JsClient.new(*client_args).example(sample)
75
84
  source = result.is_a?(Hash) ? result.fetch(:code) : result
@@ -98,6 +107,18 @@ describe HaveAPI::ClientExamples do
98
107
  expect(status).to be_success, output
99
108
  end
100
109
 
110
+ it 'round-trips PHP data literals through plain and Thorough source without changing control bytes' do
111
+ renderer = HaveAPI::ClientExamples::PhpClient.new(*client_args)
112
+ values = ['', '$user', "quote'\"\\", "line\n indented\r\n\t$value\\\x00\x1f\x7f ž", false, nil, 0]
113
+ body = "echo json_encode(#{renderer.value(values)});"
114
+ [body, renderer.with_error_handling(body)].each do |source|
115
+ output, status = Open3.capture2e('php', stdin_data: "<?php\n#{source}")
116
+ expect(status).to be_success, output
117
+ expect(JSON.parse(output)).to eq(values)
118
+ end
119
+ expect(renderer.value('public literal')).to eq("'public literal'")
120
+ end
121
+
101
122
  it 'generates shell-parseable curl requests' do
102
123
  source = HaveAPI::ClientExamples::Curl.new(*client_args).request(sample)
103
124
  status, output = syntax_check(%w[bash -n], source, '.sh')
@@ -131,4 +152,175 @@ describe HaveAPI::ClientExamples do
131
152
  expect(content_length).to eq(response_body.bytesize)
132
153
  expect(JSON.parse(response_body)).to include('status' => true)
133
154
  end
155
+
156
+ it 'runs the Ruby handlers with actual client exception classes and propagates unknown errors' do
157
+ context = HaveAPI::ClientExample::Context.new(version: '1', host: 'api.test', base_url: 'https://api.test/',
158
+ version_url: 'https://api.test/v1/', description: {},
159
+ resource_path: ['widget'], action_name: 'update', action:, sample:, detail: :thorough)
160
+ client_type = Class.new(HaveAPI::Client::Client) { attr_accessor :widget }
161
+ client = instance_double(client_type, setup: nil, authenticate: nil)
162
+ allow(HaveAPI::Client).to receive(:new).and_return(client)
163
+ failure_action = Object.new
164
+ def failure_action.client_message(_key, **values)
165
+ values[:message] || 'validation failure'
166
+ end
167
+
168
+ def failure_action.name
169
+ 'update'
170
+ end
171
+ response = HaveAPI::Client::Response.new(failure_action, { status: false, message: 'API failure', errors: { count: ['bad'] } })
172
+ errors = [HaveAPI::Client::ValidationError.new(failure_action, { count: ['bad'] }),
173
+ HaveAPI::Client::ActionFailed.new(response),
174
+ HaveAPI::Client::Authentication::AuthenticationFailed.new('authentication failure'),
175
+ HaveAPI::Client::ProtocolError.new('protocol failure'), RestClient::Exception.new,
176
+ JSON::ParserError.new('invalid JSON')]
177
+ source = HaveAPI::ClientExamples::RubyClient.render(:init, context:).fetch(:code)
178
+ errors.each do |error|
179
+ allow(client).to receive(:setup).and_raise(error)
180
+ expect { execute_ruby(source) }.to output(/./).to_stderr
181
+ end
182
+ expect(client).to have_received(:setup).exactly(errors.length).times
183
+ allow(client).to receive(:setup).and_return(nil)
184
+ allow(client).to receive(:authenticate).and_raise(errors[2])
185
+ auth = HaveAPI::ClientExamples::RubyClient.render(:auth, context:, auth_method: :basic, auth_description: {}).fetch(:code)
186
+ expect { execute_ruby(auth) }.to output(/authentication failure/).to_stderr
187
+ allow(client).to receive(:setup).and_return(nil)
188
+ widget_type = Class.new do
189
+ def update(*); end
190
+ end
191
+ widget = instance_double(widget_type)
192
+ allow(client).to receive(:widget).and_return(widget)
193
+ allow(widget).to receive(:update).and_raise(errors[0])
194
+ action_source = HaveAPI::ClientExamples::RubyClient.render(:example, context:).fetch(:code)
195
+ expect { execute_ruby(action_source) }.to output(/validation failure/).to_stderr
196
+ allow(client).to receive(:setup).and_raise(RuntimeError, 'unknown dependency failure')
197
+ expect { execute_ruby(source) }.to raise_error(RuntimeError, 'unknown dependency failure')
198
+ expect(source.index('ValidationError')).to be < source.index('ActionFailed')
199
+ expect(source).to include('error.response.is_a?(HaveAPI::Client::Response)')
200
+ end
201
+
202
+ it 'runs PHP handlers with real framework error objects and rethrows unknown dependency exceptions' do
203
+ root = File.expand_path('../../../..', __dir__)
204
+ renderer = HaveAPI::ClientExamples::PhpClient.new(*client_args)
205
+ prefix = <<~PHP
206
+ <?php
207
+ require #{renderer.value(File.join(root, 'clients/php/src/Client/Exception/Base.php'))};
208
+ require #{renderer.value(File.join(root, 'clients/php/src/Client/Exception/ValidationError.php'))};
209
+ require #{renderer.value(File.join(root, 'clients/php/src/Client/Exception/ActionFailed.php'))};
210
+ require #{renderer.value(File.join(root, 'clients/php/src/Client/Exception/AuthenticationFailed.php'))};
211
+ require #{renderer.value(File.join(root, 'clients/php/src/Client/Exception/ProtocolError.php'))};
212
+ require #{renderer.value(File.join(root, 'clients/php/src/Client/Response.php'))};
213
+ $wire = (object) ['code' => 400, 'body' => (object) ['status' => false, 'message' => 'API failure', 'errors' => ['count' => ['bad']]]];
214
+ $response = new \\HaveAPI\\Client\\Response(null, $wire, 0);
215
+ PHP
216
+ errors = [
217
+ 'new \\HaveAPI\\Client\\Exception\\ValidationError("update", ["count" => ["bad"]])',
218
+ 'new \\HaveAPI\\Client\\Exception\\ActionFailed($response, "API failure")',
219
+ 'new \\HaveAPI\\Client\\Exception\\AuthenticationFailed("authentication failure")',
220
+ 'new \\HaveAPI\\Client\\Exception\\ProtocolError("protocol failure")'
221
+ ]
222
+ errors.each do |error|
223
+ script = prefix + "$failure = #{error};\n" + renderer.with_error_handling('throw $failure;')
224
+ output, status = Open3.capture2e('php', stdin_data: script)
225
+ expect(status).to be_success, output
226
+ expect(output).not_to be_empty
227
+ end
228
+ script = "#{prefix}$failure = new \\RuntimeException('unknown dependency');\ntry {\n" \
229
+ "#{renderer.with_error_handling('throw $failure;')}" \
230
+ "} catch (\\RuntimeException $error) { echo 'UNKNOWN_PROPAGATED'; }\n"
231
+ output, status = Open3.capture2e('php', stdin_data: script)
232
+ expect(status).to be_success, output
233
+ expect(output).to include('UNKNOWN_PROPAGATED')
234
+ context = HaveAPI::ClientExample::Context.new(version: '1', host: 'api.test', base_url: 'https://api.test/',
235
+ version_url: 'https://api.test/v1/', description: {}, detail: :thorough)
236
+ source = HaveAPI::ClientExamples::PhpClient.render(:auth, context:, auth_method: :oauth2, auth_description: {}).fetch(:code)
237
+ status, output = syntax_check(%w[php -l], "<?php\n#{source}", '.php')
238
+ expect(status).to be_success, output
239
+ expect(source).to include('session_start()', 'requestAuthorizationCode()', 'requestAccessToken()', "$_SESSION['access_token']")
240
+ expect(source).not_to include('Throwable', 'getResponse()->http_status')
241
+ end
242
+
243
+ it 'keeps Ruby token-login literals truthy through the actual runtime option filter' do
244
+ fields = { user: { type: 'String', required: true }, password: { type: 'String', required: true },
245
+ enabled: { type: 'Boolean', required: true },
246
+ custom: { type: 'Custom', nullable: true, required: false,
247
+ validators: { include: { values: [nil, 'public-choice'] } } } }
248
+ continuation = { confirmation: { type: 'Boolean', required: true, validators: { accept: { value: false } } },
249
+ empty: { type: 'Custom', nullable: true, required: false, validators: { accept: { value: nil } } } }
250
+ description = { resources: { token: { actions: { request: { input: { parameters: fields } },
251
+ verify: { input: { parameters: continuation } } } } } }
252
+ definitions = HaveAPI::Params.new(:input, nil)
253
+ definitions.layout = :hash
254
+ definitions.namespace = false
255
+ definitions.custom(:custom, nullable: true, required: false, default: 'public-choice', fill: true, choices: [nil, 'public-choice'])
256
+ expect(definitions.params.first).to be_optional
257
+ expect(definitions.params.first).to be_nullable
258
+ expect(definitions.validate({ custom: nil })).to eq(custom: nil)
259
+ expect(definitions.validate({})).to eq(custom: 'public-choice')
260
+ before = Marshal.dump(description)
261
+ renderer = HaveAPI::ClientExamples::RubyClient.new(*client_args)
262
+ options = renderer.send(:auth_token_credentials, description).transform_keys(&:to_sym)
263
+ expect(options).to include(enabled: true, custom: 'public-choice')
264
+ finite = Marshal.load(Marshal.dump(description))
265
+ finite[:resources][:token][:actions][:request][:input][:parameters][:enabled][:validators] = { include: { values: [false, true] } }
266
+ expect(renderer.send(:auth_token_credentials, finite)).to include('enabled' => true)
267
+ provider = HaveAPI::Client::Authentication::Token.allocate
268
+ provider.instance_variable_set(:@opts, options)
269
+ provider.instance_variable_set(:@desc, description)
270
+ allow(provider).to receive(:login_step).and_return(:done)
271
+ provider.send(:request_token)
272
+ expect(provider).to have_received(:login_step).with(:request, include(enabled: true, custom: 'public-choice'))
273
+ expect(renderer.send(:auth_token_continuations, description).fetch('verify')).to eq('confirmation' => false, 'empty' => nil)
274
+ expect(Marshal.dump(description)).to eq(before)
275
+ php = HaveAPI::ClientExamples::PhpClient.new(*client_args)
276
+ expect(php.send(:auth_token_credentials, description)).to include('enabled' => false, 'custom' => 'public-choice')
277
+ root = File.expand_path('../../../..', __dir__)
278
+ script = <<~PHP
279
+ <?php
280
+ require #{php.value(File.join(root, 'clients/php/src/Client/Authentication/Base.php'))};
281
+ require #{php.value(File.join(root, 'clients/php/src/Client/Authentication/Token.php'))};
282
+ new class(#{php.value(php.send(:auth_token_credentials, description))}) extends \\HaveAPI\\Client\\Authentication\\Token {
283
+ public function __construct($options) { $this->opts = $options; $this->requestToken(); }
284
+ protected function getRequestCredentials() { return ["user", "password", "enabled", "custom"]; }
285
+ protected function runAuthentication($action, $input) { echo json_encode($input); }
286
+ };
287
+ PHP
288
+ output, status = Open3.capture2e('php', stdin_data: script)
289
+ expect(status).to be_success, output
290
+ expect(JSON.parse(output)).to include('enabled' => false, 'custom' => 'public-choice')
291
+ # The same real PHP method loses explicit null before the request boundary.
292
+ dropped_script = script.sub(php.value(php.send(:auth_token_credentials, description)),
293
+ php.value({ 'user' => 'user', 'password' => 'secret', 'enabled' => false, 'custom' => nil }))
294
+ dropped_output, dropped_status = Open3.capture2e('php', stdin_data: dropped_script)
295
+ expect(dropped_status).to be_success, dropped_output
296
+ dropped = JSON.parse(dropped_output)
297
+ expect(dropped).to include('enabled' => false)
298
+ expect(dropped).not_to have_key('custom')
299
+
300
+ js = HaveAPI::ClientExamples::JsClient.new(*client_args)
301
+ expect(js.send(:auth_token_credentials, description)).to include('enabled' => false, 'custom' => nil)
302
+ expect(HaveAPI::ClientExamples::PythonClient.new(*client_args).send(:auth_token_credentials, description))
303
+ .to include('enabled' => false, 'custom' => nil)
304
+ end
305
+
306
+ it 'limits required Ruby false and optional nullable Ruby/PHP null-only login choices' do
307
+ ruby = HaveAPI::ClientExamples::RubyClient
308
+ php = HaveAPI::ClientExamples::PhpClient
309
+ boolean = { type: 'Boolean', required: true, validators: { accept: { value: false } } }
310
+ custom = { type: 'Custom', nullable: true, required: false, validators: { include: { values: [nil] } } }
311
+ context = HaveAPI::ClientExample::Context.new(version: '1', host: 'api.test', base_url: 'https://api.test/',
312
+ version_url: 'https://api.test/v1/', description: {})
313
+ null_acceptance = custom.merge(validators: { accept: { value: nil } })
314
+ [[ruby, boolean], [ruby, custom], [php, custom], [ruby, null_acceptance], [php, null_acceptance],
315
+ [ruby, boolean.merge(required: false)]].each do |renderer, parameter|
316
+ description = { resources: { token: { actions: { request: { input: { parameters: { required_input: parameter } } } } } } }
317
+ %i[simple thorough].each do |detail|
318
+ result = renderer.render(:auth, context: context.with(detail:), auth_method: :token, auth_description: description)
319
+ expect(result).to include(status: :limitation)
320
+ expect(result[:reason]).to include('cannot send')
321
+ end
322
+ end
323
+ fields = { resources: { token: { actions: { request: { input: { parameters: { enabled: boolean } } } } } } }
324
+ expect(php.render(:auth, context:, auth_method: :token, auth_description: fields)[:status]).to eq(:code)
325
+ end
134
326
  end
@@ -2,6 +2,7 @@ require 'spec_helper'
2
2
 
3
3
  class TypeScriptDocumentationAdapterFixture
4
4
  def render(kind:, context:, mode:, auth_method: nil, auth_description: nil); end
5
+ def detail_levels_for(kind:, context:, result:, mode:, auth_method: nil, auth_description: nil); end
5
6
  end
6
7
 
7
8
  describe HaveAPI::ClientExamples::TsGeneratedClient do
@@ -17,12 +18,24 @@ describe HaveAPI::ClientExamples::TsGeneratedClient do
17
18
 
18
19
  it 'adds a separate generated variant while preserving dynamic TypeScript identifiers' do
19
20
  expect(described_class.id).to eq('typescript-generated')
20
- expect(described_class.label).to eq('TypeScript (generated)')
21
+ expect(described_class.label).to eq('TypeScript API client')
21
22
  expect(described_class.code).to eq(:typescript)
22
23
  expect(HaveAPI::ClientExamples::TsClient.id).to eq('typescript-callback')
23
24
  expect(HaveAPI::ClientExamples::TsAsyncClient.id).to eq('typescript-async')
24
25
  end
25
26
 
27
+ it 'delegates meaningful-detail queries with the rendered result, authentication and native mode' do
28
+ result = { status: :code, code: 'API client' }.freeze
29
+ allow(adapter).to receive(:detail_levels_for).and_return([:simple].freeze)
30
+ expect(described_class.detail_levels_for(:auth, context:, result:, auth_method: :basic, auth_description: {})).to eq([:simple])
31
+ expect(adapter).to have_received(:detail_levels_for).with(kind: :auth, context:, result:, mode: :async,
32
+ auth_method: :basic, auth_description: {})
33
+ allow(adapter).to receive(:detail_levels_for).and_return(%i[simple thorough].freeze)
34
+ expect(described_class.detail_levels_for(:example, context:, result:)).to eq(%i[simple thorough])
35
+ allow(adapter).to receive(:detail_levels_for).and_return(nil)
36
+ expect { described_class.detail_levels_for(:example, context:, result:) }.to raise_error(ArgumentError)
37
+ end
38
+
26
39
  it 'is available only for a matching optional profile' do
27
40
  expect(described_class.available?(nil)).to be(false)
28
41
  expect(described_class.available?(context.with(profile: nil))).to be(false)
@@ -42,7 +55,7 @@ describe HaveAPI::ClientExamples::TsGeneratedClient do
42
55
 
43
56
  it 'binds only the supplied authorized sample and preserves limitations' do
44
57
  sample = { request: { label: 'public' } }
45
- result = { status: :limitation, reason: 'The saved SDK does not represent this sample.' }.freeze
58
+ result = { status: :limitation, reason: 'The saved API client does not represent this sample.' }.freeze
46
59
  allow(adapter).to receive(:render).and_return(result)
47
60
  renderer = described_class.new('example.test', 'https://example.test/', '1.0').with_context(context)
48
61
  expect(renderer.example(sample)).to eq(result)
@@ -58,7 +58,7 @@ describe HaveAPI::ClientExample::Profiles do
58
58
  expect(server).to have_received(:describe_version).once
59
59
  end
60
60
 
61
- it 'loads a saved SDK once at registration with explicit package and generator versions' do
61
+ it 'loads a saved API client once at registration with explicit package and generator versions' do
62
62
  File.write(snapshot_path, JSON.generate(api_version: '1.0'))
63
63
  profiles.register('1.0', typescript: { package_name: 'widget-client', package_version: '1.2.3-beta.1+build.4',
64
64
  snapshot_path:, generator_version: HaveAPI::VERSION })
@@ -278,15 +278,20 @@ describe DocAuthFilteringSpec do
278
278
  end
279
279
 
280
280
  it 'does not retain privileged sample data across HTML documentation requests' do
281
- login('admin', 'pass')
282
- get '/v1/'
281
+ require_relative '../../test_support/documentation_oauth_fixture'
282
+ @api = HaveAPI::Server.new(get_opt(:api_module))
283
+ @api.use_version(1)
284
+ users = %w[admin user].to_h { |name| [name, DocAuthFilteringSpec::User.new(1, name)] }
285
+ fixture = DocumentationOAuthFixture.configure(@api, users: { 1 => users })
286
+ @api.mount('/')
287
+ get "#{fixture.origin}/v1/?resource=secure", {}, 'HTTP_COOKIE' => fixture.header_for(@api, version: 1, token: 'admin')
283
288
  expect(last_response.status).to eq(200)
284
289
  expect(last_response.body).to include('ADMIN_ONLY_RESULT')
285
290
 
286
- login('user', 'pass')
287
- get '/v1/'
291
+ get "#{fixture.origin}/v1/?resource=secure", {}, 'HTTP_COOKIE' => fixture.header_for(@api, version: 1, token: 'user')
288
292
  expect(last_response.status).to eq(200)
289
293
  expect(last_response.body).not_to include('ADMIN_ONLY_RESULT')
294
+ expect(fixture.lookups).to eq([[1, 'admin'], [1, 'user']])
290
295
  end
291
296
  end
292
297
  end