haveapi 0.32.1 → 0.32.2
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 +3 -8
- data/README.md +3 -422
- data/Rakefile +17 -7
- data/benchmarks/performance_compare.rb +27 -6
- data/doc/cs/client-examples.md +535 -0
- data/doc/cs/create-client.md +118 -0
- data/doc/cs/json-schema.html +1182 -0
- data/doc/cs/protocol.md +595 -0
- data/doc/cs/readme.md +432 -0
- data/doc/cs/typed-input-validation.md +122 -0
- data/doc/{client-examples.md → en/client-examples.md} +20 -10
- data/doc/{documentation-ui.md → en/documentation-ui.md} +42 -6
- data/doc/{performance.md → en/performance.md} +11 -0
- data/doc/{protocol.md → en/protocol.md} +2 -2
- data/doc/en/readme.md +423 -0
- data/haveapi.gemspec +2 -2
- data/lib/haveapi/client_example.rb +18 -5
- data/lib/haveapi/client_examples/curl.rb +5 -5
- data/lib/haveapi/client_examples/dynamic_helpers.rb +10 -10
- data/lib/haveapi/client_examples/go_client.rb +1 -1
- data/lib/haveapi/client_examples/http.rb +4 -4
- data/lib/haveapi/client_examples/js_client.rb +10 -10
- data/lib/haveapi/client_examples/php_client.rb +24 -25
- data/lib/haveapi/client_examples/python_client.rb +11 -11
- data/lib/haveapi/client_examples/ruby_cli.rb +6 -9
- data/lib/haveapi/client_examples/ruby_client.rb +24 -16
- data/lib/haveapi/client_examples/rust_client.rb +1 -1
- data/lib/haveapi/client_examples/ts_generated_client.rb +1 -1
- data/lib/haveapi/documentation_context.rb +14 -2
- data/lib/haveapi/documentation_helpers.rb +92 -15
- data/lib/haveapi/documentation_locale.rb +65 -0
- data/lib/haveapi/documentation_oauth.rb +12 -12
- data/lib/haveapi/documentation_search.rb +5 -5
- data/lib/haveapi/documentation_validators.rb +43 -33
- data/lib/haveapi/locales/cs.yml +468 -0
- data/lib/haveapi/locales/en.yml +467 -0
- data/lib/haveapi/public/css/documentation.css +8 -4
- data/lib/haveapi/public/js/documentation.js +73 -20
- data/lib/haveapi/server.rb +32 -47
- data/lib/haveapi/shared_documentation_helpers.rb +53 -5
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/doc_layout.erb +3 -2
- data/lib/haveapi/views/doc_sidebars/create-client.erb +10 -10
- data/lib/haveapi/views/doc_sidebars/json-schema.erb +2 -2
- data/lib/haveapi/views/doc_sidebars/protocol.erb +24 -24
- data/lib/haveapi/views/documentation/search_form.erb +5 -4
- data/lib/haveapi/views/documentation/search_results.erb +6 -6
- data/lib/haveapi/views/index.erb +3 -3
- data/lib/haveapi/views/main_layout.erb +4 -4
- data/lib/haveapi/views/shared_documentation.erb +8 -5
- data/lib/haveapi/views/shared_sidebar.erb +11 -11
- data/lib/haveapi/views/version_page/auth_body.erb +7 -7
- data/lib/haveapi/views/version_page/client_auth.erb +1 -1
- data/lib/haveapi/views/version_page/client_example.erb +1 -1
- data/lib/haveapi/views/version_page/client_init.erb +1 -1
- data/lib/haveapi/views/version_page/client_overview.erb +7 -5
- data/lib/haveapi/views/version_page/example_panels.erb +5 -2
- data/lib/haveapi/views/version_page/resource_body.erb +24 -24
- data/lib/haveapi/views/version_page/resource_directory.erb +2 -2
- data/lib/haveapi/views/version_page/usage.erb +5 -5
- data/lib/haveapi/views/version_page.erb +11 -11
- data/lib/haveapi/views/version_sidebar/auth_nav.erb +2 -2
- data/lib/haveapi/views/version_sidebar/authentication_control.erb +8 -8
- data/lib/haveapi/views/version_sidebar/authentication_status.erb +1 -1
- data/lib/haveapi/views/version_sidebar/brand.erb +16 -5
- data/lib/haveapi/views/version_sidebar.erb +33 -18
- data/spec/client_examples/assets/documentation_test.cjs +107 -9
- data/spec/client_examples/comment_capture.rb +72 -0
- data/spec/client_examples/comments_spec.rb +162 -0
- data/spec/client_examples/context_spec.rb +18 -2
- data/spec/client_examples/detail_spec.rb +68 -0
- data/spec/documentation/client_overview_spec.rb +60 -0
- data/spec/documentation/example_selection_spec.rb +192 -0
- data/spec/documentation/language_settings_spec.rb +132 -0
- data/spec/documentation/locale_spec.rb +367 -0
- data/spec/documentation/navigation_spec.rb +61 -3
- data/spec/documentation/oauth_spec.rb +42 -0
- data/spec/documentation/search_index_routes_spec.rb +8 -4
- data/spec/documentation/search_spec.rb +1 -1
- data/spec/documentation/shared_pages_spec.rb +63 -2
- data/spec/documentation/validators_spec.rb +25 -0
- data/spec/extensions/exception_mailer_spec.rb +2 -2
- data/spec/packaging_spec.rb +68 -9
- data/spec/performance/event_baseline_spec.rb +1 -1
- data/spec/performance/source_versions_spec.rb +111 -2
- metadata +24 -11
- /data/doc/{create-client.md → en/create-client.md} +0 -0
- /data/doc/{documentation-oauth.md → en/documentation-oauth.md} +0 -0
- /data/doc/{json-schema.html → en/json-schema.html} +0 -0
- /data/doc/{typed-input-validation.md → en/typed-input-validation.md} +0 -0
|
@@ -5,9 +5,21 @@ const path = require('node:path');
|
|
|
5
5
|
const vm = require('node:vm');
|
|
6
6
|
const filename = path.resolve(__dirname, '../../../lib/haveapi/public/js/documentation.js');
|
|
7
7
|
const stylesheet = fs.readFileSync(path.resolve(__dirname, '../../../lib/haveapi/public/css/documentation.css'), 'utf8');
|
|
8
|
-
const { preferences, readPreferences, clientChoice, detailLevels, effectiveDetail, exampleDetail, copyText, exampleCopyControls,
|
|
8
|
+
const { setDocumentationMessages, documentationLanguageURL, documentationMessage, preferences, readPreferences, clientChoice, detailLevels, effectiveDetail, exampleDetail, copyText, exampleCopyControls,
|
|
9
9
|
disclosureMenus, legacyTarget, loginTarget, highlighterTarget, activeSection, navigationTargets, referenceNavigation, searchShortcut,
|
|
10
10
|
searchConfiguration, validateSearchIndex, searchMatcher, searchResultURL, renderSearchResult, searchIndexLoader } = require(filename);
|
|
11
|
+
const catalogs = JSON.parse(require('node:child_process').execFileSync('ruby', ['-ryaml', '-rjson', '-e',
|
|
12
|
+
'print JSON.generate(YAML.load_file(ARGV[0]).transform_values { |v| v.fetch("haveapi").fetch("documentation").fetch("browser") })',
|
|
13
|
+
path.resolve(__dirname, '../../../../../i18n/haveapi.yml')], { encoding: 'utf8' }));
|
|
14
|
+
setDocumentationMessages(catalogs.en);
|
|
15
|
+
const localeURL = documentationLanguageURL('https://docs.example/api/v1/?resource=widget&q=a%20b&doc_lang=en#root-widget-show', 'cs');
|
|
16
|
+
assert.equal(localeURL, 'https://docs.example/api/v1/?resource=widget&q=a+b&doc_lang=cs#root-widget-show');
|
|
17
|
+
assert.equal(documentationLanguageURL('https://docs.example/api/v1/?page=authentication', 'auto'), null);
|
|
18
|
+
assert.equal(documentationLanguageURL('javascript:alert(1)', 'cs'), null);
|
|
19
|
+
setDocumentationMessages(catalogs.cs);
|
|
20
|
+
assert.equal(documentationMessage('copy_example'), catalogs.cs.copy_example);
|
|
21
|
+
assert.equal(documentationMessage('saved_example_unavailable', { client: '<Client>' }), catalogs.cs.saved_example_unavailable.replace('%{client}', '<Client>'));
|
|
22
|
+
setDocumentationMessages(catalogs.en);
|
|
11
23
|
const clients = [
|
|
12
24
|
{ id: '0', language: 'ruby', language_label: 'Ruby', variant: null },
|
|
13
25
|
{ id: 'python-sync', language: 'python', language_label: 'Python', variant: 'Sync' },
|
|
@@ -30,6 +42,9 @@ assert(!/padding(?:-top|-block)|margin|line-height|grid/.test(sourceLayout), 'or
|
|
|
30
42
|
assert(/padding-inline-end:/.test(sourceLayout), 'code can pan into a clear inline-end gutter');
|
|
31
43
|
assert(/position: absolute/.test(buttonLayout) && /width: 2\.75rem/.test(buttonLayout) && /height: 2\.75rem/.test(buttonLayout), 'small visual icon keeps an out-of-flow44px hit area');
|
|
32
44
|
assert(/position: absolute/.test(statusLayout) && /inset-block-start: 100%/.test(statusLayout), 'live status stays outside readable code and does not move it');
|
|
45
|
+
const breadcrumbLayout = cssRule('.docs-main .docs-breadcrumb ol');
|
|
46
|
+
assert(/display: flex/.test(breadcrumbLayout) && /list-style: none/.test(breadcrumbLayout), 'breadcrumbs stay horizontal and unnumbered in both locales');
|
|
47
|
+
assert(!/\[aria-label[\^$*~|]?=/.test(stylesheet), 'documentation style must not depend on translated accessibility labels');
|
|
33
48
|
|
|
34
49
|
assert.equal(preferences(null).theme, 'auto');
|
|
35
50
|
assert.equal(preferences(null).detail, 'simple');
|
|
@@ -309,7 +324,8 @@ function browserFixture(workerMode, blockedStorage, display) {
|
|
|
309
324
|
const visibleCode = code(true, false);
|
|
310
325
|
const codes = [visibleCode, code(false, false), code(true, true)];
|
|
311
326
|
const root = {
|
|
312
|
-
querySelector: selector =>
|
|
327
|
+
querySelector: selector => selector === '[data-documentation-messages]' ? { textContent: JSON.stringify(catalogs.en) }
|
|
328
|
+
: display && selector === '[data-documentation-clients]' ? { textContent: JSON.stringify(display.metadata) }
|
|
313
329
|
: display && selector === '[data-docs-sidebar]' ? display.sidebar
|
|
314
330
|
: display && selector === '[data-nav-toggle]' ? display.toggle : display && selector === '.docs-main' ? display.main : null,
|
|
315
331
|
querySelectorAll: selector => display && display.matches[selector] ? display.matches[selector]
|
|
@@ -343,7 +359,7 @@ function browserFixture(workerMode, blockedStorage, display) {
|
|
|
343
359
|
};
|
|
344
360
|
if (workerMode !== 'missing') window.Worker = Worker;
|
|
345
361
|
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { window, document, Worker, URL, console });
|
|
346
|
-
return { attributes, messages, worker, visibleCode, themeButtons, writes, themeMedia, document, documentEvents, windowEvents };
|
|
362
|
+
return { attributes, messages, worker, visibleCode, themeButtons, writes, themeMedia, document, documentEvents, windowEvents, window };
|
|
347
363
|
}
|
|
348
364
|
for (const mode of ['missing', 'constructor-failure', 'post-failure', 'working']) {
|
|
349
365
|
const fixture = browserFixture(mode, true);
|
|
@@ -382,6 +398,48 @@ assert.equal(settingsMenu.summary.getAttribute('aria-label'), 'Documentation set
|
|
|
382
398
|
assert.equal(settingsMenu.summary.getAttribute('title'), 'Documentation settings');
|
|
383
399
|
assert.equal(menuTheme.themeButtons[2].attributes['aria-pressed'], 'true');
|
|
384
400
|
|
|
401
|
+
const localeListeners = { form: {}, select: {} };
|
|
402
|
+
const documentationLocale = { value: 'cs', addEventListener(name, listener) { (localeListeners.select[name] ||= []).push(listener); } };
|
|
403
|
+
let localeSubmitHidden = false;
|
|
404
|
+
const localeSubmit = {
|
|
405
|
+
get hidden() { return localeSubmitHidden; },
|
|
406
|
+
set hidden(value) {
|
|
407
|
+
assert.equal(localeListeners.form.submit.length, 1, 'native submit stays available until its enhanced listener is bound');
|
|
408
|
+
assert.equal(localeListeners.select.change.length, 1, 'native submit stays available until change navigation is bound');
|
|
409
|
+
localeSubmitHidden = value;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
const localeForm = {
|
|
413
|
+
querySelector(selector) {
|
|
414
|
+
assert(['[name="doc_lang"]', '[data-documentation-language-submit]'].includes(selector));
|
|
415
|
+
return selector === '[name="doc_lang"]' ? documentationLocale : localeSubmit;
|
|
416
|
+
},
|
|
417
|
+
addEventListener(name, listener) { (localeListeners.form[name] ||= []).push(listener); }
|
|
418
|
+
};
|
|
419
|
+
const localeVariant = Object.assign(element('select'), { closest: () => null });
|
|
420
|
+
const localeDetail = Object.assign(element('select'), { closest: () => null });
|
|
421
|
+
const localeFixture = browserFixture('missing', false, {
|
|
422
|
+
metadata: clients, saved: { language: 'python', clients: { python: 'python-async' } }, geometry: { scroll: 0 },
|
|
423
|
+
matches: { '[data-documentation-language]': [localeForm], '[data-variant-select]': [localeVariant], '[data-detail-select]': [localeDetail] }
|
|
424
|
+
});
|
|
425
|
+
assert.equal(localeSubmit.hidden, true, 'enhanced language settings show the select without a submit button');
|
|
426
|
+
const localeNavigations = [];
|
|
427
|
+
localeFixture.window.location.href = 'https://docs.example/api/v1/?resource=widget&q=hostname#root-widget-show';
|
|
428
|
+
localeFixture.window.location.assign = destination => localeNavigations.push(destination);
|
|
429
|
+
localeFixture.themeButtons[2].listeners.click();
|
|
430
|
+
localeVariant.value = 'python-sync'; localeVariant.listeners.change();
|
|
431
|
+
localeDetail.value = 'thorough'; localeDetail.listeners.change();
|
|
432
|
+
let localePrevented = 0;
|
|
433
|
+
localeListeners.select.change.forEach(listener => listener({ preventDefault() { localePrevented += 1; } }));
|
|
434
|
+
assert.deepEqual(localeNavigations, ['https://docs.example/api/v1/?resource=widget&q=hostname&doc_lang=cs#root-widget-show'],
|
|
435
|
+
'changing theme, client and detail must not multiply language navigation handlers');
|
|
436
|
+
assert.equal(localePrevented, 1);
|
|
437
|
+
localeNavigations.length = 0;
|
|
438
|
+
documentationLocale.value = 'en';
|
|
439
|
+
localeListeners.form.submit.forEach(listener => listener({ preventDefault() {} }));
|
|
440
|
+
assert.deepEqual(localeNavigations, ['https://docs.example/api/v1/?resource=widget&q=hostname&doc_lang=en#root-widget-show'],
|
|
441
|
+
'submitting the native language form is enhanced once and preserves the current location');
|
|
442
|
+
|
|
385
443
|
const shortcutToggle = element('button');
|
|
386
444
|
const shortcutMain = { inert: false };
|
|
387
445
|
let slashFocusCount = 0;
|
|
@@ -438,7 +496,7 @@ assert.equal(detailSelect.value, 'thorough');
|
|
|
438
496
|
assert.equal(detailWrapper.hidden, false);
|
|
439
497
|
assert.equal(languageSelect.disabled, false, 'Language remains the primary available choice');
|
|
440
498
|
languageSelect.value = 'single'; languageSelect.listeners.change();
|
|
441
|
-
assert.equal(detailWrapper.hidden,
|
|
499
|
+
assert.equal(detailWrapper.hidden, true); assert.equal(detailSelect.disabled, true);
|
|
442
500
|
assert.equal(variantWrapper.hidden, true); assert.equal(variantSelect.disabled, true);
|
|
443
501
|
assert.deepEqual(values(variantSelect), ['single'], 'Client contains only renderers for the selected language');
|
|
444
502
|
assert.deepEqual(values(detailSelect), ['simple'], 'Examples contains only the current renderer capabilities');
|
|
@@ -466,7 +524,7 @@ detailSelect.value = 'thorough'; detailSelect.listeners.change();
|
|
|
466
524
|
assert.equal(JSON.parse(detailFixture.writes.at(-1).value).clients.capable, 'capable-other');
|
|
467
525
|
detailDisplay.metadata[2].details = null;
|
|
468
526
|
browserFixture('missing', false, { ...detailDisplay, saved: { language: 'single', detail: 'thorough' } });
|
|
469
|
-
assert.equal(detailSelect.disabled, true); assert.equal(detailWrapper.hidden,
|
|
527
|
+
assert.equal(detailSelect.disabled, true); assert.equal(detailWrapper.hidden, true);
|
|
470
528
|
assert.deepEqual(values(detailSelect), ['simple']);
|
|
471
529
|
assert.equal(detailPanels.find(panel => !panel.hidden).client, 'single', 'malformed metadata must not discard the renderer');
|
|
472
530
|
assert.equal(detailPanels.find(panel => !panel.hidden).detail, 'simple');
|
|
@@ -476,6 +534,38 @@ assert.equal(detailSelect.disabled, false); assert.equal(detailWrapper.hidden, f
|
|
|
476
534
|
assert.deepEqual(values(detailSelect), ['simple', 'thorough']);
|
|
477
535
|
assert.equal(detailPanels.find(panel => !panel.hidden).detail, 'thorough', 'missing metadata keeps legacy two-level markup');
|
|
478
536
|
|
|
537
|
+
function examplePreferenceDisplay(selection) {
|
|
538
|
+
const apply = element('button');
|
|
539
|
+
const form = Object.assign(element('form'), {
|
|
540
|
+
getAttribute: name => name === 'data-example-selection' ? selection : null,
|
|
541
|
+
querySelector: selector => selector === '[data-apply-examples]' ? apply : null
|
|
542
|
+
});
|
|
543
|
+
const language = Object.assign(element('select'), { closest: () => null });
|
|
544
|
+
const variant = Object.assign(element('select'), { closest: () => null });
|
|
545
|
+
const detail = Object.assign(element('select'), { closest: () => null });
|
|
546
|
+
const fixture = browserFixture('missing', false, { metadata: clients,
|
|
547
|
+
saved: { theme: 'dark', language: 'python', clients: { python: 'python-async' }, detail: 'simple' },
|
|
548
|
+
matches: { '[data-example-preferences]': [form], '[data-language-select]': [language],
|
|
549
|
+
'[data-variant-select]': [variant], '[data-detail-select]': [detail] } });
|
|
550
|
+
return { apply, form, language, variant, detail, fixture };
|
|
551
|
+
}
|
|
552
|
+
const nativeChoices = examplePreferenceDisplay(JSON.stringify({ language: 'python', client: 'python-sync', detail: 'thorough' }));
|
|
553
|
+
assert.equal(nativeChoices.variant.value, 'python-sync', 'explicit native selection initializes ahead of saved client');
|
|
554
|
+
assert.equal(nativeChoices.detail.value, 'thorough');
|
|
555
|
+
assert.equal(nativeChoices.fixture.attributes['data-docs-theme'], 'dark', 'native example choices preserve saved theme');
|
|
556
|
+
assert.equal(nativeChoices.fixture.writes.length, 0, 'native query initialization never persists preferences');
|
|
557
|
+
assert.equal(nativeChoices.apply.hidden, true, 'Apply is hidden after its real native form is enhanced');
|
|
558
|
+
let nativeSubmitPrevented = false;
|
|
559
|
+
nativeChoices.form.listeners.submit({ preventDefault() { nativeSubmitPrevented = true; } });
|
|
560
|
+
assert(nativeSubmitPrevented, 'enhanced Enter submission keeps immediate selection and does not navigate');
|
|
561
|
+
nativeChoices.variant.value = 'python-async'; nativeChoices.variant.listeners.change();
|
|
562
|
+
assert.equal(JSON.parse(nativeChoices.fixture.writes.at(-1).value).clients.python, 'python-async', 'actual JS changes still persist immediately');
|
|
563
|
+
const savedChoices = examplePreferenceDisplay(null);
|
|
564
|
+
assert.equal(savedChoices.variant.value, 'python-async', 'without native query the saved client retains precedence');
|
|
565
|
+
assert.equal(savedChoices.detail.value, 'simple');
|
|
566
|
+
assert.equal(savedChoices.fixture.writes.length, 0);
|
|
567
|
+
assert(!stylesheet.includes('[data-docs]:not([data-docs-enhanced]) .docs-preferences'), 'native example controls remain visible without enhancement');
|
|
568
|
+
|
|
479
569
|
function declaredGroup(levels, panels) {
|
|
480
570
|
const unavailable = { hidden: true };
|
|
481
571
|
return { isConnected: true, getAttribute: () => levels,
|
|
@@ -508,7 +598,7 @@ const singletonPage = browserFixture('missing', false, { metadata: [{ id: 'callb
|
|
|
508
598
|
saved: JSON.parse(mixedFixture.writes.at(-1).value), geometry: { scroll: 0 },
|
|
509
599
|
matches: { '[data-detail-select]': [singletonPageSelect] } });
|
|
510
600
|
assert.deepEqual(values(singletonPageSelect), ['simple'], 'a no-example page consumes its emitted Simple-only union');
|
|
511
|
-
assert.equal(singletonPageSelect.disabled, true); assert.equal(singletonPageSelect.hidden,
|
|
601
|
+
assert.equal(singletonPageSelect.disabled, true); assert.equal(singletonPageSelect.hidden, true);
|
|
512
602
|
singletonPageSelect.listeners.change();
|
|
513
603
|
assert.equal(singletonPage.writes.length, 0, 'singleton pages/events never persist over the preferred thorough choice');
|
|
514
604
|
const restoredPageSelect = Object.assign(element('select'), { closest: () => null });
|
|
@@ -525,7 +615,7 @@ const oneChoice = browserFixture('missing', false, { saved: { language: 'only',
|
|
|
525
615
|
matches: { '[data-language-select]': [oneLanguage], '[data-variant-select]': [oneClient], '[data-detail-select]': [oneDetail] } });
|
|
526
616
|
assert.deepEqual(values(oneLanguage), ['only']); assert.equal(oneLanguage.disabled, true);
|
|
527
617
|
assert.equal(oneClientWrapper.hidden, true); assert.equal(oneClient.disabled, true);
|
|
528
|
-
assert.equal(oneDetail.hidden,
|
|
618
|
+
assert.equal(oneDetail.hidden, true); assert.equal(oneDetail.disabled, true); assert.equal(oneDetail.value, 'simple');
|
|
529
619
|
oneDetail.listeners.change(); assert.equal(oneChoice.writes.length, 0, 'no-example singleton controls do not erase a saved thorough preference');
|
|
530
620
|
|
|
531
621
|
const fixture = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../documentation/fixtures/search_index.json'), 'utf8'));
|
|
@@ -541,6 +631,13 @@ assert.equal(searchConfiguration(action, 'https://foreign.example/api/v1/?page=r
|
|
|
541
631
|
assert.equal(searchConfiguration(action, indexURL + '&q=secret', key, '1', current, false), null);
|
|
542
632
|
assert.equal(searchConfiguration(action, indexURL, '', '1', current, false), null);
|
|
543
633
|
assert.equal(searchConfiguration(action, indexURL.replace(key, 'b'.repeat(64)), key, '1', current, false), null);
|
|
634
|
+
const localizedConfig = searchConfiguration(action, indexURL + '&doc_lang=cs', key, fixture.index.version, current, false);
|
|
635
|
+
assert.equal(localizedConfig.locale, 'cs');
|
|
636
|
+
assert.equal(new URL(searchConfiguration(action, indexURL + '&doc_lang=cs', key, fixture.index.version, current, true).url).searchParams.get('doc_lang'), 'cs');
|
|
637
|
+
for (const suffix of ['&doc_lang=auto', '&doc_lang=cs&doc_lang=en', '&doc_lang[]=cs']) {
|
|
638
|
+
assert.equal(searchConfiguration(action, indexURL + suffix, key, fixture.index.version, current, false), null);
|
|
639
|
+
}
|
|
640
|
+
assert.throws(() => validateSearchIndex({ key, index: { ...fixture.index, locale: 'en' } }, localizedConfig));
|
|
544
641
|
const index = validateSearchIndex({ key, index: fixture.index }, config);
|
|
545
642
|
const matchIndex = searchMatcher(index);
|
|
546
643
|
for (const example of fixture.queries) {
|
|
@@ -740,7 +837,8 @@ function clipboardFixture(secure, nativeResult) {
|
|
|
740
837
|
// copy controls share the same state rather than a second selection model.
|
|
741
838
|
const copyLanguage = Object.assign(element('select'), { closest: () => null });
|
|
742
839
|
const copyVariant = Object.assign(element('select'), { closest: selector => selector === '[data-variant-control]' ? { hidden: false } : null });
|
|
743
|
-
const
|
|
840
|
+
const copyDetailWrapper = { hidden: false };
|
|
841
|
+
const copyDetail = Object.assign(element('select'), { closest: selector => selector === '[data-detail-control]' ? copyDetailWrapper : null });
|
|
744
842
|
const copyPanels = ['capable', 'single'].flatMap(client => ['simple', 'thorough'].map(detail => {
|
|
745
843
|
const pair = copyPanel(' ' + client + ' ' + detail + '\n', false);
|
|
746
844
|
pair.panel.setAttribute('data-client', client); pair.panel.setAttribute('data-detail', detail);
|
|
@@ -765,7 +863,7 @@ function clipboardFixture(secure, nativeResult) {
|
|
|
765
863
|
assert.equal(status(copyPanels[1]).textContent, '', 'changing selected client clears stale copied status');
|
|
766
864
|
assert.deepEqual(values(copyVariant), ['single']); assert.deepEqual(values(copyDetail), ['simple']);
|
|
767
865
|
assert.equal(copyVariant.disabled, true); assert.equal(copyDetail.disabled, true);
|
|
768
|
-
assert.equal(
|
|
866
|
+
assert.equal(copyDetailWrapper.hidden, true);
|
|
769
867
|
button(selectedPair()).listeners.click(); await flush();
|
|
770
868
|
assert.equal(actualCopies.at(-1), ' single simple\n', 'copy uses the actual Simple panel while its singleton selector is disabled');
|
|
771
869
|
assert.equal(JSON.parse(copyControls.writes.at(-1).value).detail, 'thorough');
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Test only the authored prose emission boundary. No parsing or stripping of
|
|
2
|
+
# source literals, transcript data or commented commands takes place here.
|
|
3
|
+
module ExampleCommentCapture
|
|
4
|
+
def comment_capture_class(base)
|
|
5
|
+
emissions = []
|
|
6
|
+
captured = Class.new(base) do
|
|
7
|
+
define_method(:comment_lines) do |text, prefix|
|
|
8
|
+
block = super(text, prefix)
|
|
9
|
+
marker = "__HAVEAPI_COMMENT_EMISSION_#{emissions.length}__"
|
|
10
|
+
emissions << { marker:, block:, prefix: }
|
|
11
|
+
marker
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
[captured, emissions]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def restore_comment_emissions(template, emissions)
|
|
18
|
+
restored = template.dup
|
|
19
|
+
emissions.each do |emission|
|
|
20
|
+
marker = emission.fetch(:marker)
|
|
21
|
+
count = restored.scan(marker).length
|
|
22
|
+
next if count == 0 # Some renderers prepare a fragment before selecting another branch.
|
|
23
|
+
|
|
24
|
+
expect(count).to eq(1)
|
|
25
|
+
offset = restored.index(marker)
|
|
26
|
+
line_start = restored.rindex("\n", offset) || -1
|
|
27
|
+
indentation = restored[(line_start + 1)...offset]
|
|
28
|
+
indentation = '' unless indentation.match?(/\A[ \t]*\z/)
|
|
29
|
+
restored = restored.sub(marker) { emission.fetch(:block).gsub("\n", "\n#{indentation}") }
|
|
30
|
+
end
|
|
31
|
+
restored
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module ExampleCommentCapture
|
|
36
|
+
# Standalone adapters consume a duck-typed context and need no server gem.
|
|
37
|
+
def localized_comment_context(context, locale)
|
|
38
|
+
require 'yaml'
|
|
39
|
+
catalog = YAML.load_file(File.expand_path('../../../../i18n/haveapi.yml', __dir__))
|
|
40
|
+
.fetch(locale.to_s).fetch('haveapi').fetch('documentation')
|
|
41
|
+
localized = context.dup
|
|
42
|
+
localized.define_singleton_method(:documentation_message) do |key, default:, **values|
|
|
43
|
+
message = key.split('.').reduce(catalog) { |tree, part| tree.is_a?(Hash) ? tree[part] : nil } || default
|
|
44
|
+
values.empty? ? message : format(message, values)
|
|
45
|
+
end
|
|
46
|
+
localized.define_singleton_method(:example_comment) do |key, default:, **values|
|
|
47
|
+
documentation_message("comments.#{key}", default:, **values)
|
|
48
|
+
end
|
|
49
|
+
localized
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def expect_adapter_comment_parity(base, context, **arguments, &factory)
|
|
53
|
+
actual = []
|
|
54
|
+
templates = []
|
|
55
|
+
traces = []
|
|
56
|
+
%i[en cs].each do |locale|
|
|
57
|
+
localized = localized_comment_context(context, locale)
|
|
58
|
+
rendered = factory.call(base).freeze.render(context: localized, **arguments)
|
|
59
|
+
expect(rendered[:status]).to eq(:code), "#{arguments.inspect}: #{rendered.inspect}"
|
|
60
|
+
actual << rendered.fetch(:code)
|
|
61
|
+
capture, emissions = comment_capture_class(base)
|
|
62
|
+
templates << factory.call(capture).freeze.render(context: localized, **arguments).fetch(:code)
|
|
63
|
+
traces << emissions
|
|
64
|
+
expect(restore_comment_emissions(templates.last, emissions)).to eq(actual.last)
|
|
65
|
+
end
|
|
66
|
+
expect(templates[1]).to eq(templates[0])
|
|
67
|
+
expect(traces[1].map { |item| item[:prefix] }).to eq(traces[0].map { |item| item[:prefix] })
|
|
68
|
+
# The same adapter used with an old context retains the original English.
|
|
69
|
+
expect(factory.call(base).freeze.render(context:, **arguments).fetch(:code)).to eq(actual[0])
|
|
70
|
+
actual
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'ripper'
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
require_relative 'dynamic_fixture'
|
|
6
|
+
require_relative 'comment_capture'
|
|
7
|
+
|
|
8
|
+
RSpec.describe HaveAPI::ClientExample do
|
|
9
|
+
include DynamicExampleFixture
|
|
10
|
+
include ExampleCommentCapture
|
|
11
|
+
|
|
12
|
+
let(:renderers) do
|
|
13
|
+
described_class.clients.reject { |renderer| %w[go rust typescript-generated].include?(renderer.code.to_s) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render_comments(renderer, context, kind, method = nil)
|
|
17
|
+
renderer.render(kind, context:, auth_method: method, auth_description: token_description.merge(
|
|
18
|
+
authorize_path: '/authorize', token_path: '/token', http_header: 'X-Token'
|
|
19
|
+
)).fetch(:code)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def context_for_comments(detail)
|
|
23
|
+
dynamic_context(detail:, resource_path: ['widget'], action_name: 'show',
|
|
24
|
+
action: dynamic_action.merge(blocking: true),
|
|
25
|
+
sample: dynamic_sample.merge(path_params: [], request: { name: 'literal // # komentář', enabled: false,
|
|
26
|
+
count: 0, nullable: nil }))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'varies only authored prose boundaries across all native renderers, kinds and detail levels' do
|
|
30
|
+
differences = 0
|
|
31
|
+
renderers.each do |renderer|
|
|
32
|
+
%i[simple thorough].each do |detail|
|
|
33
|
+
[[:init, nil], %i[auth basic], %i[auth token], %i[auth oauth2], [:example, nil]].each do |kind, method|
|
|
34
|
+
context = context_for_comments(detail)
|
|
35
|
+
# HTTP needs sample status metadata; action outputs remain literal data.
|
|
36
|
+
context = context.with(sample: context.sample.merge(http_status: 200))
|
|
37
|
+
actual = []
|
|
38
|
+
traced = []
|
|
39
|
+
traces = []
|
|
40
|
+
%i[en cs].each do |locale|
|
|
41
|
+
localized = context.with(locale:)
|
|
42
|
+
result = renderer.render(kind, context: localized, auth_method: method, auth_description: token_description.merge(
|
|
43
|
+
authorize_path: '/authorize', token_path: '/token', http_header: 'X-Token'
|
|
44
|
+
))
|
|
45
|
+
next unless result[:status] == :code
|
|
46
|
+
|
|
47
|
+
actual << result.fetch(:code)
|
|
48
|
+
capture, emissions = comment_capture_class(renderer)
|
|
49
|
+
traced << render_comments(capture, localized, kind, method)
|
|
50
|
+
traces << emissions
|
|
51
|
+
expect(restore_comment_emissions(traced.last, emissions)).to eq(actual.last)
|
|
52
|
+
end
|
|
53
|
+
next if actual.empty?
|
|
54
|
+
|
|
55
|
+
expect(actual.length).to eq(2)
|
|
56
|
+
expect(traced[1]).to eq(traced[0]), "Noncomment source changed for #{renderer} #{kind}/#{method}/#{detail}"
|
|
57
|
+
expect(traces[1].map { |item| item[:prefix] }).to eq(traces[0].map { |item| item[:prefix] })
|
|
58
|
+
differences += 1 if actual[0] != actual[1]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
expect(differences).to be > 20
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'preserves response data and failure semantics across every Ruby and PHP layout' do
|
|
66
|
+
[HaveAPI::ClientExamples::RubyClient, HaveAPI::ClientExamples::PhpClient].each do |renderer|
|
|
67
|
+
%i[hash hash_list object object_list].product([true, false]).each do |layout, success|
|
|
68
|
+
context = context_for_comments(:simple)
|
|
69
|
+
output = { layout:, namespace: :widget, parameters: {
|
|
70
|
+
name: { type: 'String' }, custom: { type: 'Custom' },
|
|
71
|
+
relation: { type: 'Resource', resource: ['owner'] }
|
|
72
|
+
} }
|
|
73
|
+
response = { name: 'literal // # komentář', custom: false, relation: { id: 0 } }
|
|
74
|
+
response = [response] if %i[hash_list object_list].include?(layout)
|
|
75
|
+
context = context.with(action: context.action.merge(output:), sample: context.sample.merge(status: success, response:))
|
|
76
|
+
templates = []
|
|
77
|
+
%i[en cs].each do |locale|
|
|
78
|
+
localized = context.with(locale:)
|
|
79
|
+
actual = renderer.render(:example, context: localized).fetch(:code)
|
|
80
|
+
capture, emissions = comment_capture_class(renderer)
|
|
81
|
+
templates << capture.render(:example, context: localized).fetch(:code)
|
|
82
|
+
expect(restore_comment_emissions(templates.last, emissions)).to eq(actual)
|
|
83
|
+
end
|
|
84
|
+
expect(templates[1]).to eq(templates[0])
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'translates Ruby token reuse and leaves direct legacy renderer calls English' do
|
|
90
|
+
context = context_for_comments(:simple)
|
|
91
|
+
renderer = HaveAPI::ClientExamples::RubyClient
|
|
92
|
+
czech = render_comments(renderer, context.with(locale: :cs), :auth, :token)
|
|
93
|
+
english = render_comments(renderer, context, :auth, :token)
|
|
94
|
+
expect(czech).to include(::I18n.t('haveapi.documentation.comments.reuse_token', locale: :cs))
|
|
95
|
+
expect(czech).not_to include('# Reuse an existing token')
|
|
96
|
+
direct = renderer.new(context.host, context.base_url, context.version).auth(:token, token_description)
|
|
97
|
+
expect(direct).to include('# Reuse an existing token')
|
|
98
|
+
[english, czech].each { |source| expect(Ripper.sexp(source)).not_to be_nil }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'keeps Czech comments syntactically inert in native programs and async fragments' do
|
|
102
|
+
renderers.select { |renderer| %i[ruby php javascript typescript python].include?(renderer.code) }.each do |renderer|
|
|
103
|
+
%i[simple thorough].each do |detail|
|
|
104
|
+
[[:init, nil], %i[auth token], %i[auth oauth2], [:example, nil]].each do |kind, method|
|
|
105
|
+
context = context_for_comments(detail).with(locale: :cs)
|
|
106
|
+
result = renderer.render(kind, context:, auth_method: method, auth_description: token_description)
|
|
107
|
+
next unless result[:status] == :code
|
|
108
|
+
|
|
109
|
+
source = result.fetch(:code)
|
|
110
|
+
case renderer.code
|
|
111
|
+
when :ruby
|
|
112
|
+
expect(Ripper.sexp(source)).not_to be_nil
|
|
113
|
+
when :php
|
|
114
|
+
output, status = Open3.capture2e('php', '-l', stdin_data: "<?php\n#{source}")
|
|
115
|
+
expect(status).to be_success, output
|
|
116
|
+
when :javascript
|
|
117
|
+
Tempfile.create(['haveapi-comment-', '.mjs']) do |file|
|
|
118
|
+
file.write(source)
|
|
119
|
+
file.flush
|
|
120
|
+
output, status = Open3.capture2e('node', '--check', file.path)
|
|
121
|
+
expect(status).to be_success, output
|
|
122
|
+
end
|
|
123
|
+
when :python
|
|
124
|
+
if kind == :example && detail == :simple && renderer == HaveAPI::ClientExamples::PythonAsyncClient
|
|
125
|
+
source = "async def example():\n#{source.lines.map { |line| " #{line}" }.join}"
|
|
126
|
+
end
|
|
127
|
+
output, status = Open3.capture2e('python', '-c', 'import ast, sys; ast.parse(sys.stdin.read())', stdin_data: source)
|
|
128
|
+
expect(status).to be_success, output
|
|
129
|
+
when :typescript
|
|
130
|
+
compiler = File.expand_path('../../../../clients/js/node_modules/typescript', __dir__)
|
|
131
|
+
script = 'const ts = require(process.argv[1]); let src = ""; process.stdin.on("data", x => src += x); process.stdin.on("end", () => { const result = ts.transpileModule(src, { reportDiagnostics: true, compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }); if ((result.diagnostics || []).length) { process.stderr.write(JSON.stringify(result.diagnostics)); process.exitCode = 1; } });'
|
|
132
|
+
output, status = Open3.capture2e('node', '-e', script, compiler, stdin_data: source)
|
|
133
|
+
expect(status).to be_success, output
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'keeps interpolated multiline translations inside actual Ruby comment tokens' do
|
|
141
|
+
localized_context = Class.new(HaveAPI::ClientExample::Context) do
|
|
142
|
+
def example_comment(*)
|
|
143
|
+
"first\r\nputs(:unexpected)\nlast"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
context = context_for_comments(:simple)
|
|
147
|
+
localized = localized_context.new(**HaveAPI::ClientExample::Context::FIELDS.to_h { |field| [field, context.public_send(field)] })
|
|
148
|
+
source = render_comments(HaveAPI::ClientExamples::RubyClient, localized, :auth, :token)
|
|
149
|
+
expect(source).to include("# first\n# puts(:unexpected)\n# last")
|
|
150
|
+
tokens = Ripper.lex(source)
|
|
151
|
+
expect(tokens.reject { |(_, type, _, _)| type == :on_comment }.map { |(_, _, token, _)| token }.join)
|
|
152
|
+
.not_to include('unexpected')
|
|
153
|
+
expect(Ripper.sexp(source)).not_to be_nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'prefixes every physical translated prose line, including interpolated newlines' do
|
|
157
|
+
renderer = HaveAPI::ClientExamples::RubyClient.new('api.test', '/', '1')
|
|
158
|
+
expect(renderer.comment_lines("first\r\nsecond\rthird\n", ' # ')).to eq(" # first\n # second\n # third\n # ")
|
|
159
|
+
expect(renderer.comment_lines("first\nsecond", ' // ')).to eq(" // first\n // second")
|
|
160
|
+
expect(renderer.example_comment('missing', default: 'Type %{value}', value: "A\nB")).to eq("Type A\nB")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -46,6 +46,22 @@ describe HaveAPI::ClientExample::Context do
|
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
it 'captures locale independently of ambient state and retains English defaults' do
|
|
50
|
+
czech = context.with(locale: :cs)
|
|
51
|
+
expect(context.locale).to eq(:en)
|
|
52
|
+
expect(czech.with(detail: :thorough).locale).to eq(:cs)
|
|
53
|
+
::I18n.with_locale(:en) do
|
|
54
|
+
expect(czech.example_comment('reuse_token', default: 'Reuse an existing token')).to eq('Použití existujícího tokenu')
|
|
55
|
+
end
|
|
56
|
+
::I18n.with_locale(:cs) do
|
|
57
|
+
expect(context.example_comment('reuse_token', default: 'Reuse an existing token')).to eq('Reuse an existing token')
|
|
58
|
+
expect(context.example_comment('missing_comment', default: 'Keep %{value}', value: 'literal')).to eq('Keep literal')
|
|
59
|
+
end
|
|
60
|
+
expect(czech.example_comment('instance_of', default: '%{variable} is an instance of %{type}', variable: 'reply', type: 'Type'))
|
|
61
|
+
.to eq('reply je instance Type')
|
|
62
|
+
expect(context).to be_frozen
|
|
63
|
+
end
|
|
64
|
+
|
|
49
65
|
describe '#with' do
|
|
50
66
|
let(:fields) do
|
|
51
67
|
{
|
|
@@ -54,7 +70,7 @@ describe HaveAPI::ClientExample::Context do
|
|
|
54
70
|
description: { 'resources' => { 'widget' => { 'label' => 'Widget' } } },
|
|
55
71
|
resource_path: [:widget, 2], resource: { 'label' => 'Widget' },
|
|
56
72
|
action_name: :show, action: { 'method' => 'GET' },
|
|
57
|
-
sample: { 'request' => { 'values' => [0, false, '', nil] } }, profile: Object.new, detail: :simple
|
|
73
|
+
sample: { 'request' => { 'values' => [0, false, '', nil] } }, profile: Object.new, detail: :simple, locale: :en
|
|
58
74
|
}
|
|
59
75
|
end
|
|
60
76
|
let(:parent) { described_class.new(**fields) }
|
|
@@ -66,7 +82,7 @@ describe HaveAPI::ClientExample::Context do
|
|
|
66
82
|
resource_path: [:other, 3], resource: { 'label' => 'Other' },
|
|
67
83
|
action_name: :update, action: { 'method' => 'POST' },
|
|
68
84
|
sample: { 'request' => { 'values' => [false, 0, nil, ''] } },
|
|
69
|
-
profile: Object.new, detail: :thorough
|
|
85
|
+
profile: Object.new, detail: :thorough, locale: 'cs'
|
|
70
86
|
}
|
|
71
87
|
end
|
|
72
88
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
2
|
require 'open3'
|
|
3
|
+
require 'ripper'
|
|
3
4
|
require_relative 'dynamic_fixture'
|
|
4
5
|
|
|
5
6
|
RSpec.describe HaveAPI::ClientExample, '.render' do
|
|
@@ -57,6 +58,19 @@ RSpec.describe HaveAPI::ClientExample, '.render' do
|
|
|
57
58
|
end
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
it 'localizes built-in limitation prose while preserving executable code' do
|
|
62
|
+
translated = %i[en cs].map do |locale|
|
|
63
|
+
::I18n.with_locale(locale) do
|
|
64
|
+
renderer = HaveAPI::ClientExamples::JsClient
|
|
65
|
+
{ code: renderer.render(:example, context: dynamic_context).fetch(:code),
|
|
66
|
+
reason: renderer.render(:example, context: dynamic_context(sample: { request: nil, response: nil })).fetch(:reason) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
expect(translated.last[:code]).to eq(translated.first[:code])
|
|
70
|
+
expect(translated.last[:reason]).to eq(::I18n.t('haveapi.documentation.limitations.prose_only', locale: :cs))
|
|
71
|
+
expect(translated.last[:reason]).not_to eq(translated.first[:reason])
|
|
72
|
+
end
|
|
73
|
+
|
|
60
74
|
it 'keeps Python async fragments in an explicit async context without adding executable wrappers' do
|
|
61
75
|
source = HaveAPI::ClientExamples::PythonAsyncClient.render(:example, context: dynamic_context).fetch(:code)
|
|
62
76
|
script = 'import ast, sys; ast.parse("async def example():\n" + "".join(" " + line for line in sys.stdin))'
|
|
@@ -143,6 +157,60 @@ RSpec.describe HaveAPI::ClientExample, '.render' do
|
|
|
143
157
|
end
|
|
144
158
|
end
|
|
145
159
|
|
|
160
|
+
it 'loads Ruby before initialization and authentication handlers in both documentation languages' do
|
|
161
|
+
renderer = HaveAPI::ClientExamples::RubyClient
|
|
162
|
+
%i[en cs].product([[:init, nil], %i[auth basic], %i[auth token]]).each do |locale, (kind, method)|
|
|
163
|
+
%i[simple thorough].each do |detail|
|
|
164
|
+
context = dynamic_context(locale:, detail:)
|
|
165
|
+
source = renderer.render(kind, context:, auth_method: method, auth_description: token_description).fetch(:code)
|
|
166
|
+
expect(Ripper.sexp(source)).not_to be_nil
|
|
167
|
+
expect(source.scan("require 'haveapi/client'").length).to eq(1)
|
|
168
|
+
if detail == :thorough
|
|
169
|
+
expect(source).to start_with("require 'haveapi/client'\n\nbegin\n api = HaveAPI::Client.new")
|
|
170
|
+
expect(source.index('api.setup')).to be < source.index('rescue HaveAPI::Client::ValidationError')
|
|
171
|
+
else
|
|
172
|
+
expect(source).to start_with("require 'haveapi/client'\n\napi = HaveAPI::Client.new")
|
|
173
|
+
expect(source).not_to include('begin', 'api.setup', 'rescue ')
|
|
174
|
+
end
|
|
175
|
+
next unless method == :token
|
|
176
|
+
|
|
177
|
+
expect(source).to include('continuations.fetch', "token: 'example-token'",
|
|
178
|
+
::I18n.t('haveapi.documentation.comments.reuse_token', locale:))
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'loads Ruby before handlers for public and protected actions in every response layout' do
|
|
184
|
+
%i[hash hash_list object object_list].product([true, false], [true, false]).each do |layout, success, authenticated|
|
|
185
|
+
context = dynamic_context(detail: :thorough, resource_path: ['widget'], action_name: 'show')
|
|
186
|
+
context = context.with(action: context.action.merge(auth: authenticated, output: context.action[:output].merge(layout:)),
|
|
187
|
+
sample: context.sample.merge(status: success, path_params: [7]))
|
|
188
|
+
source = HaveAPI::ClientExamples::RubyClient.render(:example, context:).fetch(:code)
|
|
189
|
+
expect(Ripper.sexp(source)).not_to be_nil
|
|
190
|
+
expect(source).to start_with("require 'haveapi/client'\n\nbegin\n api = HaveAPI::Client.new")
|
|
191
|
+
expect(source.scan("require 'haveapi/client'").length).to eq(1)
|
|
192
|
+
expect(source.index('api.setup')).to be < source.index('reply = api.widget.show')
|
|
193
|
+
expect(source.index('reply = api.widget.show')).to be < source.index('rescue HaveAPI::Client::ValidationError')
|
|
194
|
+
expect(source.include?("api.authenticate(:token, token: 'example-token')")).to eq(authenticated)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'propagates a Ruby client load failure before evaluating application rescue constants' do
|
|
199
|
+
context = dynamic_context(detail: :thorough)
|
|
200
|
+
source = HaveAPI::ClientExamples::RubyClient.render(:auth, context:, auth_method: :token, auth_description: token_description).fetch(:code)
|
|
201
|
+
missing_library = <<~RUBY
|
|
202
|
+
module Kernel
|
|
203
|
+
def require(name)
|
|
204
|
+
raise LoadError, "missing fixture library: \#{name}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
RUBY
|
|
208
|
+
output, status = Open3.capture2e(RbConfig.ruby, '-e', missing_library + source)
|
|
209
|
+
expect(status).not_to be_success
|
|
210
|
+
expect(output).to include('missing fixture library: haveapi/client', 'LoadError')
|
|
211
|
+
expect(output).not_to include('NameError', 'uninitialized constant')
|
|
212
|
+
end
|
|
213
|
+
|
|
146
214
|
it 'advertises only Simple for built-in limitations without probing another render' do
|
|
147
215
|
context = dynamic_context(sample: dynamic_sample.merge(path_params: [7]))
|
|
148
216
|
results = renderers.to_h { |renderer| [renderer, renderer.render(:example, context:)] }
|
|
@@ -142,4 +142,64 @@ describe HaveAPI::DocumentationHelpers, '#render_version_documentation' do
|
|
|
142
142
|
expect(body).to include('href="/rack/mounted/api/v1/?page=resources">HTTP</a>')
|
|
143
143
|
expect(body).to include('href="https://github.com/vpsfreecz/haveapi/tree/master/clients/ruby#client-library">library</a>')
|
|
144
144
|
end
|
|
145
|
+
|
|
146
|
+
it 'checks each registered renderer availability once for the complete request' do
|
|
147
|
+
HaveAPI::ClientExample.clients.each { |client| allow(client).to receive(:available?).and_call_original }
|
|
148
|
+
get '/v1/'
|
|
149
|
+
expect(last_response.status).to eq(200)
|
|
150
|
+
expect(HaveAPI::ClientExample.clients).to all(have_received(:available?).once)
|
|
151
|
+
metadata = JSON.parse(last_response.body.match(%r{data-documentation-clients>(.*?)</script>}m)[1])
|
|
152
|
+
expect(metadata.map { |client| client['id'] }).to eq(HaveAPI::ClientExample.clients.reject do |client|
|
|
153
|
+
%w[go rust-async rust-blocking typescript-generated].include?(client.id)
|
|
154
|
+
end.map(&:id))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'retains an empty available list for one request and evaluates it afresh on the next' do
|
|
158
|
+
HaveAPI::ClientExample.clients.each { |client| allow(client).to receive(:available?).and_return(false) }
|
|
159
|
+
2.times do
|
|
160
|
+
get '/v1/'
|
|
161
|
+
expect(last_response.status).to eq(200)
|
|
162
|
+
expect(last_response.body).to include('data-documentation-clients>[]</script>')
|
|
163
|
+
expect(last_response.body).not_to include('data-example-preferences')
|
|
164
|
+
end
|
|
165
|
+
expect(HaveAPI::ClientExample.clients).to all(have_received(:available?).twice)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'uses fresh identity, version, locale, description and profiles on consecutive requests' do
|
|
169
|
+
require_relative '../../test_support/documentation_oauth_fixture'
|
|
170
|
+
server = HaveAPI::Server.new(get_opt(:api_module))
|
|
171
|
+
server.use_version([1, 2])
|
|
172
|
+
users = %w[admin member].to_h { |name| [name, Struct.new(:login).new(name)] }
|
|
173
|
+
fixture = DocumentationOAuthFixture.configure(server, users: { 1 => users, 2 => users })
|
|
174
|
+
server.mount('/')
|
|
175
|
+
allow(server).to receive(:describe_version).and_wrap_original do |method, context|
|
|
176
|
+
method.call(context).merge(probe_user: context.current_user&.login)
|
|
177
|
+
end
|
|
178
|
+
configured_profiles = {}
|
|
179
|
+
allow(server).to receive(:documentation_client_profiles) { |_version| configured_profiles }
|
|
180
|
+
observed = []
|
|
181
|
+
allow(HaveAPI::ClientExamples::PythonAsyncClient).to receive(:available?) do |context|
|
|
182
|
+
observed << [context.description[:probe_user], context.version, context.locale]
|
|
183
|
+
context.description[:probe_user] == 'admin' && context.version == '1' && context.locale == :en
|
|
184
|
+
end
|
|
185
|
+
request = lambda do |version, user, locale|
|
|
186
|
+
Rack::MockRequest.new(server.app).get("#{fixture.origin}/v#{version}/",
|
|
187
|
+
'HTTP_HOST' => URI(fixture.origin).host, 'HTTP_ACCEPT_LANGUAGE' => locale,
|
|
188
|
+
'HTTP_COOKIE' => fixture.header_for(server, version:, token: user))
|
|
189
|
+
end
|
|
190
|
+
english = request.call(1, 'admin', 'en')
|
|
191
|
+
expect(english.status).to eq(200)
|
|
192
|
+
expect(english.body).to include('data-client="python-async"')
|
|
193
|
+
czech = request.call(1, 'admin', 'cs')
|
|
194
|
+
expect(czech.status).to eq(200)
|
|
195
|
+
expect(czech.body).not_to include('data-client="python-async"')
|
|
196
|
+
member = request.call(2, 'member', 'en')
|
|
197
|
+
expect(member.status).to eq(200)
|
|
198
|
+
expect(member.body).not_to include('data-client="python-async"', 'data-client="go"')
|
|
199
|
+
configured_profiles[:go] = profile(:go, 'example.test/new-profile')
|
|
200
|
+
with_profile = request.call(2, 'member', 'en')
|
|
201
|
+
expect(with_profile.status).to eq(200)
|
|
202
|
+
expect(with_profile.body).to include('data-client="go"', 'example.test/new-profile')
|
|
203
|
+
expect(observed).to eq([['admin', '1', :en], ['admin', '1', :cs], ['member', '2', :en], ['member', '2', :en]])
|
|
204
|
+
end
|
|
145
205
|
end
|