haveapi 0.29.12 → 0.30.1

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Rakefile +1 -0
  4. data/doc/client-examples.md +198 -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 +249 -0
  17. data/lib/haveapi/documentation_context.rb +47 -0
  18. data/lib/haveapi/documentation_schema.rb +73 -0
  19. data/lib/haveapi/public/js/highlight-LICENSE +29 -0
  20. data/lib/haveapi/public/js/highlight.pack.js +2 -2
  21. data/lib/haveapi/public/js/highlighter.js +16 -6
  22. data/lib/haveapi/public/js/main.js +9 -1
  23. data/lib/haveapi/public/js/nojs-tabs.js +37 -3
  24. data/lib/haveapi/server.rb +39 -0
  25. data/lib/haveapi/version.rb +1 -1
  26. data/lib/haveapi/views/main_layout.erb +11 -0
  27. data/lib/haveapi/views/version_page/auth_body.erb +3 -3
  28. data/lib/haveapi/views/version_page/client_auth.erb +2 -2
  29. data/lib/haveapi/views/version_page/client_example.erb +2 -11
  30. data/lib/haveapi/views/version_page/client_init.erb +2 -2
  31. data/lib/haveapi/views/version_page/client_result.erb +5 -0
  32. data/lib/haveapi/views/version_page/resource_body.erb +3 -7
  33. data/lib/haveapi/views/version_page.erb +13 -9
  34. data/spec/client_examples/assets/highlighter_test.cjs +26 -0
  35. data/spec/client_examples/assets/tabs_test.cjs +54 -0
  36. data/spec/client_examples/context_spec.rb +68 -0
  37. data/spec/client_examples/dynamic_acceptance.md +83 -0
  38. data/spec/client_examples/dynamic_acceptance.rb +151 -0
  39. data/spec/client_examples/dynamic_acceptance_spec.rb +76 -0
  40. data/spec/client_examples/dynamic_fixture.rb +36 -0
  41. data/spec/client_examples/dynamic_js_spec.rb +196 -0
  42. data/spec/client_examples/dynamic_metadata_spec.rb +52 -0
  43. data/spec/client_examples/dynamic_mounted_paths_spec.rb +65 -0
  44. data/spec/client_examples/dynamic_python_spec.rb +123 -0
  45. data/spec/client_examples/go_spec.rb +57 -0
  46. data/spec/client_examples/profile_mount_spec.rb +215 -0
  47. data/spec/client_examples/profiles_spec.rb +265 -0
  48. data/spec/client_examples/rust_client_spec.rb +75 -0
  49. data/spec/client_examples/structural_schema_spec.rb +48 -0
  50. data/spec/client_examples/syntax_spec.rb +2 -1
  51. data/spec/documentation/auth_filtering_spec.rb +12 -0
  52. data/spec/documentation/client_examples_spec.rb +92 -0
  53. data/spec/packaging_spec.rb +13 -4
  54. data/test_support/dynamic_documentation_api.rb +171 -0
  55. data/test_support/dynamic_protocol_spec.rb +133 -0
  56. data/test_support/dynamic_python_login_spec.rb +75 -0
  57. metadata +37 -4
  58. data/lib/haveapi/client_examples/fs_client.rb +0 -119
@@ -1,9 +1,19 @@
1
1
  importScripts('highlight.pack.js');
2
2
 
3
- onmessage = function (e) {
4
- if (e.data.language)
5
- postMessage(hljs.highlight(e.data.language, e.data.code).value);
6
-
7
- else
8
- postMessage(hljs.highlightAuto(e.data.code).value);
3
+ function plainText(source) {
4
+ return source.replace(/[&<>"']/g, function (value) {
5
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[value];
6
+ });
9
7
  }
8
+
9
+ onmessage = function (e) {
10
+ var source = String(e.data.code || '');
11
+ var language = e.data.language;
12
+ try {
13
+ postMessage(language && hljs.getLanguage(language)
14
+ ? hljs.highlight(source, { language: language, ignoreIllegals: true }).value
15
+ : plainText(source));
16
+ } catch (error) {
17
+ postMessage(plainText(source));
18
+ }
19
+ };
@@ -19,13 +19,21 @@ $(document).ready(function () {
19
19
 
20
20
  worker.onmessage = function (e) {
21
21
  currentElement.innerHTML = e.data;
22
+ currentElement.classList.add('hljs');
22
23
  highlightNext();
23
24
  };
25
+ worker.onerror = function () {
26
+ worker.terminate();
27
+ // The server's escaped source remains readable if the worker fails.
28
+ };
24
29
 
25
30
  highlightNext();
26
31
 
27
32
  } else {
28
- hljs.initHighlighting();
33
+ document.querySelectorAll('pre code').forEach(function (element) {
34
+ if (hljs.getLanguage(element.classList.item(0)))
35
+ hljs.highlightElement(element);
36
+ });
29
37
  }
30
38
  });
