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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -8
  3. data/README.md +3 -422
  4. data/Rakefile +17 -7
  5. data/benchmarks/performance_compare.rb +27 -6
  6. data/doc/cs/client-examples.md +535 -0
  7. data/doc/cs/create-client.md +118 -0
  8. data/doc/cs/json-schema.html +1182 -0
  9. data/doc/cs/protocol.md +595 -0
  10. data/doc/cs/readme.md +432 -0
  11. data/doc/cs/typed-input-validation.md +122 -0
  12. data/doc/{client-examples.md → en/client-examples.md} +20 -10
  13. data/doc/{documentation-ui.md → en/documentation-ui.md} +42 -6
  14. data/doc/{performance.md → en/performance.md} +11 -0
  15. data/doc/{protocol.md → en/protocol.md} +2 -2
  16. data/doc/en/readme.md +423 -0
  17. data/haveapi.gemspec +2 -2
  18. data/lib/haveapi/client_example.rb +18 -5
  19. data/lib/haveapi/client_examples/curl.rb +5 -5
  20. data/lib/haveapi/client_examples/dynamic_helpers.rb +10 -10
  21. data/lib/haveapi/client_examples/go_client.rb +1 -1
  22. data/lib/haveapi/client_examples/http.rb +4 -4
  23. data/lib/haveapi/client_examples/js_client.rb +10 -10
  24. data/lib/haveapi/client_examples/php_client.rb +24 -25
  25. data/lib/haveapi/client_examples/python_client.rb +11 -11
  26. data/lib/haveapi/client_examples/ruby_cli.rb +6 -9
  27. data/lib/haveapi/client_examples/ruby_client.rb +24 -16
  28. data/lib/haveapi/client_examples/rust_client.rb +1 -1
  29. data/lib/haveapi/client_examples/ts_generated_client.rb +1 -1
  30. data/lib/haveapi/documentation_context.rb +14 -2
  31. data/lib/haveapi/documentation_helpers.rb +92 -15
  32. data/lib/haveapi/documentation_locale.rb +65 -0
  33. data/lib/haveapi/documentation_oauth.rb +12 -12
  34. data/lib/haveapi/documentation_search.rb +5 -5
  35. data/lib/haveapi/documentation_validators.rb +43 -33
  36. data/lib/haveapi/locales/cs.yml +468 -0
  37. data/lib/haveapi/locales/en.yml +467 -0
  38. data/lib/haveapi/public/css/documentation.css +8 -4
  39. data/lib/haveapi/public/js/documentation.js +73 -20
  40. data/lib/haveapi/server.rb +32 -47
  41. data/lib/haveapi/shared_documentation_helpers.rb +53 -5
  42. data/lib/haveapi/version.rb +1 -1
  43. data/lib/haveapi/views/doc_layout.erb +3 -2
  44. data/lib/haveapi/views/doc_sidebars/create-client.erb +10 -10
  45. data/lib/haveapi/views/doc_sidebars/json-schema.erb +2 -2
  46. data/lib/haveapi/views/doc_sidebars/protocol.erb +24 -24
  47. data/lib/haveapi/views/documentation/search_form.erb +5 -4
  48. data/lib/haveapi/views/documentation/search_results.erb +6 -6
  49. data/lib/haveapi/views/index.erb +3 -3
  50. data/lib/haveapi/views/main_layout.erb +4 -4
  51. data/lib/haveapi/views/shared_documentation.erb +8 -5
  52. data/lib/haveapi/views/shared_sidebar.erb +11 -11
  53. data/lib/haveapi/views/version_page/auth_body.erb +7 -7
  54. data/lib/haveapi/views/version_page/client_auth.erb +1 -1
  55. data/lib/haveapi/views/version_page/client_example.erb +1 -1
  56. data/lib/haveapi/views/version_page/client_init.erb +1 -1
  57. data/lib/haveapi/views/version_page/client_overview.erb +7 -5
  58. data/lib/haveapi/views/version_page/example_panels.erb +5 -2
  59. data/lib/haveapi/views/version_page/resource_body.erb +24 -24
  60. data/lib/haveapi/views/version_page/resource_directory.erb +2 -2
  61. data/lib/haveapi/views/version_page/usage.erb +5 -5
  62. data/lib/haveapi/views/version_page.erb +11 -11
  63. data/lib/haveapi/views/version_sidebar/auth_nav.erb +2 -2
  64. data/lib/haveapi/views/version_sidebar/authentication_control.erb +8 -8
  65. data/lib/haveapi/views/version_sidebar/authentication_status.erb +1 -1
  66. data/lib/haveapi/views/version_sidebar/brand.erb +16 -5
  67. data/lib/haveapi/views/version_sidebar.erb +33 -18
  68. data/spec/client_examples/assets/documentation_test.cjs +107 -9
  69. data/spec/client_examples/comment_capture.rb +72 -0
  70. data/spec/client_examples/comments_spec.rb +162 -0
  71. data/spec/client_examples/context_spec.rb +18 -2
  72. data/spec/client_examples/detail_spec.rb +68 -0
  73. data/spec/documentation/client_overview_spec.rb +60 -0
  74. data/spec/documentation/example_selection_spec.rb +192 -0
  75. data/spec/documentation/language_settings_spec.rb +132 -0
  76. data/spec/documentation/locale_spec.rb +367 -0
  77. data/spec/documentation/navigation_spec.rb +61 -3
  78. data/spec/documentation/oauth_spec.rb +42 -0
  79. data/spec/documentation/search_index_routes_spec.rb +8 -4
  80. data/spec/documentation/search_spec.rb +1 -1
  81. data/spec/documentation/shared_pages_spec.rb +63 -2
  82. data/spec/documentation/validators_spec.rb +25 -0
  83. data/spec/extensions/exception_mailer_spec.rb +2 -2
  84. data/spec/packaging_spec.rb +68 -9
  85. data/spec/performance/event_baseline_spec.rb +1 -1
  86. data/spec/performance/source_versions_spec.rb +111 -2
  87. metadata +24 -11
  88. /data/doc/{create-client.md → en/create-client.md} +0 -0
  89. /data/doc/{documentation-oauth.md → en/documentation-oauth.md} +0 -0
  90. /data/doc/{json-schema.html → en/json-schema.html} +0 -0
  91. /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 => display && selector === '[data-documentation-clients]' ? { textContent: JSON.stringify(display.metadata) }
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, false); assert.equal(detailSelect.disabled, true);
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, false);
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, false);
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, false); assert.equal(oneDetail.disabled, true); assert.equal(oneDetail.value, 'simple');
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 copyDetail = Object.assign(element('select'), { closest: selector => selector === '[data-detail-control]' ? { hidden: false } : null });
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(copyDetail.hidden, false);
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