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
@@ -30,11 +30,12 @@ describe HaveAPI::ClientExample::Context, '#with' do
30
30
  it 'shares every unchanged field and owns a different snapshot for a new request' do
31
31
  parent = parent_context(8)
32
32
  other_request = parent_context(8)
33
- children = [parent.with(profile:), parent.with(sample:)]
34
- fields = %i[version host base_url version_url description resource_path resource action_name action sample profile]
33
+ changes = [{ profile: }, { sample: }, { detail: :thorough }]
34
+ children = changes.map { |change| parent.with(**change) }
35
+ fields = described_class::FIELDS
35
36
 
36
37
  children.each_with_index do |child, index|
37
- changed = index == 0 ? :profile : :sample
38
+ changed = changes[index].keys.first
38
39
  (fields - [changed]).each do |field|
39
40
  expect(child.public_send(field)).to equal(parent.public_send(field))
40
41
  end
@@ -51,7 +52,7 @@ describe HaveAPI::ClientExample::Context, '#with' do
51
52
  it 'keeps allocations independent of unrelated description size for small changes' do
52
53
  small = parent_context(8)
53
54
  large = parent_context(128)
54
- changes = [{ profile: }, { sample: }]
55
+ changes = [{ profile: }, { sample: }, { detail: :thorough }]
55
56
  [small, large].each do |parent|
56
57
  changes.each { |change| 80.times { parent.with(**change) } }
57
58
  end
@@ -25,6 +25,27 @@ describe HaveAPI::ClientExample::Context do
25
25
  expect(child).to be_frozen
26
26
  end
27
27
 
28
+ it 'defaults to simple and shares owned data when changing the detail' do
29
+ expect(context.detail).to eq(:simple)
30
+ thorough = context.with(detail: :thorough)
31
+ expect(thorough.detail).to eq(:thorough)
32
+ expect(thorough).to be_frozen
33
+ (described_class::FIELDS - [:detail]).each do |field|
34
+ expect(thorough.public_send(field)).to equal(context.public_send(field))
35
+ end
36
+ expect(thorough.with(detail: :simple).detail).to eq(:simple)
37
+ expect(context.detail).to eq(:simple)
38
+ end
39
+
40
+ it 'rejects detail values outside the two explicit symbols in construction and derivation' do
41
+ [nil, false, 'simple', 'thorough', :unknown].each do |detail|
42
+ expect { context.with(detail:) }.to raise_error(ArgumentError, 'detail must be :simple or :thorough')
43
+ expect do
44
+ described_class.new(version: '1', host: 'api.test', base_url: '/', version_url: '/v1/', description: {}, detail:)
45
+ end.to raise_error(ArgumentError, 'detail must be :simple or :thorough')
46
+ end
47
+ end
48
+
28
49
  describe '#with' do
29
50
  let(:fields) do
30
51
  {
@@ -33,7 +54,7 @@ describe HaveAPI::ClientExample::Context do
33
54
  description: { 'resources' => { 'widget' => { 'label' => 'Widget' } } },
34
55
  resource_path: [:widget, 2], resource: { 'label' => 'Widget' },
35
56
  action_name: :show, action: { 'method' => 'GET' },
36
- sample: { 'request' => { 'values' => [0, false, '', nil] } }, profile: Object.new
57
+ sample: { 'request' => { 'values' => [0, false, '', nil] } }, profile: Object.new, detail: :simple
37
58
  }
38
59
  end
39
60
  let(:parent) { described_class.new(**fields) }
@@ -45,7 +66,7 @@ describe HaveAPI::ClientExample::Context do
45
66
  resource_path: [:other, 3], resource: { 'label' => 'Other' },
46
67
  action_name: :update, action: { 'method' => 'POST' },
47
68
  sample: { 'request' => { 'values' => [false, 0, nil, ''] } },
48
- profile: Object.new
69
+ profile: Object.new, detail: :thorough
49
70
  }
50
71
  end
51
72
 
