haveapi 0.29.9 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Rakefile +1 -0
  4. data/doc/client-examples.md +96 -0
  5. data/haveapi.gemspec +1 -1
  6. data/lib/haveapi/client_example.rb +47 -1
  7. data/lib/haveapi/client_examples/dynamic_helpers.rb +129 -0
  8. data/lib/haveapi/client_examples/go_client.rb +37 -0
  9. data/lib/haveapi/client_examples/js_async_client.rb +14 -0
  10. data/lib/haveapi/client_examples/js_client.rb +179 -135
  11. data/lib/haveapi/client_examples/python_async_client.rb +14 -0
  12. data/lib/haveapi/client_examples/python_client.rb +172 -0
  13. data/lib/haveapi/client_examples/rust_client.rb +59 -0
  14. data/lib/haveapi/client_examples/ts_async_client.rb +14 -0
  15. data/lib/haveapi/client_examples/ts_client.rb +14 -0
  16. data/lib/haveapi/documentation_clients.rb +126 -0
  17. data/lib/haveapi/documentation_context.rb +47 -0
  18. data/lib/haveapi/public/js/highlight-LICENSE +29 -0
  19. data/lib/haveapi/public/js/highlight.pack.js +2 -2
  20. data/lib/haveapi/public/js/highlighter.js +16 -6
  21. data/lib/haveapi/public/js/main.js +9 -1
  22. data/lib/haveapi/public/js/nojs-tabs.js +37 -3
  23. data/lib/haveapi/server.rb +34 -0
  24. data/lib/haveapi/version.rb +1 -1
  25. data/lib/haveapi/views/main_layout.erb +11 -0
  26. data/lib/haveapi/views/version_page/auth_body.erb +3 -3
  27. data/lib/haveapi/views/version_page/client_auth.erb +2 -2
  28. data/lib/haveapi/views/version_page/client_example.erb +2 -11
  29. data/lib/haveapi/views/version_page/client_init.erb +2 -2
  30. data/lib/haveapi/views/version_page/client_result.erb +5 -0
  31. data/lib/haveapi/views/version_page/resource_body.erb +3 -7
  32. data/lib/haveapi/views/version_page.erb +13 -9
  33. data/spec/client_examples/assets/highlighter_test.cjs +26 -0
  34. data/spec/client_examples/assets/tabs_test.cjs +54 -0
  35. data/spec/client_examples/context_spec.rb +68 -0
  36. data/spec/client_examples/dynamic_acceptance.md +83 -0
  37. data/spec/client_examples/dynamic_acceptance.rb +151 -0
  38. data/spec/client_examples/dynamic_acceptance_spec.rb +76 -0
  39. data/spec/client_examples/dynamic_fixture.rb +36 -0
  40. data/spec/client_examples/dynamic_js_spec.rb +195 -0
  41. data/spec/client_examples/dynamic_metadata_spec.rb +52 -0
  42. data/spec/client_examples/dynamic_mounted_paths_spec.rb +65 -0
  43. data/spec/client_examples/dynamic_python_spec.rb +123 -0
  44. data/spec/client_examples/go_spec.rb +57 -0
  45. data/spec/client_examples/profiles_spec.rb +95 -0
  46. data/spec/client_examples/rust_client_spec.rb +75 -0
  47. data/spec/client_examples/syntax_spec.rb +2 -1
  48. data/spec/documentation/auth_filtering_spec.rb +12 -0
  49. data/spec/documentation/client_examples_spec.rb +82 -0
  50. data/spec/packaging_spec.rb +13 -4
  51. data/test_support/dynamic_documentation_api.rb +171 -0
  52. data/test_support/dynamic_protocol_spec.rb +133 -0
  53. data/test_support/dynamic_python_login_spec.rb +75 -0
  54. metadata +34 -4
  55. data/lib/haveapi/client_examples/fs_client.rb +0 -119
@@ -3,6 +3,7 @@ require 'redcarpet'
3
3
  require 'tilt'
4
4
  require 'cgi'
5
5
  require 'haveapi/hooks'
6
+ require 'haveapi/documentation_clients'
6
7
 
7
8
  module HaveAPI
8
9
  class Server
@@ -250,6 +251,29 @@ module HaveAPI
250
251
  "#{scheme}://#{request.env['HTTP_HOST']}"
251
252
  end
252
253
 