31
39
 
@@ -3,7 +3,9 @@
3
3
  this.opts = opts;
4
4
 
5
5
  var ul = this.createElement(document.createElement('UL'));
6
+ ul.setAttribute('role', 'tablist');
6
7
  var tabNum = opts.tabs.children.length;
8
+ if (!tabNum) return;
7
9
 
8
10
  for (var i = 0; i < tabNum; i++) {
9
11
  var tab = opts.tabs.children[i];
@@ -50,6 +52,11 @@
50
52
  var a_text = document.createTextNode(this.getTitle(tab));
51
53
 
52
54
  a.href = '#' + tab.id;
55
+ a.id = 'tab-button-' + tab.id;
56
+ a.setAttribute('role', 'tab');
57
+ a.setAttribute('aria-controls', tab.id);
58
+ tab.setAttribute('role', 'tabpanel');
59
+ tab.setAttribute('aria-labelledby', a.id);
53
60
 
54
61
  var that = this;
55
62
 
@@ -59,6 +66,18 @@
59
66
  if (history.pushState)
60
67
  e.preventDefault();
61
68
  });
69
+ a.addEventListener('keydown', function (e) {
70
+ var links = Array.prototype.slice.call(that.opts.tabBar.querySelectorAll('[role="tab"]'));
71
+ var index = links.indexOf(a);
72
+ if (e.key === 'ArrowRight') index = (index + 1) % links.length;
73
+ else if (e.key === 'ArrowLeft') index = (index + links.length - 1) % links.length;
74
+ else if (e.key === 'Home') index = 0;
75
+ else if (e.key === 'End') index = links.length - 1;
76
+ else return;
77
+ e.preventDefault();
78
+ links[index].focus();
79
+ that.switchTab(that.opts.tabs.children[index]);
80
+ });
62
81
 
63
82
  a.appendChild(a_text);
64
83
  li.appendChild(a);
@@ -80,7 +99,7 @@
80
99
 
81
100
  if (typeof s === 'string' || s instanceof String) {
82
101
  var el = tab.querySelector(s);
83
- var ret = el.innerHTML;
102
+ var ret = el.textContent;
84
103
 
85
104
  if (this.opts.removeHeading === undefined || this.opts.removeHeading)
86
105
  el.parentElement.removeChild(el);
@@ -167,7 +186,7 @@
167
186
  this.activate([targetTab, targetTab.tabAnchor]);
168
187
 
169
188
  // afterChange
170
- if (this.opts.beforeChange !== undefined)
189
+ if (this.opts.afterChange !== undefined)
171
190
  this.opts.afterChange(targetTab, activeTab);
172
191
  };
173
192
 
@@ -177,6 +196,13 @@
177
196
  for (var i = 0; i < len; i++) {
178
197
  elems[i].classList.remove(this.opts.hiddenClass);
179
198
  elems[i].classList.add(this.opts.activeClass);
199
+ var link = elems[i].querySelector('[role="tab"]');
200
+ if (link) {
201
+ link.setAttribute('aria-selected', 'true');
202
+ link.setAttribute('tabindex', '0');
203
+ } else if (elems[i].getAttribute('role') === 'tabpanel') {
204
+ elems[i].setAttribute('aria-hidden', 'false');
205
+ }
180
206
  }
181
207
  };
182
208
 
@@ -185,7 +211,15 @@
185
211
 
186
212
  for (var i = 0; i < len; i++) {
187
213
  elems[i].classList.remove(this.opts.activeClass);
188
- elems[i].classList.add(this.opts.hiddenClass);
214
+ if (elems[i].getAttribute('role') === 'tabpanel')
215
+ elems[i].classList.add(this.opts.hiddenClass);
216
+ var link = elems[i].querySelector('[role="tab"]');
217
+ if (link) {
218
+ link.setAttribute('aria-selected', 'false');
219
+ link.setAttribute('tabindex', '-1');
220
+ } else if (elems[i].getAttribute('role') === 'tabpanel') {
221
+ elems[i].setAttribute('aria-hidden', 'true');
222
+ }
189
223
  }
190
224
  };
191
225
 
@@ -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
@@ -588,6 +622,11 @@ module HaveAPI
588
622
  end
589
623
 
590
624
  call_hooks_for(:post_mount, args: [self, @sinatra])
625
+ @documentation_clients.finalize(self)
626
+ rescue StandardError
627
+ @documentation_clients.abort_mount
628
+ @sinatra = nil
629
+ raise
591
630
  end
592
631
 
593
632
  def mount_version(prefix, v)
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.29.12'.freeze
3
+ VERSION = '0.30.1'.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><% if profile.package_version %> <%= escape_html(profile.package_version) %><% end %></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. Their configured SDKs are expected to match this 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.