@@ -0,0 +1,175 @@
1
+ require 'spec_helper'
2
+ require 'open3'
3
+ require_relative 'dynamic_fixture'
4
+
5
+ RSpec.describe HaveAPI::ClientExample, '.render' do
6
+ include DynamicExampleFixture
7
+
8
+ let(:renderers) do
9
+ [HaveAPI::ClientExamples::JsClient, HaveAPI::ClientExamples::JsAsyncClient,
10
+ HaveAPI::ClientExamples::TsClient, HaveAPI::ClientExamples::TsAsyncClient,
11
+ HaveAPI::ClientExamples::PythonClient, HaveAPI::ClientExamples::PythonAsyncClient]
12
+ end
13
+
14
+ it 'keeps simple actions focused on the existing api and leaves setup and diagnostics to thorough' do
15
+ renderers.each do |renderer|
16
+ context = dynamic_context
17
+ simple = renderer.render(:example, context:).fetch(:code)
18
+ thorough = renderer.render(:example, context: context.with(detail: :thorough)).fetch(:code)
19
+ expect(simple).to include('api', 'name/with space')
20
+ expect(simple).not_to include('import ', 'credential(', 'os.environ', 'new AsyncClient', 'new CallbackClient',
21
+ 'with Client(', 'with AsyncClient(', 'try:', 'try {', 'response.meta', 'reply.meta()')
22
+ expect(thorough).to include('import ', context.base_url)
23
+ expect(thorough).to include(renderer.code == :python ? 'except HaveAPIError' : 'catch (error)')
24
+ expect(context.detail).to eq(:simple)
25
+ expect(context.sample[:request][:enabled]).to be(false)
26
+ expect(context.sample[:request][:count]).to eq(0)
27
+ expect(context.sample[:request][:nullable]).to be_nil
28
+ end
29
+ end
30
+
31
+ it 'returns identical limitations at both levels without revealing hidden sample or metadata values' do
32
+ contexts = [
33
+ dynamic_context(sample: dynamic_sample.merge(path_params: [7])),
34
+ dynamic_context(sample: dynamic_sample.merge(request: { hidden_parameter: 'private-sample' })),
35
+ dynamic_context(sample: { request: nil, response: nil }),
36
+ dynamic_context(action: dynamic_action.merge(meta: {
37
+ global: { input: { parameters: { secret: { required: true, default: 'private-default' } } } }
38
+ }))
39
+ ]
40
+ renderers.each do |renderer|
41
+ contexts.each do |context|
42
+ simple = renderer.render(:example, context:)
43
+ thorough = renderer.render(:example, context: context.with(detail: :thorough))
44
+ expect(simple).to eq(thorough)
45
+ expect(simple[:status]).to eq(:limitation)
46
+ expect(simple[:reason]).not_to include('private-sample', 'private-default', 'hidden_parameter')
47
+ end
48
+ end
49
+ end
50
+
51
+ it 'validates unsupported required authentication before returning either action fragment' do
52
+ context = dynamic_context(action: dynamic_action.merge(auth: true), description: { authentication: {} })
53
+ renderers.each do |renderer|
54
+ results = %i[simple thorough].map { |detail| renderer.render(:example, context: context.with(detail:)) }
55
+ expect(results[0]).to eq(results[1])
56
+ expect(results[0][:status]).to eq(:limitation)
57
+ end
58
+ end
59
+
60
+ it 'keeps Python async fragments in an explicit async context without adding executable wrappers' do
61
+ source = HaveAPI::ClientExamples::PythonAsyncClient.render(:example, context: dynamic_context).fetch(:code)
62
+ script = 'import ast, sys; ast.parse("async def example():\n" + "".join(" " + line for line in sys.stdin))'
63
+ output, status = Open3.capture2e('python', '-c', script, stdin_data: source)
64
+ expect(status).to be_success, output
65
+ expect(source).to include('# Run inside an async function after initializing api.', 'response = await api.resources')
66
+ expect(source).not_to include('__main__', 'async def ', 'asyncio.run')
67
+ end
68
+
69
+ it 'preserves operation waiting and its timeout meaning at both detail levels' do
70
+ context = dynamic_context(action: dynamic_action.merge(blocking: true))
71
+ [HaveAPI::ClientExamples::PythonClient, HaveAPI::ClientExamples::PythonAsyncClient].each do |renderer|
72
+ %i[simple thorough].each do |detail|
73
+ source = renderer.render(:example, context: context.with(detail:)).fetch(:code)
74
+ expect(source).to include('response.operation.wait(timeout=300, interval=1)', 'does not cancel a server operation')
75
+ end
76
+ end
77
+ end
78
+
79
+ it 'uses simple fragments for positional dynamic, Ruby and PHP renderers without changing their calls' do
80
+ context = dynamic_context
81
+ args = [context.host, context.base_url, context.version, context.resource_path, context.resource,
82
+ context.action_name, context.action]
83
+ renderers.each do |renderer|
84
+ expect(renderer.new(*args).example(context.sample)).to eq(renderer.render(:example, context:))
85
+ end
86
+ sample = { path_params: [7], request: { name: '' }, status: false }
87
+ action = { output: { layout: :hash } }
88
+ ruby = HaveAPI::ClientExamples::RubyClient.new('api.test', 'https://api.test/', '1', ['widget'], {}, 'show', action)
89
+ php = HaveAPI::ClientExamples::PhpClient.new('api.test', 'https://api.test/', '1', ['widget'], {}, 'show', action)
90
+ expect(ruby.example(sample)).to start_with('reply = api.widget.show(7,')
91
+ expect(php.example(sample)).to start_with('$reply = $api->widget->show(7,')
92
+ expect(ruby.example(sample)).not_to include('HaveAPI::Client.new')
93
+ expect(php.example(sample)).not_to include('new \\HaveAPI\\Client')
94
+ end
95
+
96
+ it 'offers callback setup once while preserving real action lookup and reply error handling' do
97
+ [HaveAPI::ClientExamples::JsClient, HaveAPI::ClientExamples::TsClient].each do |renderer|
98
+ context = dynamic_context
99
+ %i[init auth].each do |kind|
100
+ result = renderer.render(kind, context:, auth_method: :basic, auth_description: {})
101
+ expect(result.fetch(:code)).to include('if (!ready)', 'Discovery or authentication failed')
102
+ levels = renderer.detail_levels_for(kind, context:, result:, auth_method: :basic, auth_description: {})
103
+ expect(levels).to eq([:simple])
104
+ expect(levels).to be_frozen
105
+ end
106
+ result = renderer.render(:example, context:)
107
+ expect(result.fetch(:code)).to include('findAction', 'if (!reply.isOk())')
108
+ thorough = renderer.render(:example, context: context.with(detail: :thorough)).fetch(:code)
109
+ expect(thorough).to include('try {', 'catch (error)', 'reply.meta()')
110
+ expect(renderer.detail_levels_for(:example, context:, result:)).to eq(%i[simple thorough])
111
+ end
112
+ end
113
+
114
+ it 'offers async discovery and Python context-manager error handling for setup and actions' do
115
+ [HaveAPI::ClientExamples::JsAsyncClient, HaveAPI::ClientExamples::TsAsyncClient,
116
+ HaveAPI::ClientExamples::PythonClient, HaveAPI::ClientExamples::PythonAsyncClient].each do |renderer|
117
+ context = dynamic_context
118
+ %i[init auth example].each do |kind|
119
+ result = renderer.render(kind, context:, auth_method: :basic, auth_description: {})
120
+ levels = renderer.detail_levels_for(kind, context:, result:, auth_method: :basic, auth_description: {})
121
+ expect(levels).to eq(%i[simple thorough])
122
+ expect(levels).to be_frozen
123
+ thorough = renderer.render(kind, context: context.with(detail: :thorough),
124
+ auth_method: :basic, auth_description: {}).fetch(:code)
125
+ expect(thorough).to include(renderer.code == :python ? 'except HaveAPIError' : 'error instanceof AsyncError')
126
+ end
127
+ end
128
+ end
129
+
130
+ it 'offers Ruby and PHP real error handling while retaining simple direct calls' do
131
+ context = dynamic_context(resource_path: ['widget'], action_name: 'show')
132
+ [HaveAPI::ClientExamples::RubyClient, HaveAPI::ClientExamples::PhpClient].each do |renderer|
133
+ %i[init auth example].each do |kind|
134
+ result = renderer.render(kind, context:, auth_method: :basic, auth_description: {})
135
+ expect(renderer.detail_levels_for(kind, context:, result:, auth_method: :basic)).to eq(%i[simple thorough])
136
+ source = renderer.render(kind, context: context.with(detail: :thorough),
137
+ auth_method: :basic, auth_description: {}).fetch(:code)
138
+ expect(source).to include(renderer.code == :ruby ? 'api.setup' : '$api->setup()')
139
+ expect(source).to include('ValidationError', 'ActionFailed', renderer.code == :ruby ? 'rescue' : 'catch')
140
+ end
141
+ expect(context.detail).to eq(:simple)
142
+ expect(context.sample).to eq(dynamic_sample)
143
+ end
144
+ end
145
+
146
+ it 'advertises only Simple for built-in limitations without probing another render' do
147
+ context = dynamic_context(sample: dynamic_sample.merge(path_params: [7]))
148
+ results = renderers.to_h { |renderer| [renderer, renderer.render(:example, context:)] }
149
+ renderers.each do |renderer|
150
+ result = results.fetch(renderer)
151
+ expect(result).to include(status: :limitation)
152
+ allow(renderer).to receive(:render).and_call_original
153
+ expect(renderer.detail_levels_for(:example, context:, result:)).to eq([:simple])
154
+ expect(renderer).not_to have_received(:render)
155
+ end
156
+ [HaveAPI::ClientExamples::RubyClient, HaveAPI::ClientExamples::RubyCli].each do |renderer|
157
+ result = renderer.render(:auth, context:, auth_method: :oauth2, auth_description: {})
158
+ expect(result.fetch(:code)).to include('not supported')
159
+ expect(renderer.detail_levels_for(:auth, context:, result:, auth_method: :oauth2,
160
+ auth_description: {})).to eq([:simple])
161
+ end
162
+ end
163
+
164
+ it 'authenticates protected Ruby and PHP programs with advertised literal existing credentials' do
165
+ context = dynamic_context(action: dynamic_action.merge(auth: true), resource_path: ['widget'], action_name: 'show', detail: :thorough)
166
+ [HaveAPI::ClientExamples::RubyClient, HaveAPI::ClientExamples::PhpClient].each do |renderer|
167
+ result = renderer.render(:example, context:)
168
+ expect(result[:status]).to eq(:code)
169
+ expect(result[:code]).to include('example-token')
170
+ expect(result[:code].index('example-token')).to be < result[:code].index(renderer.code == :ruby ? 'reply = api.widget' : '$reply = $api->widget')
171
+ unsupported = renderer.render(:example, context: context.with(description: { authentication: {} }))
172
+ expect(unsupported).to include(status: :limitation)
173
+ end
174
+ end
175
+ end
@@ -1,9 +1,10 @@
1
1
  # Dynamic example verification