254
+ def documentation_context(client = nil, **changes)
255
+ @documentation_context ||= HaveAPI::ClientExample::Context.new(
256
+ version: @v, host:, base_url: "#{base_url}#{root.end_with?('/') ? root : "#{root}/"}",
257
+ version_url: "#{base_url}#{root.end_with?('/') ? root : "#{root}/"}v#{@v}/",
258
+ description: @help
259
+ )
260
+ profile = if client
261
+ settings.api_server.documentation_client_profiles(@v)[client.code&.to_sym]
262
+ end
263
+ @documentation_context.with(profile:, **changes)
264
+ end
265
+
266
+ def documentation_clients
267
+ HaveAPI::ClientExample.clients.select { |client| client.available?(documentation_context(client)) }
268
+ end
269
+
270
+ def documentation_panels(kind, auth_method: nil, auth_description: nil, **changes)
271
+ documentation_clients.filter_map do |client|
272
+ result = client.render(kind, context: documentation_context(client, **changes), auth_method:, auth_description:)
273
+ [client, result] if result
274
+ end
275
+ end
276
+
253
277
  def host
254
278
  request.env['HTTP_HOST'].split(':').first
255
279
  end
@@ -312,11 +336,20 @@ module HaveAPI
312
336
  @extensions = []
313
337
  @action_state_auth = :backend
314
338
  @validation_error_http_status = nil
339
+ @documentation_clients = HaveAPI::ClientExample::Profiles.new
315
340
  @default_locale = :en
316
341
  self.available_locales = HaveAPI::I18n.available_locales
317
342
  self.locale_header = 'Accept-Language'
318
343
  end
319
344
 
345
+ def documentation_clients(version, **profiles)
346
+ @documentation_clients.register(version, **profiles)
347
+ end
348
+
349
+ def documentation_client_profiles(version)
350
+ @documentation_clients.for_version(version)
351
+ end
352
+
320
353
  def available_locales=(locales)
321
354
  @available_locales = Array(locales)
322
355
  allow_i18n_locales(@available_locales)
@@ -407,6 +440,7 @@ module HaveAPI
407
440
  # All routes are mounted under prefix `path`.
408
441
  # If no default version is set, the last included version is used.
409
442
  def mount(prefix = '/')
443
+ @documentation_clients.mount(@versions)
410
444
  @root = prefix
411
445
 
412
446
  @sinatra = Sinatra.new do
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.29.9'.freeze
3
+ VERSION = '0.30.0'.freeze
4
4
  end
@@ -54,6 +54,17 @@
54
54
  .tab-hidden {
55
55
  display: none;
56
56
  }
57
+ .nav-tabs {
58
+ display: flex;
59
+ flex-wrap: wrap;
60
+ }
61
+ .nav-tabs > li {
62
+ float: none;
63
+ }
64
+ .nav-tabs a:focus {
65
+ outline: 2px solid #337ab7;
66
+ outline-offset: -2px;
67
+ }
57
68
  pre {
58
69
  overflow: auto;
59
70
  }
@@ -32,9 +32,9 @@
32
32
  <div id="<%= baseid %>-tabbar"></div>
33
33
 
34
34
  <div id="<%= baseid %>-examples">
35
- <% HaveAPI::ClientExample.clients.each_with_index do |client, i| %>
36
- <div id="<%= "#{baseid}-#{i}" %>">
37
- <%= erb :"version_page/client_auth", locals: {client: client, method: name, desc: info} %>
35
+ <% documentation_panels(:auth, auth_method: name, auth_description: info).each do |client, result| %>
36
+ <div id="<%= escape_html("#{baseid}-#{client.id}") %>">
37
+ <%= erb :"version_page/client_auth", locals: {client: client, result: result} %>
38
38
  </div>
39
39
  <% end %>
40
40
  </div>
