diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a6b8d7b..fb916343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ This package follows standard semver, `..`. No breaking cha ## 2.4 * Add support for [OpenAPI Spec 3.2](https://www.openapis.org/blog/2025/09/23/announcing-openapi-v3-2). +* Separate multiple `examples` of a schema with `┃` when they are displayed, instead of running them together. ## 2.3 * Schema types will now utilize the title property for displaying, rather than the type ID, when defined. diff --git a/src/components/api-request.js b/src/components/api-request.js index c08cc984..20cdf8e7 100644 --- a/src/components/api-request.js +++ b/src/components/api-request.js @@ -6,7 +6,7 @@ import formatXml from 'xml-but-prettier'; import { getI18nText } from '../languages/index.js'; import { schemaInObjectNotation, getTypeInfo, generateExample, isPatternProperty } from '../utils/schema-utils.js'; -import { toMarkdown } from '../utils/common-utils.js'; +import { toMarkdown, convertToDisplayString } from '../utils/common-utils.js'; import './schema-tree.js'; import getRequestFormTable from './request-form-table.js'; import './tag-input.js'; @@ -16,6 +16,11 @@ import json5 from 'json5'; const textFileRegex = RegExp('^font/|tar$|zip$|7z$|rtf$|msword$|excel$|/pdf$|/octet-stream$|^application/vnd.'); const mediaFileRegex = RegExp('^audio/|^image/|^video/'); +// The placeholder of a parameter input is its first example. +function exampleText(examples) { + return convertToDisplayString(examples[0] ?? ''); +} + export default class ApiRequest extends LitElement { createRenderRoot() { return this; } @@ -202,7 +207,7 @@ export default class ApiRequest extends LitElement { data-param-serialize-style = "${paramStyle}" data-param-serialize-explode = "${paramExplode}" spellcheck = "false" - placeholder="${generatedParamSchema.example || defaultVal || ''}" + placeholder="${exampleText(generatedParamSchema.example) || defaultVal || ''}" style = "width:100%; margin-top: 1rem; margin-bottom: 1rem;" .value="${this.fillRequestWithDefault === 'true' ? defaultVal : ''}">` || generatedParamSchema.allowedValues && html` @@ -224,7 +229,7 @@ export default class ApiRequest extends LitElement { id="request-param-${paramName}" aria-labelledby = "request-${paramName}-label" @input="${() => { this.computeCurlSyntax(); }}" - placeholder="${generatedParamSchema.example || defaultVal || ''}" + placeholder="${exampleText(generatedParamSchema.example) || defaultVal || ''}" class="request-param" part="textbox textbox-param" data-ptype="${paramLocation}" diff --git a/src/components/api-response.js b/src/components/api-response.js index f5e3ecf9..012a6338 100644 --- a/src/components/api-response.js +++ b/src/components/api-response.js @@ -1,7 +1,7 @@ import { LitElement, html, css } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { schemaInObjectNotation, generateExample, getTypeInfo } from '../utils/schema-utils.js'; -import { toMarkdown } from '../utils/common-utils.js'; +import { toMarkdown, formatExamples } from '../utils/common-utils.js'; import { getI18nText } from '../languages/index.js'; import FontStyles from '../styles/font-styles.js'; import FlexStyles from '../styles/flex-styles.js'; @@ -240,7 +240,7 @@ export default class ApiResponse extends LitElement {
${unsafeHTML(toMarkdown(v.description || ''))}
- ${typeData?.example ?? typeData?.default ?? ''} + ${formatExamples(typeData?.example ?? []) ?? typeData?.default ?? ''} `; diff --git a/src/components/request-form-table.js b/src/components/request-form-table.js index 0148e129..4e29e3e8 100644 --- a/src/components/request-form-table.js +++ b/src/components/request-form-table.js @@ -1,6 +1,6 @@ /* eslint-disable indent */ import { html } from 'lit'; -import { toMarkdown } from '../utils/common-utils.js'; +import { toMarkdown, formatExamples, convertToDisplayString } from '../utils/common-utils.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { isPatternProperty } from '../utils/schema-utils.js'; import { map } from 'lit/directives/map.js'; @@ -78,6 +78,11 @@ function generateFormRows(data, options, dataType = 'object', key = '', descript function generatePrimitiveRow(rowData, parentRecursionOptions) { const { type, format, readOrWriteOnly, constraints, defaultValue, example, allowedValues, pattern, schemaDescription, schemaTitle, deprecated } = rowData; const { key, keyLabel, keyDescr, description, dataType, isRequired, options } = parentRecursionOptions; + const exampleLabel = formatExamples(example); + const [filledExample] = example; + const exampleFill = type === 'array' && Array.isArray(filledExample) + ? filledExample.join('~|~') + : convertToDisplayString(filledExample ?? ''); if (readOrWriteOnly === '🆁') { return undefined; } @@ -106,8 +111,8 @@ function generatePrimitiveRow(rowData, parentRecursionOptions) { ${inputFieldKeyLabel.call(this, key.startsWith('::OPTION'), keyLabel, keyDescr, dataType, deprecated, isRequired, schemaTitle, format || type, rowGenerator)} - ${dataType === 'array' ? getArrayFormField.call(this, keyLabel, example, defaultValue, format, rowGenerator) : ''} - ${dataType !== 'array' ? getPrimitiveFormField.call(this, keyLabel, example, defaultValue, format, options, rowGenerator) : ''} + ${dataType === 'array' ? getArrayFormField.call(this, keyLabel, filledExample, defaultValue, format, rowGenerator) : ''} + ${dataType !== 'array' ? getPrimitiveFormField.call(this, keyLabel, exampleFill, defaultValue, format, options, rowGenerator) : ''} ${description ? html`
${unsafeHTML(toMarkdown(description))}
` : ''} ${defaultValue || constraints || allowedValues || pattern @@ -138,17 +143,16 @@ function generatePrimitiveRow(rowData, parentRecursionOptions) { - ${schemaDescription || example ? html` + ${schemaDescription || exampleLabel ? html` ${unsafeHTML(toMarkdown(schemaDescription || ''))} - ${example + ${exampleLabel ? html` Example: - ${type === 'array' ? '[ ' : ''} - ${type === 'array' ? example.join(', ') : example} + ${exampleLabel} - ${type === 'array' ? '] ' : ''} ` : ''} diff --git a/src/components/schema-table.js b/src/components/schema-table.js index 4ea72a23..ea82f07e 100644 --- a/src/components/schema-table.js +++ b/src/components/schema-table.js @@ -1,5 +1,5 @@ import { LitElement, html, css } from 'lit'; -import { toMarkdown } from '../utils/common-utils.js'; +import { toMarkdown, formatExamples } from '../utils/common-utils.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import FontStyles from '../styles/font-styles.js'; import SchemaStyles from '../styles/schema-styles.js'; @@ -293,6 +293,7 @@ export default class SchemaTable extends LitElement { // For Primitive Data types const { type, cssType, format, readOrWriteOnly, constraints, defaultValue, example, allowedValues, pattern, schemaDescription, schemaTitle, deprecated } = JSON.parse(data); + const exampleLabel = formatExamples(example); if (readOrWriteOnly === '🆁' && this.schemaHideReadOnly === 'true') { return { result: undefined, keyLabelMaxCharacterLength: newIndentLevel }; } @@ -322,7 +323,7 @@ export default class SchemaTable extends LitElement { ${defaultValue !== '' ? html`
Default: ${defaultValue}

` : ''} ${allowedValues ? html`
Allowed: ${allowedValues.filter(v => v !== null && v !== undefined).join(' ┃ ')}

` : ''} ${pattern ? html`
Pattern: ${pattern}

` : ''} - ${example ? html`
Example: ${example}

` : ''} + ${exampleLabel ? html`
Example: ${exampleLabel}

` : ''} `; diff --git a/src/components/schema-tree.js b/src/components/schema-tree.js index 97cca90c..99ed9095 100644 --- a/src/components/schema-tree.js +++ b/src/components/schema-tree.js @@ -1,5 +1,5 @@ import { LitElement, html, css } from 'lit'; -import { toMarkdown } from '../utils/common-utils.js'; +import { toMarkdown, formatExamples } from '../utils/common-utils.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { getI18nText } from '../languages/index.js'; import FontStyles from '../styles/font-styles.js'; @@ -283,6 +283,7 @@ export default class SchemaTree extends LitElement { // For Primitive Data types const { type, cssType, format, readOrWriteOnly, constraints, defaultValue, example, allowedValues, pattern, schemaDescription, schemaTitle, deprecated } = JSON.parse(data); + const exampleLabel = formatExamples(example); if (readOrWriteOnly === '🆁' && this.schemaHideReadOnly === 'true') { return undefined; } @@ -315,7 +316,7 @@ export default class SchemaTree extends LitElement { ${defaultValue !== '' ? html`
Default: ${defaultValue}

` : ''} ${allowedValues ? html`
Allowed: ${allowedValues.filter(v => v !== null && v !== undefined).join(' ┃ ')}

` : ''} ${pattern ? html`
Pattern: ${pattern}

` : ''} - ${example ? html`
Example: ${example}

` : ''}` : ''} + ${exampleLabel ? html`
Example: ${exampleLabel}

` : ''}` : ''} `; diff --git a/src/utils/common-utils.js b/src/utils/common-utils.js index a55e96c5..db7c9b7f 100644 --- a/src/utils/common-utils.js +++ b/src/utils/common-utils.js @@ -163,6 +163,14 @@ export function toMarkdown(markdownStringRaw) { return markdownResult; } +export function convertToDisplayString(entry) { + return typeof entry === 'string' ? entry : JSON.stringify(entry); +} + +export function formatExamples(examples) { + return examples.map(convertToDisplayString).join(' ┃ '); +} + export function getSanitizedUrl(urlString) { if (!urlString) { return ''; diff --git a/src/utils/schema-utils.js b/src/utils/schema-utils.js index a1ebb4e4..479b0e92 100644 --- a/src/utils/schema-utils.js +++ b/src/utils/schema-utils.js @@ -7,6 +7,47 @@ import xmlFormatter from './xml/xml.js'; const IS_MISSING_TYPE_INFO_TYPE = ''; const EXAMPLE_VALUE_FOR_MISSING_TYPE = ''; +// OpenAPI 3.1 `examples` is a list. A legacy `example` is one value, even when that value is an array. +// An examples map (OpenAPI 3.0) contributes each entry's value. No example is an empty list. +function schemaExampleList(schema, options) { + const listed = listedExamples(schema.examples); + if (listed) { + return listed; + } + if (Object.prototype.hasOwnProperty.call(schema, 'example') && schema.example !== undefined) { + return [schema.example]; + } + if (options?.enableExampleGeneration) { + const generated = getSampleValueByType(schema, null); + if (generated !== undefined && generated !== '') { + return [generated]; + } + } + return []; +} + +function listedExamples(examples) { + if (Array.isArray(examples)) { + return examples.length ? examples : null; + } + if (!examples || typeof examples !== 'object') { + return null; + } + const values = []; + for (const entry of Object.values(examples)) { + if (entry && typeof entry === 'object') { + if (Object.prototype.hasOwnProperty.call(entry, 'value') && entry.value !== undefined) { + values.push(entry.value); + } + continue; + } + if (entry !== undefined) { + values.push(entry); + } + } + return values.length ? values : null; +} + /* Generates an schema object containing type and constraint info */ export function getTypeInfo(parameter, options = { includeNulls: false, enableExampleGeneration: false }) { if (!parameter) { @@ -35,7 +76,7 @@ export function getTypeInfo(parameter, options = { includeNulls: false, enableEx } } - const examples = schema.examples || schema.example || options?.enableExampleGeneration && getSampleValueByType(schema, null) || ''; + const examples = schemaExampleList(schema, options); const info = { type: dataType, format, @@ -43,7 +84,7 @@ export function getTypeInfo(parameter, options = { includeNulls: false, enableEx pattern: (schema.pattern && !schema.enum) ? schema.pattern.replace(/(^\^)|(\$$)/g, '') : '', readOrWriteOnly: schema.readOnly && '🆁' || schema.writeOnly && '🆆' || '', deprecated: !!schema.deprecated, - example: examples || '', + example: examples, default: schema.default ?? '', title: schema.title || '', description: schema.description || '', diff --git a/tests/common-utils.test.js b/tests/common-utils.test.js new file mode 100644 index 00000000..3ea1ddce --- /dev/null +++ b/tests/common-utils.test.js @@ -0,0 +1,47 @@ +import test from 'ava'; +import { expect } from 'chai'; + +import { convertToDisplayString, formatExamples } from '../src/utils/common-utils.js'; + +test('common-utils.js formatExamples separates multiple examples', t => { + expect(formatExamples(['apple', 'banana', 'pear'])).to.equal('apple ┃ banana ┃ pear'); + t.pass(); +}); + +test('common-utils.js formatExamples keeps a single entry as is', t => { + expect(formatExamples(['apple'])).to.equal('apple'); + t.pass(); +}); + +test('common-utils.js formatExamples writes examples which are not text as JSON', t => { + expect(formatExamples([1, 2, 3])).to.equal('1 ┃ 2 ┃ 3'); + expect(formatExamples([true, null])).to.equal('true ┃ null'); + expect(formatExamples([{ id: 1 }, { id: 2 }])).to.equal('{"id":1} ┃ {"id":2}'); + expect(formatExamples([['a', 'b'], ['c']])).to.equal('["a","b"] ┃ ["c"]'); + t.pass(); +}); + +test('common-utils.js formatExamples gives an empty list no text', t => { + expect(formatExamples([])).to.equal(''); + t.pass(); +}); + +test('common-utils.js formatExamples prints one legacy example, kept as a one entry list, as text', t => { + expect(formatExamples(['apple'])).to.equal('apple'); + expect(formatExamples([5])).to.equal('5'); + expect(formatExamples([''])).to.equal(''); + expect(formatExamples([{ id: 1 }])).to.equal('{"id":1}'); + expect(formatExamples([['a', 'b']])).to.equal('["a","b"]'); + t.pass(); +}); + +test('common-utils.js convertToDisplayString keeps text as is and writes any other value as JSON', t => { + expect(convertToDisplayString('apple')).to.equal('apple'); + expect(convertToDisplayString('')).to.equal(''); + expect(convertToDisplayString(0)).to.equal('0'); + expect(convertToDisplayString(false)).to.equal('false'); + expect(convertToDisplayString(null)).to.equal('null'); + expect(convertToDisplayString(['a', 'b'])).to.equal('["a","b"]'); + expect(convertToDisplayString({ id: 1 })).to.equal('{"id":1}'); + t.pass(); +}); diff --git a/tests/schema-utils.examples.test.js b/tests/schema-utils.examples.test.js new file mode 100644 index 00000000..ebaed210 --- /dev/null +++ b/tests/schema-utils.examples.test.js @@ -0,0 +1,65 @@ +import test from 'ava'; +import { expect } from 'chai'; + +import { getTypeInfo } from '../src/utils/schema-utils.js'; + +test('schema-utils.js getTypeInfo returns the examples of a schema as they are', t => { + expect(getTypeInfo({ type: 'string', examples: ['apple', 'banana', 'pear'] }).example).to.deep.equal(['apple', 'banana', 'pear']); + expect(getTypeInfo({ type: 'integer', examples: [1, 2, 3] }).example).to.deep.equal([1, 2, 3]); + expect(getTypeInfo({ type: 'object', examples: [{ id: 1 }, { id: 2 }] }).example).to.deep.equal([{ id: 1 }, { id: 2 }]); + t.pass(); +}); + +test('schema-utils.js getTypeInfo keeps a single entry of examples as a list', t => { + expect(getTypeInfo({ type: 'string', examples: ['apple'] }).example).to.deep.equal(['apple']); + t.pass(); +}); + +test('schema-utils.js getTypeInfo hands the examples to the schema views as a list', t => { + const info = getTypeInfo({ type: 'string', examples: ['apple', 'banana'] }); + + expect(JSON.parse(info.html).example).to.deep.equal(['apple', 'banana']); + t.pass(); +}); + +test('schema-utils.js getTypeInfo wraps a legacy example as one entry', t => { + expect(getTypeInfo({ type: 'string', example: 'apple' }).example).to.deep.equal(['apple']); + // The example value is an array, so the list has one entry and the value stays intact + expect(getTypeInfo({ type: 'array', items: { type: 'string' }, example: ['a', 'b'] }).example).to.deep.equal([['a', 'b']]); + expect(getTypeInfo({ type: 'integer', example: 0 }).example).to.deep.equal([0]); + expect(getTypeInfo({ type: 'boolean', example: false }).example).to.deep.equal([false]); + t.pass(); +}); + +test('schema-utils.js getTypeInfo prefers the examples over a single example', t => { + expect(getTypeInfo({ type: 'string', examples: ['apple', 'banana'], example: 'pear' }).example).to.deep.equal(['apple', 'banana']); + t.pass(); +}); + +test('schema-utils.js getTypeInfo uses a legacy example when the examples list is empty', t => { + expect(getTypeInfo({ type: 'string', examples: [], example: 'pear' }).example).to.deep.equal(['pear']); + t.pass(); +}); + +test('schema-utils.js getTypeInfo reads values from an examples map', t => { + const info = getTypeInfo({ + type: 'string', + examples: { red: { value: 'red' }, blue: { value: 'blue' }, none: { summary: 'missing value' } } + }); + + expect(info.example).to.deep.equal(['red', 'blue']); + t.pass(); +}); + +test('schema-utils.js getTypeInfo returns an empty list when a schema has no example', t => { + expect(getTypeInfo({ type: 'string' }).example).to.deep.equal([]); + expect(JSON.parse(getTypeInfo({ type: 'string' }).html).example).to.deep.equal([]); + t.pass(); +}); + +test('schema-utils.js getTypeInfo generates one example only when asked and none is present', t => { + expect(getTypeInfo({ type: 'string' }, { enableExampleGeneration: true }).example).to.deep.equal(['string']); + expect(getTypeInfo({ type: 'boolean' }, { enableExampleGeneration: true }).example).to.deep.equal([false]); + expect(getTypeInfo({ type: 'string', example: 'apple' }, { enableExampleGeneration: true }).example).to.deep.equal(['apple']); + t.pass(); +});