2
2
 
3
3
  The six JavaScript, TypeScript and Python variants are ordinary server renderers.
4
- They require no generated-SDK profile. Their examples use exact public lookups,
5
- mounted base URLs and explicit API versions. Credentials come from environment
6
- variables in the example process; no example executes in a documentation request.
4
+ They require no API client profile. Their examples use exact public lookups,
5
+ mounted base URLs and explicit API versions. Credentials are public example
6
+ literals: `user`, `secret`, `example-token` and `example-access-token`. Examples
7
+ do not execute during a documentation request.
7
8
 
8
9
  Run the focused checks in the prepared top-level Nix environment:
9
10
 
@@ -16,9 +17,11 @@ bundle exec rspec spec/client_examples/dynamic_js_spec.rb \
16
17
  ```
17
18
 
18
19
  The unit checks parse the actual generated source and compile both TypeScript
19
- variants against emitted public declarations. Prepare the JavaScript build and
20
- its declared development dependencies first. The Rack fixture check renders real
21
- mounted HTML and verifies the extraction boundary and request/result fixtures;
20
+ variants at both detail levels against emitted public declarations. Simple
21
+ action fragments receive a declared initialized `api` in the compiler fixture;
22
+ Python async fragments are parsed in an async function. Prepare the JavaScript
23
+ build and its declared development dependencies first. The Rack fixture check
24
+ renders real mounted HTML and verifies extraction and request/result fixtures;
22
25
  it opens no listening socket and does not install packages.
23
26
 
24
27
  `make test-documentation-python` runs the supplemental native Python request
@@ -31,12 +34,11 @@ transport. It installs nothing and is separate from ordinary Ruby spec discovery
31
34
  The focused JavaScript specs cross-check the built callback and async query/body
32
35
  serializers with a mocked transport. The full `make test` includes both checks.
33
36
 
34
- Python token-login examples read required request fields from JSON values in
35
- `HAVEAPI_AUTH_*` environment variables. For example, an API that requires
36
- `lifetime` reads `HAVEAPI_AUTH_lifetime`. The examples omit optional lifetime and
37
- interval fields. The acceptance fixture explicitly chooses `"fixed"` from its
38
- advertised lifetime values and omits optional interval. The Python challenge
39
- object supplies the continuation token.
37
+ Token-login examples use advertised typed literal input maps. Python selects
38
+ continuations through `step.next_action` and calls `step.submit(values)`;
39
+ JavaScript/TypeScript use their actual action callback argument. The runtime
40
+ propagates intermediate tokens. The acceptance fixture requires fixed lifetime,
41
+ credential request and verification before authenticated discovery.
40
42
 
41
43
  `dynamic_acceptance.rb` checks the installed packages. Supply the built npm and
42
44
  Python artifacts, the compiler and Node declaration directories, and a directory
@@ -54,20 +56,51 @@ bundle exec ruby spec/client_examples/dynamic_acceptance.rb \
54
56
  ```