@@ -1,2 +1,2 @@
1
- <h4><%= client.label %></h4>
2
- <pre><code class="<%= client.code %>"><%= escape_html(client.auth(host, base_url, api_version, method, desc)) %></code></pre>
1
+ <h4><%= escape_html(client.label) %></h4>
2
+ <%= erb :"version_page/client_result", locals: {client: client, result: result} %>
@@ -1,11 +1,2 @@
1
- <h6><%= client.label %></h6>
2
- <% sample = client.new(host, base_url, api_version, r_name, resource, a_name, action) %>
3
- <% if sample.respond_to?(:example) %>
4
- <pre><code class="<%= client.code %>"><%= escape_html(sample.example(example)) %></code></pre>
5
-
6
- <% else %>
7
- <h6>Request</h6>
8
- <pre><code class="<%= client.code %>"><%= escape_html(sample.request(example)) %></code></pre>
9
- <h6>Response</h6>
10
- <pre><code class="<%= client.code %>"><%= escape_html(sample.response(example)) %></code></pre>
11
- <% end %>
1
+ <h6><%= escape_html(client.label) %></h6>
2
+ <%= erb :"version_page/client_result", locals: {client: client, result: result} %>
@@ -1,2 +1,2 @@
1
- <h4><%= client.label %></h4>
2
- <pre><code class="<%= client.code %>"><%= escape_html(client.init(host, base_url, api_version)) %></code></pre>
1
+ <h4><%= escape_html(client.label) %></h4>
2
+ <%= erb :"version_page/client_result", locals: {client: client, result: result} %>
@@ -0,0 +1,5 @@
1
+ <% if result[:status] == :limitation %>
2
+ <p class="client-limitation"><%= escape_html(result[:reason]) %></p>
3
+ <% else %>
4
+ <pre><code class="<%= escape_html(client.code) %>"><%= escape_html(result[:code]) %></code></pre>
5
+ <% end %>
@@ -104,16 +104,12 @@
104
104
  <div id="<%= "#{baseid}-tabbar" %>"></div>
105
105
 
106
106
  <div id="<%= "#{baseid}-examples" %>">
107
- <% HaveAPI::ClientExample.clients.each_with_index do |client, j| %>
108
- <div id="<%= "#{baseid}-#{j}" %>">
107
+ <% documentation_panels(:example, resource_path: resource_path, resource: resource_info, action_name: action, action: info, sample: example).each do |client, result| %>
108
+ <div id="<%= escape_html("#{baseid}-#{client.id}") %>">
109
109
  <%=
110
110
  erb :"version_page/client_example", locals: {
111
111
  client: client,
112
- r_name: resource_path,
113
- resource: resource_info,
114
- a_name: action,
115
- action: info,
116
- example: example,
112
+ result: result,
117
113
  }
118
114
  %>
119
115
  </div>
@@ -16,19 +16,23 @@
16
16
  </p>
17
17
 
18
18
  <ul>
19
- <li><a href="https://github.com/vpsfreecz/haveapi-client" target="_blank">Ruby library and CLI</a></li>
20
- <li><a href="https://github.com/vpsfreecz/haveapi-client-js" target="_blank">JavaScript</a></li>
21
- <li><a href="https://github.com/vpsfreecz/haveapi-client-php" target="_blank">PHP</a></li>
19
+ <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/ruby" target="_blank" rel="noopener">Ruby library and CLI</a></li>
20
+ <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/js" target="_blank" rel="noopener">JavaScript and TypeScript (callback and async)</a></li>
21
+ <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/python" target="_blank" rel="noopener">Python (sync and async)</a></li>
22
+ <% settings.api_server.documentation_client_profiles(@v).each do |language, profile| %>
23
+ <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/<%= escape_html(language) %>" target="_blank" rel="noopener"><%= language == :go ? 'Go' : 'Rust (async and blocking)' %></a>: <code><%= escape_html(profile.package_name) %></code> <%= escape_html(profile.package_version) %></li>
24
+ <% end %>
25
+ <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/php" target="_blank" rel="noopener">PHP</a></li>
22
26
  <li>
23
27
  <a href="https://github.com/vpsfreecz/haveapi-webui" target="_blank">Generic web interface</a>
24
28
  (<a href="https://webui.haveapi.org/v<%= version %>/#<%= urlescape(base_url) %>" target="_blank">connect to this API</a>)
25
29
  </li>
26
- <li><a href="https://github.com/vpsfreecz/haveapi-fs" target="_blank">FUSE-based file system</a></li>
27
30
  </ul>
28
31
 
29
32
  <p>
30
- The code examples found on this page are for HaveAPI v<%= version %>, so be sure
31
- to use clients of the same version.
33
+ These examples target API v<%= escape_html(@v) %>. The framework and dynamic
34
+ clients share HaveAPI package versions. Generated Go and Rust SDKs use their
35
+ own package versions and a saved description of the API.
32
36
  </p>
