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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/Rakefile +1 -0
- data/doc/client-examples.md +96 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/client_example.rb +47 -1
- data/lib/haveapi/client_examples/dynamic_helpers.rb +129 -0
- data/lib/haveapi/client_examples/go_client.rb +37 -0
- data/lib/haveapi/client_examples/js_async_client.rb +14 -0
- data/lib/haveapi/client_examples/js_client.rb +179 -135
- data/lib/haveapi/client_examples/python_async_client.rb +14 -0
- data/lib/haveapi/client_examples/python_client.rb +172 -0
- data/lib/haveapi/client_examples/rust_client.rb +59 -0
- data/lib/haveapi/client_examples/ts_async_client.rb +14 -0
- data/lib/haveapi/client_examples/ts_client.rb +14 -0
- data/lib/haveapi/documentation_clients.rb +126 -0
- data/lib/haveapi/documentation_context.rb +47 -0
- data/lib/haveapi/public/js/highlight-LICENSE +29 -0
- data/lib/haveapi/public/js/highlight.pack.js +2 -2
- data/lib/haveapi/public/js/highlighter.js +16 -6
- data/lib/haveapi/public/js/main.js +9 -1
- data/lib/haveapi/public/js/nojs-tabs.js +37 -3
- data/lib/haveapi/server.rb +34 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/main_layout.erb +11 -0
- data/lib/haveapi/views/version_page/auth_body.erb +3 -3
- data/lib/haveapi/views/version_page/client_auth.erb +2 -2
- data/lib/haveapi/views/version_page/client_example.erb +2 -11
- data/lib/haveapi/views/version_page/client_init.erb +2 -2
- data/lib/haveapi/views/version_page/client_result.erb +5 -0
- data/lib/haveapi/views/version_page/resource_body.erb +3 -7
- data/lib/haveapi/views/version_page.erb +13 -9
- data/spec/client_examples/assets/highlighter_test.cjs +26 -0
- data/spec/client_examples/assets/tabs_test.cjs +54 -0
- data/spec/client_examples/context_spec.rb +68 -0
- data/spec/client_examples/dynamic_acceptance.md +83 -0
- data/spec/client_examples/dynamic_acceptance.rb +151 -0
- data/spec/client_examples/dynamic_acceptance_spec.rb +76 -0
- data/spec/client_examples/dynamic_fixture.rb +36 -0
- data/spec/client_examples/dynamic_js_spec.rb +195 -0
- data/spec/client_examples/dynamic_metadata_spec.rb +52 -0
- data/spec/client_examples/dynamic_mounted_paths_spec.rb +65 -0
- data/spec/client_examples/dynamic_python_spec.rb +123 -0
- data/spec/client_examples/go_spec.rb +57 -0
- data/spec/client_examples/profiles_spec.rb +95 -0
- data/spec/client_examples/rust_client_spec.rb +75 -0
- data/spec/client_examples/syntax_spec.rb +2 -1
- data/spec/documentation/auth_filtering_spec.rb +12 -0
- data/spec/documentation/client_examples_spec.rb +82 -0
- data/spec/packaging_spec.rb +13 -4
- data/test_support/dynamic_documentation_api.rb +171 -0
- data/test_support/dynamic_protocol_spec.rb +133 -0
- data/test_support/dynamic_python_login_spec.rb +75 -0
- metadata +34 -4
- data/lib/haveapi/client_examples/fs_client.rb +0 -119
data/lib/haveapi/server.rb
CHANGED
|
@@ -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
|
data/lib/haveapi/version.rb
CHANGED
|
@@ -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
|
-
<%
|
|
36
|
-
<div id="<%= "#{baseid}-#{
|
|
37
|
-
<%= erb :"version_page/client_auth", locals: {client: client,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
<h4><%= escape_html(client.label) %></h4>
|
|
2
|
+
<%= erb :"version_page/client_result", locals: {client: client, result: result} %>
|
|
@@ -104,16 +104,12 @@
|
|
|
104
104
|
<div id="<%= "#{baseid}-tabbar" %>"></div>
|
|
105
105
|
|
|
106
106
|
<div id="<%= "#{baseid}-examples" %>">
|
|
107
|
-
<%
|
|
108
|
-
<div id="<%= "#{baseid}-#{
|
|
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
|
-
|
|
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
|
|
20
|
-
<li><a href="https://github.com/vpsfreecz/haveapi
|
|
21
|
-
<li><a href="https://github.com/vpsfreecz/haveapi
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
<%
|
|
40
|
-
<div id="<%= "init-#{
|
|
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(), '<script>&"'');
|
|
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(), '<unsafe>');
|
|
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
|