55
57
 
56
58
  Repeat with the retained Python sdist and a separate evidence directory. Replace
57
- artifact versions with the release being tested. The test installs the supplied
58
- packages without checkout imports, starts its disposable mounted fixture, extracts
59
- the code from the served HTML, compiles TypeScript with strict checking and runs
60
- the snippets with the installed clients. It checks discovery, Basic/existing
61
- token/OAuth credentials, a deterministic token challenge, success/failure and
62
- request receipts preserving exact names, path order, Unicode, nested Custom
63
- values, omission, null, empty strings, false and zero. Output and extracted
64
- programs remain in the evidence directory. The fixture stops and disposable
65
- consumer is removed when the operation exits.
59
+ artifact versions with the release being tested. The harness installs the
60
+ supplied packages without checkout imports and starts its disposable mounted
61
+ fixture. It extracts examples from the overview, authentication and selected
62
+ nested-resource pages, requesting thorough detail. Each group's escaped
63
+ `data-example-details` map selects thorough when declared and Simple otherwise.
64
+ Missing or malformed declarations, duplicate panels, limitations and missing
65
+ code fail the run.
66
+
67
+ The fixture selects exactly 36 panels: six variants, each with initialization,
68
+ Basic, token, OAuth2, successful action and failed action. Eight callback
69
+ JavaScript/TypeScript initialization and provider panels are Simple; the other
70
+ 28 are thorough. The harness compiles TypeScript with strict checking and runs
71
+ the exact extracted code with the installed clients. It preserves source,
72
+ including its stdout messages. This does not establish complete-program
73
+ acceptance of all Simple action fragments.
74
+
75
+ Checks cover discovery, Basic/existing token/OAuth credentials, a deterministic
76
+ token challenge, success/failure and request receipts. Receipts preserve exact
77
+ names, path order, Unicode, nested Custom values, omission, null, empty strings,
78
+ false and zero. Output and extracted programs remain in the evidence directory.
79
+ The fixture stops and the disposable consumer is removed when the operation
80
+ exits.
66
81
 