33
37
 
34
38
  <h2>Initialization</h2>
@@ -36,9 +40,9 @@
36
40
  <div id="init-tabbar"></div>
37
41
 
38
42
  <div id="init-examples">
39
- <% HaveAPI::ClientExample.clients.each_with_index do |client, i| %>
40
- <div id="<%= "init-#{i}" %>">
41
- <%= erb :"version_page/client_init", locals: {client: client} %>
43
+ <% documentation_panels(:init).each do |client, result| %>
44
+ <div id="<%= escape_html("init-#{client.id}") %>">
45
+ <%= erb :"version_page/client_init", locals: {client: client, result: result} %>
42
46
  </div>
43
47
  <% end %>
44
48
  </div>
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const vm = require('node:vm');
6
+ const root = path.resolve(__dirname, '../../../lib/haveapi/public/js');
7
+ const context = vm.createContext({});
8
+ vm.runInContext(fs.readFileSync(path.join(root, 'highlight.pack.js'), 'utf8'), context);
9
+ for (const language of ['bash', 'http', 'xml', 'php', 'json', 'javascript', 'ruby', 'go', 'rust', 'python', 'typescript']) {
10
+ assert(context.hljs.getLanguage(language), language);
11
+ const result = context.hljs.highlight('const text = "<script>";', { language, ignoreIllegals: true });
12
+ assert(!result.value.includes('<script>'), language);
13
+ }
14
+ assert.equal(context.hljs.versionString, '11.12.0');
15
+ const messages = [];
16
+ context.importScripts = name => assert.equal(name, 'highlight.pack.js');
17
+ context.postMessage = value => messages.push(value);
18
+ vm.runInContext(fs.readFileSync(path.join(root, 'highlighter.js'), 'utf8'), context);
19
+ context.onmessage({ data: { language: 'unknown', code: '<script>&"\'' } });
20
+ assert.equal(messages.pop(), '&lt;script&gt;&amp;&quot;&#39;');
21
+ context.onmessage({ data: { language: 'go', code: 'package main' } });
22
+ assert(messages.pop().includes('hljs-keyword'));
23
+ context.hljs.highlight = () => { throw new Error('fixture failure'); };
24
+ context.onmessage({ data: { language: 'rust', code: '<unsafe>' } });
25
+ assert.equal(messages.pop(), '&lt;unsafe&gt;');
26
+ console.log('All bundled languages and safe worker fallbacks passed');
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const vm = require('node:vm');
6
+ class Element {
7
+ constructor(tag, document) {
8
+ this.tagName = tag.toUpperCase(); this.document = document;
9
+ this.children = []; this.attributes = {}; this.listeners = {};
10
+ const classes = new Set();
11
+ this.classList = { add: (...values) => values.forEach(value => classes.add(value)), remove: value => classes.delete(value), contains: value => classes.has(value) };
12
+ this.textContent = '';
13
+ }
14
+ setAttribute(name, value) { this.attributes[name] = String(value); }
15
+ getAttribute(name) { return this.attributes[name]; }
16
+ appendChild(child) { child.parentElement = this; this.children.push(child); return child; }
17
+ removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
18
+ addEventListener(name, handler) { this.listeners[name] = handler; }
19
+ querySelectorAll(selector) {
20
+ const matches = element => selector === '[role="tab"]' ? element.getAttribute('role') === 'tab' : selector[0] === '.' ? element.classList.contains(selector.slice(1)) : element.tagName === selector.toUpperCase();
21
+ return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
22
+ }
23
+ querySelector(selector) { return this.querySelectorAll(selector)[0]; }
24
+ getElementsByTagName(name) { return this.querySelectorAll(name); }
25
+ focus() { this.document.activeElement = this; }
26
+ }
27
+ const document = { createElement: tag => new Element(tag, document), createTextNode: value => Object.assign(new Element('#text', document), { textContent: value }) };
28
+ const page = document.createElement('main');
29
+ document.getElementById = id => [page, ...page.querySelectorAll('div')].find(element => element.id === id);
30
+ const tabs = page.appendChild(document.createElement('div')); tabs.id = 'examples';
31
+ const bar = page.appendChild(document.createElement('div')); bar.id = 'bar';
32
+ for (let index = 0; index < 3; index++) {
33
+ const panel = tabs.appendChild(document.createElement('div')); panel.id = 'example-' + index;
34
+ const heading = panel.appendChild(document.createElement('h4')); heading.textContent = 'Language ' + index;
35
+ }
36
+ const context = vm.createContext({ window: {}, document, history: { pushState() {} }, location: { hash: '' } });
37
+ vm.runInContext(fs.readFileSync(path.resolve(__dirname, '../../../lib/haveapi/public/js/nojs-tabs.js'), 'utf8'), context);
38
+ context.window.nojsTabs({ tabs, tabBar: bar, titleSelector: 'h4', hiddenClass: 'tab-hidden', activeClass: 'active' });
39
+ const links = bar.querySelectorAll('[role="tab"]');
40
+ assert.equal(links.length, 3);
41
+ assert.equal(links[0].getAttribute('aria-selected'), 'true');
42
+ assert.equal(links[1].getAttribute('tabindex'), '-1');
43
+ for (const link of links) assert(!link.parentElement.classList.contains('tab-hidden'));
44
+ const key = (index, value) => links[index].listeners.keydown({ key: value, preventDefault() {} });
45
+ key(0, 'ArrowRight');
46
+ assert.equal(document.activeElement, links[1]);
47
+ assert.equal(tabs.children[1].getAttribute('aria-hidden'), 'false');
48
+ assert.equal(tabs.children[0].getAttribute('aria-hidden'), 'true');
49
+ key(1, 'End'); assert.equal(document.activeElement, links[2]);
50
+ key(2, 'ArrowRight'); assert.equal(document.activeElement, links[0]);
51
+ key(0, 'ArrowLeft'); assert.equal(document.activeElement, links[2]);
52
+ key(2, 'Home'); assert.equal(document.activeElement, links[0]);
53
+ context.window.nojsTabs({ tabs: document.createElement('div'), tabBar: document.createElement('div') });
54
+ console.log('Keyboard tabs, accessible state and empty panels passed');
@@ -0,0 +1,68 @@
1
+ require 'spec_helper'
2
+
3
+ describe HaveAPI::ClientExample::Context do
4
+ let(:description) { { resources: { widget: { actions: {} } } } }
5
+ let(:context) do
6
+ described_class.new(version: '1', host: 'api.test', base_url: 'https://api.test/mount/',
7
+ version_url: 'https://api.test/mount/v1/', description:)
8
+ end
9
+
10
+ it 'copies and freezes request data without freezing the source description' do
11
+ expect(context.description).to be_frozen
12
+ expect(context.description[:resources]).to be_frozen
13
+ expect(description[:resources]).not_to be_frozen
14
+ description[:resources][:secret] = {}
15
+ expect(context.description[:resources]).not_to have_key(:secret)
16
+ end
17
+
18
+ it 'keeps per-example state independent and leaves the original context intact' do
19
+ data = { request: { name: 'safe' } }
20
+ child = context.with(resource_path: ['widget'], action_name: 'show', sample: data)
21
+ data[:request][:name].replace('changed')
22
+ expect(child.sample[:request][:name]).to eq('safe')
23
+ expect(context.sample).to be_nil
24
+ expect(child.resource_path).to eq(['widget'])
25
+ expect(child).to be_frozen
26
+ end
27
+
28
+ it 'preserves legacy initialization constructors and wraps plain source' do
29
+ renderer = Class.new(HaveAPI::ClientExample) do
30
+ def initialize(host, base_url, version)
31
+ raise ArgumentError, 'expected API version 1' unless version == '1'
32
+
33
+ super
34
+ end
35
+
36
+ def init
37
+ "#{base_url}v#{version}/"
38
+ end
39
+ end
40
+ result = renderer.render(:init, context:)
41
+ expect(result).to eq(status: :code, code: 'https://api.test/mount/v1/')
42
+ expect(result).to be_frozen
43
+ expect(result[:code]).to be_frozen
44
+ end
45
+
46
+ it 'preserves request/response renderers and nil legacy authentication' do
47
+ renderer = Class.new(HaveAPI::ClientExample) do
48
+ def request(_sample)
49
+ 'request'
50
+ end
51
+
52
+ def response(_sample)
53
+ 'response'
54
+ end
55
+ end
56
+ expect(renderer.render(:example, context:)).to eq(status: :code, code: "request\n\nresponse")
57
+ expect(renderer.render(:auth, context:, auth_method: :basic, auth_description: {})).to be_nil
58
+ end
59
+
60
+ it 'wraps a limitation without allowing HTML or an invalid result contract' do
61
+ renderer = Class.new(HaveAPI::ClientExample) do
62
+ def init
63
+ { status: :limitation, reason: 'Cannot render <this>' }
64
+ end
65
+ end
66
+ expect(renderer.render(:init, context:)).to eq(status: :limitation, reason: 'Cannot render <this>')
67
+ end
68
+ end
@@ -0,0 +1,83 @@
1
+ # Dynamic example verification
2
+
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.
7
+
8
+ Run the focused checks in the prepared top-level Nix environment:
9
+
10
+ ```sh
11
+ cd servers/ruby
12
+ bundle exec rspec spec/client_examples/dynamic_js_spec.rb \
13
+ spec/client_examples/dynamic_python_spec.rb \
14
+ spec/client_examples/dynamic_metadata_spec.rb \
15
+ spec/client_examples/dynamic_acceptance_spec.rb
16
+ ```
17
+
18
+ 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;
22
+ it opens no listening socket and does not install packages.
23
+
24
+ `make test-documentation-python` runs the supplemental native Python request
25
+ builder checks in `test_support/dynamic_protocol_spec.rb` and
26
+ `test_support/dynamic_python_login_spec.rb`. Prepare the locked Python
27
+ component environment with `make release-deps` first; the check uses
28
+ `clients/python/.venv/bin/python` and imports the checkout protocol implementation.
29
+ It cross-checks GET limitations, JSON methods and required input metadata without
30
+ transport. It installs nothing and is separate from ordinary Ruby spec discovery.
31
+ The focused JavaScript specs cross-check the built callback and async query/body
32
+ serializers with a mocked transport. The full `make test` includes both checks.
33
+
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.
40
+
41
+ `dynamic_acceptance.rb` checks the installed packages. Supply the built npm and
42
+ Python artifacts, the compiler and Node declaration directories, and a directory
43
+ for evidence. Paths are absolute because the harness changes to an
44
+ isolated consumer:
45
+
46
+ ```sh
47
+ cd servers/ruby
48
+ bundle exec ruby spec/client_examples/dynamic_acceptance.rb \
49
+ --npm-artifact /absolute/path/dist/haveapi-client-0.30.0.tgz \
50
+ --python-artifact /absolute/path/dist/haveapi_client-0.30.0-py3-none-any.whl \
51
+ --typescript /absolute/path/clients/js/node_modules/typescript/bin/tsc \
52
+ --node-types /absolute/path/clients/js/node_modules/@types \
53
+ --output /absolute/path/session/dynamic-acceptance-wheel
54
+ ```
55
+
56
+ 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.
66
+
67
+ 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.
71
+
72
+ JavaScript list input and nested GET/OPTIONS query values produce a visible
73
+ limitation; HEAD input retains the client's JSON body behavior. Python GET
74
+ examples use a conservative flat-scalar subset with nonempty, bracket-free
75
+ parameter names and namespaces. Custom values must be strings or nullable nulls;
76
+ structured GET values produce a limitation even when the client supports them.
77
+ Python POST, PUT and DELETE retain supported object/list JSON input; unsupported
78
+ methods produce a limitation. Required global or object input metadata also
79
+ produces a limitation because the sample format has no metadata input channel.
80
+ Optional metadata needs no invented values. Prose-only and incomplete samples do
81
+ 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.
@@ -0,0 +1,151 @@
1
+ # Run after committed-deliverable review, with built npm and Python artifacts.
2
+ # The only code executed below comes from the real server's rendered HTML.
3
+ require 'cgi'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'net/http'
7
+ require 'open3'
8
+ require 'tmpdir'
9
+ require 'timeout'
10
+ require_relative '../../test_support/dynamic_documentation_api'
11
+
12
+ module DynamicDocumentationAcceptance
13
+ VARIANTS = %w[javascript-callback javascript-async typescript-callback typescript-async python-sync python-async].freeze
14
+
15
+ def self.run!(*command, chdir:, env: {}, log_path: nil)
16
+ output, status = Open3.capture2e(env, *command, chdir:)
17
+ File.write(log_path, output) if log_path
18
+ raise "#{command.first} failed (#{status.exitstatus}):\n#{output}" unless status.success?
19
+
20
+ output
21
+ end
22
+
23
+ def self.request(url)
24
+ uri = URI(url)
25
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 10, read_timeout: 30) { |http| http.get(uri.request_uri) }
26
+ raise "Fixture HTTP #{response.code}: #{response.body[0, 200]}" unless response.is_a?(Net::HTTPSuccess)
27
+
28
+ response.body
29
+ end
30
+
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
35
+
36
+ { id:, variant:, language:, source: CGI.unescapeHTML(escaped) }
37
+ end
38
+ end
39
+
40
+ def self.run(npm_artifact:, python_artifact:, typescript:, node_types:, output:)
41
+ root = File.expand_path('../../../..', __dir__)
42
+ FileUtils.mkdir_p(output)
43
+ tools = { typescript: File.expand_path(typescript), node_types: File.expand_path(node_types) }
44
+ npm_artifact = File.expand_path(npm_artifact)
45
+ python_artifact = File.expand_path(python_artifact)
46
+ Dir.mktmpdir('haveapi-dynamic-acceptance-') do |consumer|
47
+ File.write(File.join(consumer, 'package.json'), JSON.generate({ private: true, type: 'module' }))
48
+ run!('npm', 'install', '--ignore-scripts', '--no-audit', '--no-fund', npm_artifact, chdir: consumer,
49
+ env: { 'NODE_PATH' => nil })
50
+ run!('uv', 'venv', '--python', '3.11', 'venv', chdir: consumer)
51
+ python = File.join(consumer, 'venv/bin/python')
52
+ run!('uv', 'pip', 'install', '--python', python, python_artifact, chdir: consumer)
53
+ server_script = File.join(root, 'servers/ruby/test_support/dynamic_documentation_api.rb')
54
+ Open3.popen2e('ruby', server_script, chdir: File.join(root, 'servers/ruby')) do |stdin, server_output, wait|
55
+ stdin.close
56
+ reader = nil
57
+ begin
58
+ base = Timeout.timeout(30) do
59
+ loop do
60
+ line = server_output.gets
61
+ raise 'Documentation fixture exited before readiness' unless line
62
+ break line.split.last if line.start_with?('DYNAMIC_DOCUMENTATION_READY ')
63
+ end
64
+ end
65
+ 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
77
+ end
78
+ panels.each_with_index do |panel, index|
79
+ extension = { 'typescript' => '.ts', 'javascript' => '.mjs', 'python' => '.py' }.fetch(panel[:language])
80
+ name = "example-#{index}#{extension}"
81
+ path = File.join(consumer, name)
82
+ File.write(path, panel[:source])
83
+ 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') }
91
+ if extension == '.ts'
92
+ config = {
93
+ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true,
94
+ types: ['node'], typeRoots: [tools[:node_types]], outDir: 'compiled', skipLibCheck: false },
95
+ files: [name]
96
+ }
97
+ File.write(File.join(consumer, 'tsconfig.json'), JSON.generate(config))
98
+ file_list = run!('node', tools[:typescript], '-p', 'tsconfig.json', '--listFiles', chdir: consumer)
99
+ raise 'Checkout HaveAPI declarations leaked into installed-package check' if file_list.lines.any? do |line|
100
+ line.include?('/clients/js/src/') || (line.include?('haveapi.d.ts') && !line.start_with?(consumer))
101
+ end
102
+
103
+ path = File.join(consumer, 'compiled', "example-#{index}.js")
104
+ end
105
+ before = JSON.parse(request("#{base}/__acceptance_receipts"))
106
+ 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)
127
+ end
128
+ end
129
+ File.write(File.join(output, 'result.json'), JSON.pretty_generate({ panels: panels.length, variants: VARIANTS, status: 'passed' }))
130
+ ensure
131
+ Process.kill('TERM', wait.pid) if wait.alive?
132
+ wait.value
133
+ reader&.join
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ if $0 == __FILE__
141
+ require 'optparse'
142
+ options = {}
143
+ OptionParser.new do |parser|
144
+ %i[npm_artifact python_artifact typescript node_types output].each do |key|
145
+ parser.on("--#{key.to_s.tr('_', '-')} PATH") { |path| options[key] = path }
146
+ end
147
+ end.parse!
148
+ raise 'Supply --npm-artifact, --python-artifact, --typescript, --node-types and --output' unless options.length == 5
149
+
150
+ DynamicDocumentationAcceptance.run(**options)
151
+ end