67
82
  Each snippet's output is saved before completion checks, including nonzero
68
- exits. Token panels have separate `example-N-challenge.log` files for the login
69
- flow without an existing token. `fixture.log` records request method, path and
70
- HTTP status; the trace excludes headers, query strings and request bodies.
83
+ exits. Each of the 36 programs has its own before/after receipt snapshot and
84
+ saves its action/event delta beside the log. The six token-auth programs each
85
+ complete their challenge flow in that run. The fixture receipt endpoint returns
86
+ separate `actions` and `events` collections. Events contain only method, path,
87
+ status, provider/authenticated state and token request/verification completion.
88
+ They exclude credentials, tokens, challenge values, headers, queries and extra
89
+ request bodies. `fixture.log` records method, path and status.
90
+
91
+ Simple callback initialization and authentication code omits success-only
92
+ stdout markers. Completion requires successful mounted/versioned OPTIONS
93
+ discovery, authenticated through the expected provider where applicable.
94
+ Anonymous discovery alone is insufficient for authentication examples. Each
95
+ token program requires ordered request, verification and authenticated-discovery
96
+ events from that run. The twelve protected action programs require existing-token
97
+ authentication and discovery; challenge events cannot substitute for that proof.
98
+
99
+ The harness requires stdout markers where the selected source emits them, and
100
+ rejects failed callbacks or nonzero processes. Action samples retain exactly
101
+ one receipt, exact path/input values and DOC_SUCCESS/DOC_FAILURE.
102
+ `result.json` records group, variant and panel IDs with requested/effective
103
+ detail for all 36 programs, including `challenge_completions: 6`.
71
104
 
72
105
  JavaScript list input and nested GET/OPTIONS query values produce a visible
73
106
  limitation; HEAD input retains the client's JSON body behavior. Python GET
@@ -79,5 +112,5 @@ methods produce a limitation. Required global or object input metadata also
79
112
  produces a limitation because the sample format has no metadata input channel.
80
113
  Optional metadata needs no invented values. Prose-only and incomplete samples do
81
114
  not become fabricated calls. Callback examples print the raw response; async
82
- examples use the actual invocation receipt. Operation waiting is shown only through the client's existing
83
- API, and a timeout is not a cancellation of the server operation.
115
+ examples use the actual invocation receipt. Operation waiting uses the client's
116
+ existing API; a timeout does not cancel the server operation.
@@ -11,6 +11,7 @@ require_relative '../../test_support/dynamic_documentation_api'
11
11
 
12
12
  module DynamicDocumentationAcceptance
13
13
  VARIANTS = %w[javascript-callback javascript-async typescript-callback typescript-async python-sync python-async].freeze
14
+ PAGES = { overview: '', authentication: '?page=authentication', resource: '?resource=constructor.resources' }.freeze
14
15
 
15
16
  def self.run!(*command, chdir:, env: {}, log_path: nil)
16
17
  output, status = Open3.capture2e(env, *command, chdir:)
@@ -28,12 +29,150 @@ module DynamicDocumentationAcceptance
28
29
  response.body
29
30
  end
30
31
 
31
- def self.extract(html)
32
- html.scan(%r{<div id="([^"]+)">\s*<h[46]>[^<]*</h[46]>\s*<pre><code class="([^"]+)">(.*?)</code></pre>}m).filter_map do |id, language, escaped|
33
- variant = VARIANTS.find { |name| id.end_with?("-#{name}") }
34
- next unless variant
32
+ GROUPS = %w[init-examples auth-basic-examples auth-token-examples auth-oauth2-examples
33
+ example-constructor.resources-new-0-examples example-constructor.resources-new-1-examples].freeze
35
34
 
36
- { id:, variant:, language:, source: CGI.unescapeHTML(escaped) }
35
+ class DetailMap < Hash
36
+ def []=(key, value)
37
+ raise "Duplicate example detail declaration: #{key}" if has_key?(key)
38
+
39
+ super
40
+ end
41
+ end
42
+
43
+ def self.attributes(source)
44
+ entries = source.scan(/([a-z][a-z0-9-]*)="([^"]*)"/)
45
+ raise 'Duplicate example HTML attribute' unless entries.map(&:first).uniq.length == entries.length
46
+
47
+ entries.to_h.transform_values { |value| CGI.unescapeHTML(value) }
48
+ end
49
+
50
+ def self.extract(html, detail: :thorough)
51
+ raise 'Invalid requested example detail' unless %i[simple thorough].include?(detail)
52
+
53
+ groups = html.scan(%r{<div\b([^>]*\bdata-example(?=\s|>)[^>]*)>(.*?)</div>}m)
54
+ raise 'Malformed example group' unless groups.length == html.scan(/<div\b[^>]*\bdata-example(?=\s|>)/).length
55
+
56
+ ids = []
57
+ groups.flat_map do |attributes, body|
58
+ group = self.attributes(attributes)
59
+ id = group.fetch('id')
60
+ raise "Duplicate example group: #{id}" if ids.include?(id)
61
+
62
+ ids << id
63
+ declarations = JSON.parse(group.fetch('data-example-details'), object_class: DetailMap)
64
+ unless declarations.is_a?(Hash) && declarations.all? do |client, levels|
65
+ client.is_a?(String) && [%w[simple], %w[simple thorough]].include?(levels)
66
+ end
67
+ raise "Invalid example detail declarations: #{id}"
68
+ end
69
+
70
+ actual = body.scan(%r{<details\b([^>]*\bdata-example-panel\b[^>]*)>(.*?)</details>}m)
71
+ VARIANTS.map do |variant|
72
+ levels = declarations.fetch(variant)
73
+ effective = levels.include?(detail.to_s) ? detail.to_s : 'simple'
74
+ variants = actual.select { |data, _code| self.attributes(data)['data-client'] == variant }
75
+ unless variants.map { |data, _code| self.attributes(data)['data-detail'] }.sort == levels.sort
76
+ raise "Missing, duplicate or undeclared example panels: #{id}/#{variant}"
77
+ end
78
+
79
+ data, code_body = variants.find { |data, _code| self.attributes(data)['data-detail'] == effective }
80
+ panel = self.attributes(data)
81
+ code = code_body.scan(%r{<pre><code class="([^"]+)">(.*?)</code></pre>}m)
82
+ unless code.length == 1 && code[0][0] == variant.split('-').first && !code_body.include?('class="client-limitation"')
83
+ raise "Expected one code panel: #{id}/#{variant}/#{effective}"
84
+ end
85
+
86
+ { group: id, id: panel.fetch('id'), variant:, language: code[0][0], requested_detail: detail,
87
+ detail: effective.to_sym, source: CGI.unescapeHTML(code[0][1]) }
88
+ end
89
+ end
90
+ end
91
+
92
+ def self.validate_coverage!(panels)
93
+ VARIANTS.each do |variant|
94
+ selected = panels.select { |panel| panel[:variant] == variant }
95
+ raise "Incomplete rendered coverage for #{variant}" unless selected.map { |panel| panel[:group] }.sort == GROUPS.sort
96
+
97
+ selected.each do |panel|
98
+ singleton = %w[javascript-callback typescript-callback].include?(variant) && !panel[:group].start_with?('example-')
99
+ expected = singleton ? :simple : :thorough
100
+ unless panel[:requested_detail] == :thorough && panel[:detail] == expected
101
+ raise "Unexpected effective example detail: #{panel[:group]}/#{variant}"
102
+ end
103
+ end
104
+ end
105
+ unless panels.length == 36 && panels.count { |panel| panel[:detail] == :simple } == 8 &&
106
+ panels.map { |panel| panel[:id] }.uniq.length == panels.length
107
+ raise 'Unexpected rendered panel matrix'
108
+ end
109
+ end
110
+
111
+ def self.receipt_delta(before, after)
112
+ unless [before, after].all? { |receipt| receipt.is_a?(Hash) && receipt.keys.sort == %w[actions events] && receipt.values.all?(Array) }
113
+ raise 'Invalid fixture receipt envelope'
114
+ end
115
+
116
+ before.to_h do |kind, previous|
117
+ current = after.fetch(kind)
118
+ raise 'Fixture receipts changed before the run boundary' unless current.take(previous.length) == previous
119
+
120
+ [kind, current.drop(previous.length)]
121
+ end
122
+ end
123
+
124
+ def self.authenticated_discovery(events, provider)
125
+ events.each_index.find do |index|
126
+ event = events[index]
127
+ request = event['type'] == 'request' && event['method'] == 'OPTIONS' && event['path'] == '/mount/v2/' &&
128
+ event['status'] == 200 && event['provider'] == provider && event['authenticated'] == !provider.nil?
129
+ request && (provider.nil? || events.take(index).any? do |previous|
130
+ previous['type'] == 'authentication' && previous['provider'] == provider && previous['authenticated'] == true
131
+ end)
132
+ end
133
+ end
134
+
135
+ def self.verify_completion!(panel, output, delta)
136
+ raise "Failed callback: #{panel[:id]}" if output.include?('Discovery or authentication failed')
137
+
138
+ actions = delta.fetch('actions')
139
+ events = delta.fetch('events')
140
+ if panel[:group].start_with?('example-')
141
+ raise "Rendered action did not execute once: #{panel[:id]}" unless actions.length == 1
142
+
143
+ receipt = actions.first
144
+ expected = { 'name' => '', 'enabled' => false, 'nullable' => nil, 'payload' => DynamicDocumentationAPI::PAYLOAD }
145
+ raise "Changed request values: #{receipt.inspect}" unless receipt['input'].except('count') == expected
146
+ raise 'Changed mounted path arguments' unless receipt['path'].map(&:to_s) == ['7', 'name with space']
147
+
148
+ count = panel[:group] == GROUPS.last ? -1 : 0
149
+ raise 'Changed sample success/failure input' unless receipt['input']['count'] == count
150
+
151
+ unless authenticated_discovery(events, 'token') && events.none? { |event| event['type'].start_with?('token_') }
152
+ raise "Existing-token action authentication did not complete: #{panel[:id]}"
153
+ end
154
+
155
+ marker = count < 0 ? 'DOC_FAILURE' : 'DOC_SUCCESS'
156
+ raise "Wrong result/error handling: #{panel[:id]}\n#{output}" unless output.include?(marker)
157
+ else
158
+ raise "Unexpected action during setup: #{panel[:id]}" unless actions.empty?
159
+
160
+ provider = panel[:group] == 'init-examples' ? nil : panel[:group].delete_prefix('auth-').delete_suffix('-examples')
161
+ discovery = authenticated_discovery(events, provider)
162
+ raise "Discovery/authentication did not complete: #{panel[:id]}" unless discovery
163
+
164
+ if provider == 'token'
165
+ sequence = events.map { |event| event['type'] }
166
+ request = sequence.index('token_request')
167
+ verification = sequence.index('token_verification')
168
+ unless provider == 'token' && request && verification && request < verification && verification < discovery
169
+ raise "Token challenge did not complete: #{panel[:id]}"
170
+ end
171
+ end
172
+ if panel[:detail] == :thorough
173
+ marker = provider ? 'Authenticated' : 'Discovery complete'
174
+ raise "Missing completion output: #{panel[:id]}\n#{output}" unless output.include?(marker)
175
+ end
37
176
  end
38
177
  end
39
178
 
@@ -63,31 +202,20 @@ module DynamicDocumentationAcceptance
63
202
  end
64
203
  end
65
204
  reader = Thread.new { File.write(File.join(output, 'fixture.log'), server_output.read) }
66
- html = request("#{base}/mount/v2/")
67
- File.write(File.join(output, 'documentation.html'), html)
68
- panels = extract(html)
69
- raise 'No actual HTML code panels extracted' if panels.empty?
70
-
71
- VARIANTS.each do |variant|
72
- selected = panels.select { |panel| panel[:variant] == variant }
73
- raise "Incomplete rendered coverage for #{variant}" unless selected.length >= 4 &&
74
- selected.any? { |panel| panel[:id].start_with?('init-') } &&
75
- selected.any? { |panel| panel[:id].start_with?('auth-basic-') } &&
76
- selected.count { |panel| panel[:id].start_with?('example-') } >= 2
205
+ panels = PAGES.flat_map do |page, query|
206
+ html = request("#{base}/mount/v2/#{query}")
207
+ File.write(File.join(output, "documentation-#{page}.html"), html)
208
+ extract(html)
77
209
  end
210
+ validate_coverage!(panels)
211
+ executions = []
78
212
  panels.each_with_index do |panel, index|
79
213
  extension = { 'typescript' => '.ts', 'javascript' => '.mjs', 'python' => '.py' }.fetch(panel[:language])
80
214
  name = "example-#{index}#{extension}"
81
215
  path = File.join(consumer, name)
82
216
  File.write(path, panel[:source])
83
217
  File.write(File.join(output, name), panel[:source])
84
- env = { 'NODE_PATH' => nil, 'PYTHONPATH' => nil, 'PYTHONHOME' => nil,
85
- 'HAVEAPI_USER' => 'fixture-user', 'HAVEAPI_PASSWORD' => 'fixture-password',
86
- 'HAVEAPI_TOKEN' => 'fixture-token', 'HAVEAPI_ACCESS_TOKEN' => 'fixture-access-token',
87
- 'HAVEAPI_AUTH_user' => JSON.generate('fixture-user'),
88
- 'HAVEAPI_AUTH_password' => JSON.generate('fixture-password'),
89
- 'HAVEAPI_AUTH_lifetime' => JSON.generate('fixed'),
90
- 'HAVEAPI_AUTH_scope' => JSON.generate('all'), 'HAVEAPI_CHALLENGE_code' => JSON.generate('123456') }
218
+ env = { 'NODE_PATH' => nil, 'PYTHONPATH' => nil, 'PYTHONHOME' => nil }
91
219
  if extension == '.ts'
92
220
  config = {
93
221
  compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true,
@@ -104,29 +232,21 @@ module DynamicDocumentationAcceptance
104
232
  end
105
233
  before = JSON.parse(request("#{base}/__acceptance_receipts"))
106
234
  command = panel[:language] == 'python' ? [python, path] : ['node', path]
107
- result = run!(*command, chdir: consumer, env:, log_path: File.join(output, "example-#{index}.log"))
108
- if panel[:id].start_with?('auth-token-')
109
- challenge_result = run!(*command, chdir: consumer, env: env.merge('HAVEAPI_TOKEN' => nil),
110
- log_path: File.join(output, "example-#{index}-challenge.log"))
111
- raise "Token challenge did not complete: #{panel[:id]}\n#{challenge_result}" unless challenge_result.include?('Authenticated')
112
- end
113
- after = JSON.parse(request("#{base}/__acceptance_receipts"))
114
- if panel[:id].start_with?('example-')
115
- raise "Rendered action did not execute: #{panel[:id]}\n#{result}" unless after.length == before.length + 1
116
-
117
- receipt = after.last
118
- expected = { 'name' => '', 'enabled' => false, 'nullable' => nil, 'payload' => DynamicDocumentationAPI::PAYLOAD }
119
- raise "Changed request values: #{receipt.inspect}" unless receipt['input'].except('count') == expected
120
- raise 'Changed mounted path arguments' unless receipt['path'].map(&:to_s) == ['7', 'name with space']
121
-
122
- marker = receipt['input']['count'] < 0 ? 'DOC_FAILURE' : 'DOC_SUCCESS'
123
- raise "Wrong result/error handling: #{panel[:id]}\n#{result}" unless result.include?(marker)
124
- else
125
- marker = panel[:id].start_with?('init-') ? 'Discovery complete' : 'Authenticated'
126
- raise "Discovery/authentication did not complete: #{panel[:id]}\n#{result}" unless result.include?(marker)
235
+ begin
236
+ result = run!(*command, chdir: consumer, env:, log_path: File.join(output, "example-#{index}.log"))
237
+ ensure
238
+ after = JSON.parse(request("#{base}/__acceptance_receipts"))
239
+ delta = receipt_delta(before, after)
240
+ File.write(File.join(output, "example-#{index}-receipts.json"), JSON.pretty_generate(delta))
127
241
  end
242
+ verify_completion!(panel, result, delta)
243
+ executions << panel.except(:source).merge(challenge_completion: panel[:group] == 'auth-token-examples')
128
244
  end
129
- File.write(File.join(output, 'result.json'), JSON.pretty_generate({ panels: panels.length, variants: VARIANTS, status: 'passed' }))
245
+ raise 'Incomplete token challenge coverage' unless executions.count { |execution| execution[:challenge_completion] } == VARIANTS.length
246
+
247
+ File.write(File.join(output, 'result.json'), JSON.pretty_generate({ panels: panels.length, variants: VARIANTS,
248
+ details: panels.group_by { |panel| panel[:detail] }.transform_values(&:length),
249
+ challenge_completions: VARIANTS.length, executions:, status: 'passed' }))
130
250
  ensure
131
251
  Process.kill('TERM', wait.pid) if wait.alive?
132
252
  wait.value