diff --git a/.changeset/xml-root-and-allof.md b/.changeset/xml-root-and-allof.md new file mode 100644 index 0000000..49487bc --- /dev/null +++ b/.changeset/xml-root-and-allof.md @@ -0,0 +1,5 @@ +--- +"openapi-sampler": patch +--- + +Fixed XML samples (`format: 'xml'`) that were not well-formed, threw, or dropped data. The root schema's `xml.name` now names the document element, `example` and `default` arrays name their items like sampled arrays, and an `allOf` or `oneOf` property renders as one element. diff --git a/src/allOf.js b/src/allOf.js index a4d3d31..8a81365 100644 --- a/src/allOf.js +++ b/src/allOf.js @@ -1,16 +1,19 @@ import { traverse } from './traverse'; -import { mergeDeep, SKIP_SYMBOL } from './utils'; +import { isAnnotatedRef, mergeDeep, SKIP_SYMBOL, stripSkipped } from './utils'; export function allOfSample(into, children, options, spec, context) { let res = traverse(into, options, spec); const subSamples = []; + const flattenXmlRefs = options.format === 'xml' && !isAnnotatedRef({ ...into, allOf: children }); + for (let subSchema of children) { const childSchema = subSchema.$ref ? subSchema : { type: res.type, ...subSchema }; const { type, readOnly, writeOnly, value } = traverse(childSchema, options, spec, { ...context, isAllOfChild: true, + ...(flattenXmlRefs && { flattenXmlRef: true }), }); if (res.type && type && type !== res.type) { console.warn('allOf: schemas with different types can\'t be merged'); @@ -29,6 +32,10 @@ export function allOfSample(into, children, options, spec, context) { delete res.value[key]; } } + if (options.format === 'xml') { + // An XML $ref wrapper nests the skipped properties one level down + res.value = stripSkipped(res.value); + } return res; } else { if (res.type === 'array') { diff --git a/src/openapi-sampler.js b/src/openapi-sampler.js index 575b025..0501146 100644 --- a/src/openapi-sampler.js +++ b/src/openapi-sampler.js @@ -1,6 +1,7 @@ import { traverse, clearCache } from './traverse'; import { sampleArray, sampleBoolean, sampleNumber, sampleObject, sampleString } from './samplers/index'; import { XMLBuilder } from 'fast-xml-parser'; +import { firstRefXml, getXMLAttributes, isArraySchema, isComposedAllOf, isSampledAsArray, nameLiteralItems, prefixed, withNamespace } from './utils'; export var _samplers = {}; @@ -9,12 +10,27 @@ const defaults = { maxSampleDepth: 15, }; -function convertJsonToXml(obj, schema) { - if (!obj) { +function convertJsonToXml(obj, schema, spec) { + if (obj === undefined || obj === null) { throw new Error('Unknown format output for building XML.'); } - if (Array.isArray(obj) || Object.keys(obj).length > 1) { - obj = { [schema?.xml?.name || 'root']: obj }; // XML document must contain one root element + // traverse() has already named the element of a root $ref, after the $ref and its siblings + const rootSchema = schema?.$ref ? undefined : schema; + const isComposed = isComposedAllOf(rootSchema); + const xmlSource = isComposed && !rootSchema.xml?.name ? firstRefXml(rootSchema.allOf, spec) : rootSchema; + const { name, prefix, namespace } = getXMLAttributes(xmlSource); + const nodeType = rootSchema?.xml?.nodeType; + const isPrimitive = typeof obj !== 'object'; + const isArray = isSampledAsArray(rootSchema); + if (Array.isArray(obj)) { + obj = literalArrayToXml(obj, rootSchema, spec); + } + if (name && !isArray && (!nodeType || nodeType === 'element')) { + obj = { [prefixed(name, prefix)]: withNamespace(obj, prefix, namespace) }; + } else if (isPrimitive || isComposed || Object.keys(obj).length > 1) { + obj = { [name || 'root']: obj }; // XML document must contain one root element + } else if (hasRepeatedRootElement(obj)) { + obj = { root: obj }; // repeated (unwrapped array) elements would otherwise be sibling root elements } const builder = new XMLBuilder({ ignoreAttributes: false, @@ -26,12 +42,28 @@ function convertJsonToXml(obj, schema) { return builder.build(obj); } +function hasRepeatedRootElement(obj) { + const [key, value] = Object.entries(obj)[0] || []; + return Array.isArray(value) && value.length > 1 && !key.startsWith('#') && !key.startsWith('$'); +} + +function literalArrayToXml(values, schema, spec) { + const array = getXMLAttributes(schema); + const items = nameLiteralItems(values, schema, spec, array.name ? prefixed(array.name, array.prefix) : 'root'); + const value = { [items.name]: items.values }; + const nodeType = schema?.xml?.nodeType || (schema?.xml?.wrapped === true ? 'element' : 'none'); + if (isArraySchema(schema) && array.name && nodeType === 'element') { + return { [prefixed(array.name, array.prefix)]: withNamespace(value, array.prefix, array.namespace) }; + } + return value; +} + export function sample(schema, options, spec) { let opts = Object.assign({}, defaults, options); clearCache(); let result = traverse(schema, opts, spec).value; if (opts?.format === 'xml') { - return convertJsonToXml(result, schema); + return convertJsonToXml(result, schema, spec); } return result; }; diff --git a/src/samplers/array.js b/src/samplers/array.js index b7405f1..524fde1 100644 --- a/src/samplers/array.js +++ b/src/samplers/array.js @@ -1,5 +1,5 @@ import { traverse } from '../traverse'; -import { applyXMLAttributes } from '../utils'; +import { applyXMLAttributes, getXMLAttributes, prefixed } from '../utils'; export function sampleArray(schema, options = {}, spec, context) { const depth = (context && context.depth || 1); @@ -25,7 +25,11 @@ export function sampleArray(schema, options = {}, spec, context) { let itemSchema = itemSchemaGetter(i); let { value: sample } = traverse(itemSchema, options, spec, { depth: depth + 1 }); if (options?.format === 'xml') { - const { value, propertyName } = applyXMLAttributes({ value: sample }, itemSchema, context); + let { value, propertyName } = applyXMLAttributes({ value: sample }, itemSchema, context, spec); + if (!propertyName && (value === null || typeof value !== 'object')) { + const { name, prefix } = getXMLAttributes(schema); + propertyName = prefixed(name || 'root', prefix); + } if (propertyName) { if (!res?.[propertyName]) { res = { ...res, [propertyName]: [] }; @@ -40,7 +44,7 @@ export function sampleArray(schema, options = {}, spec, context) { } if (options?.format === 'xml' && depth === 1) { - const { value, propertyName } = applyXMLAttributes({ value: null }, schema, context); + const { value, propertyName } = applyXMLAttributes({ value: null }, schema, context, spec); if (propertyName) { if (value) { res = Array.isArray(res) ? { [propertyName]: { ...value, ...res.map(item => ({ ['#text']: { ...item } })) } } : { [propertyName]: { ...res, ...value } }; diff --git a/src/samplers/object.js b/src/samplers/object.js index a870c0e..c24d3a4 100644 --- a/src/samplers/object.js +++ b/src/samplers/object.js @@ -38,7 +38,7 @@ export function sampleObject(schema, options = {}, spec, context) { } if (options?.format === 'xml') { - const { propertyName: newPropertyName, value } = applyXMLAttributes(sample, schema.properties[propertyName], { propertyName }); + const { propertyName: newPropertyName, value } = applyXMLAttributes(sample, schema.properties[propertyName], { propertyName }, spec); if (newPropertyName) { res[newPropertyName] = value; } else if (value !== null && typeof value === 'object') { diff --git a/src/traverse.js b/src/traverse.js index fa4c73b..552ed8a 100644 --- a/src/traverse.js +++ b/src/traverse.js @@ -1,9 +1,7 @@ import { _samplers } from './openapi-sampler'; import { allOfSample } from './allOf'; import { inferType } from './infer'; -import { getResultForCircular, mergeDeep, popSchemaStack } from './utils'; -import JsonPointer from 'json-pointer'; -import { applyXMLAttributes } from './utils'; +import { applyXMLAttributes, getResultForCircular, mergeDeep, popSchemaStack, resolveRef } from './utils'; let $refCache = {}; // for circular JS references we use additional array and not object as we need to compare entire schemas and not strings @@ -14,19 +12,6 @@ export function clearCache() { seenSchemasStack = []; } -function shouldIgnoreRefSiblings(spec) { - return String(spec.openapi ?? '').startsWith('3.0') || spec.swagger !== undefined; -} - -function mergeRefSiblings(referenced, siblings) { - const merged = mergeDeep(referenced, siblings); - // mergeDeep merges arrays by index, which would drop required names of the referenced schema - if (Array.isArray(referenced.required) && Array.isArray(siblings.required)) { - merged.required = [...new Set([...referenced.required, ...siblings.required])]; - } - return merged; -} - function inferExample(schema) { let example; if (schema.const !== undefined) { @@ -70,29 +55,17 @@ export function traverse(schema, options, spec, context) { } if (schema.$ref) { - if (!spec) { - throw new Error('Your schema contains $ref. You must provide full specification in the third parameter.'); - } - let ref = decodeURIComponent(schema.$ref); - if (ref.startsWith('#')) { - ref = ref.substring(1); - } - - const siblings = Object.fromEntries( - Object.entries(schema).filter(([key, value]) => key !== '$ref' && value !== undefined), - ); - const referenced = Object.keys(siblings).length && !shouldIgnoreRefSiblings(spec) - ? mergeRefSiblings(JsonPointer.get(spec, ref), siblings) - : JsonPointer.get(spec, ref); + const { pointer: ref, referenced } = resolveRef(schema, spec); let result; if ($refCache[ref] !== true) { $refCache[ref] = true; const traverseResult = traverse(referenced, options, spec, context); - if (options.format === 'xml') { + // In a composed allOf, a $ref's properties merge into one element; a $ref typed as an array keeps its own nodes + if (options.format === 'xml' && !(context?.flattenXmlRef && traverseResult.type !== 'array')) { const refName = ref.split('/').pop(); const xmlContext = { ...context, propertyName: context?.propertyName || refName }; - const { propertyName, value } = applyXMLAttributes(traverseResult, referenced, xmlContext); + const { propertyName, value } = applyXMLAttributes(traverseResult, referenced, xmlContext, spec); result = { ...traverseResult, value: { [propertyName || 'root']: value } }; } else { result = traverseResult; diff --git a/src/utils.js b/src/utils.js index bb7efe9..105865d 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,5 +1,8 @@ 'use strict'; +import JsonPointer from 'json-pointer'; +import { inferType } from './infer'; + export const SKIP_SYMBOL = Symbol('skip'); function pad(number) { @@ -73,6 +76,148 @@ export function popSchemaStack(seenSchemasStack, context) { if (context) seenSchemasStack.pop(); } +const STRUCTURAL_KEYWORDS = [ + '$ref', '$dynamicRef', 'properties', 'patternProperties', 'additionalProperties', + 'items', 'prefixItems', 'allOf', 'oneOf', 'anyOf', +]; + +function hasOwnValue(schema) { + return schema.example !== undefined || schema.const !== undefined || schema.default !== undefined + || !!schema.examples?.length || !!schema.enum?.length; +} + +function isAnnotationOnly(schema) { + return !!schema && typeof schema === 'object' && !hasOwnValue(schema) + && !STRUCTURAL_KEYWORDS.some(keyword => schema[keyword] !== undefined); +} + +function isSingleRefAllOf(allOf) { + if (!Array.isArray(allOf)) return false; + const refs = allOf.filter(child => child?.$ref); + return refs.length === 1 && allOf.every(child => child?.$ref || isAnnotationOnly(child)); +} + +export function isAnnotatedRef(schema) { + return !!schema && isSingleRefAllOf(schema.allOf) && schema.xml === undefined + && isAnnotationOnly({ ...schema, allOf: undefined }); +} + +// traverse() samples a schema as an array when its first type, or the type inferred from its keywords, is array +export function isSampledAsArray(schema) { + return !!schema && typeof schema === 'object' && inferType(schema) === 'array'; +} + +// `type: "array"`, which makes `none` the default nodeType; a list of types is sampled as its first +export function isArraySchema(schema) { + const type = schema?.type; + return (Array.isArray(type) ? type[0] : type) === 'array'; +} + +// Copies only the objects it removes something from, so examples from the spec are never written to +export function stripSkipped(value) { + if (!value || typeof value !== 'object') { + return value; + } + let result = value; + for (const [key, child] of Object.entries(value)) { + const stripped = child === SKIP_SYMBOL ? child : stripSkipped(child); + if (stripped === child && child !== SKIP_SYMBOL) { + continue; + } + if (result === value) { + result = Array.isArray(value) ? [...value] : { ...value }; + } + if (child === SKIP_SYMBOL) { + delete result[key]; + } else { + result[key] = stripped; + } + } + return result; +} + +function shouldIgnoreRefSiblings(spec) { + return String(spec.openapi ?? '').startsWith('3.0') || spec.swagger !== undefined; +} + +function mergeRefSiblings(referenced, siblings) { + const merged = mergeDeep(referenced, siblings); + // mergeDeep merges arrays by index, which would drop required names of the referenced schema + if (Array.isArray(referenced.required) && Array.isArray(siblings.required)) { + merged.required = [...new Set([...referenced.required, ...siblings.required])]; + } + return merged; +} + +function refPointer($ref) { + const pointer = decodeURIComponent($ref); + return pointer.startsWith('#') ? pointer.substring(1) : pointer; +} + +// The schema a $ref points to, with the keywords next to the $ref merged in +// (except in OpenAPI 3.0 and Swagger 2.0, where they are ignored) +export function resolveRef(schema, spec) { + if (!spec) { + throw new Error('Your schema contains $ref. You must provide full specification in the third parameter.'); + } + const pointer = refPointer(schema.$ref); + const siblings = Object.fromEntries( + Object.entries(schema).filter(([key, value]) => key !== '$ref' && value !== undefined), + ); + const referenced = Object.keys(siblings).length && !shouldIgnoreRefSiblings(spec) + ? mergeRefSiblings(JsonPointer.get(spec, pointer), siblings) + : JsonPointer.get(spec, pointer); + return { pointer, referenced }; +} + +// The xml of a $ref, named after the referenced component when it has no xml.name. +// traverse() never resolves a $ref under a value it takes as is (example, default, const, enum or examples), +// so here the $ref may be missing or external: then there is no xml to use. +export function refXml(schema, spec) { + if (!spec || !JsonPointer.has(spec, refPointer(schema.$ref))) { + return null; + } + const { pointer, referenced } = resolveRef(schema, spec); + return { ...referenced?.xml, name: referenced?.xml?.name || pointer.split('/').pop() }; +} + +export function isComposedAllOf(schema) { + return !!schema?.allOf && !isAnnotatedRef(schema); +} + +// A composed allOf without a name of its own is named after its first $ref +export function firstRefXml(allOf, spec) { + const ref = allOf.find(child => child?.$ref); + const xml = ref && refXml(ref, spec); + return xml ? { xml } : null; +} + +export function prefixed(name, prefix) { + return prefix ? `${prefix}:${name}` : name; +} + +export function withNamespace(value, prefix, namespace) { + if (!namespace) { + return value; + } + const attribute = `$xmlns${prefix ? ':' + prefix : ''}`; + if (value !== null && typeof value === 'object') { + return Object.assign(Array.isArray(value) ? [...value] : { ...value }, { [attribute]: namespace }); + } + return { [attribute]: namespace, '#text': value }; +} + +// Names the items of a literal array: by their own xml.name or $ref, else by fallbackName +export function nameLiteralItems(values, schema, spec, fallbackName) { + const items = schema?.items; + const itemRefXml = items?.$ref && refXml(items, spec); + const item = getXMLAttributes(itemRefXml ? { xml: itemRefXml } : items); + return { + name: item.name ? prefixed(item.name, item.prefix) : fallbackName, + values: values.map(value => withNamespace(value, item.prefix, item.namespace)), + }; +} + export function getXMLAttributes(schema) { return { name: schema?.xml?.name || '', @@ -83,7 +228,7 @@ export function getXMLAttributes(schema) { }; } -function resolveNodeType(schema) { +function resolveNodeType(schema, resultType) { const xml = schema?.xml; if (xml?.nodeType) { @@ -94,14 +239,29 @@ function resolveNodeType(schema) { return 'attribute'; } - if (xml?.wrapped === true && schema?.type === 'array') { + if (xml?.wrapped === true && isArraySchema(schema)) { return 'element'; } - if (schema?.$ref || schema?.$dynamicRef || schema?.type === 'array') { + if (schema?.$ref || schema?.$dynamicRef || isArraySchema(schema)) { return 'none'; } + if ((schema?.allOf || schema?.oneOf || schema?.anyOf) && hasOwnValue(schema)) { + // traverse() takes this value before the subschemas, so no $ref in them names it + return 'element'; + } + + if (isComposedAllOf(schema)) { + return resultType === 'array' ? 'none' : 'element'; + } + + const variants = schema?.oneOf || schema?.anyOf; + if (Array.isArray(variants) && variants.length) { + // The first variant is the one that gets sampled, so it decides the node type + return resolveNodeType(variants[0], resultType) === 'element' ? 'element' : 'none'; + } + if (schema?.oneOf || schema?.anyOf || schema?.allOf) { return 'none'; } @@ -109,13 +269,21 @@ function resolveNodeType(schema) { return 'element'; } -export function applyXMLAttributes(result, schema = {}, context = {}) { +export function applyXMLAttributes(result, schema = {}, context = {}, spec) { const { value: oldValue } = result; const { propertyName: oldPropertyName } = context; - const { name, prefix, namespace } = getXMLAttributes(schema); - const effectiveNodeType = resolveNodeType(schema); - - let propertyName = name || oldPropertyName ? `${prefix ? prefix + ':' : ''}${name || oldPropertyName}` : null; + // An unnamed composed allOf item, with no property to name it, is named like a root one + const xmlSource = !schema.xml?.name && !oldPropertyName && isComposedAllOf(schema) + ? firstRefXml(schema.allOf, spec) || schema + : schema; + const { name, prefix, namespace } = getXMLAttributes(xmlSource); + const effectiveNodeType = resolveNodeType(schema, result.type); + + let propertyName = name || oldPropertyName ? prefixed(name || oldPropertyName, prefix) : null; + // Sampled arrays are objects by now (unless empty), so an array here is an example, default, etc. + const isLiteralArray = Array.isArray(oldValue) && (isSampledAsArray(schema) || result.type === 'array'); + // An array property whose value is null or a scalar (e.g. `default: null`) has no items: it stays one element + const isItemlessArray = isArraySchema(schema) && !!oldPropertyName && (oldValue === null || typeof oldValue !== 'object'); let value = typeof oldValue === 'object' ? Array.isArray(oldValue) @@ -139,31 +307,24 @@ export function applyXMLAttributes(result, schema = {}, context = {}) { break; case 'none': - if (schema.type === 'array') { - propertyName = null; - if (schema.example !== undefined) { - propertyName = schema.items?.xml?.name || propertyName; - } - } else { + if (isLiteralArray) { + // An unwrapped array's own xml.name is ignored, so unnamed items are named after the property + ({ name: propertyName, values: value } = nameLiteralItems(value, schema, spec, oldPropertyName || propertyName)); + } else if (!isItemlessArray) { propertyName = null; } break; default: - if (schema.type === 'array') { - if (Array.isArray(value)) { - value = { [propertyName]: [...value] }; - } + if (isLiteralArray) { + const items = nameLiteralItems(value, schema, spec, propertyName); + value = { [items.name]: items.values }; } break; } - if (namespace && effectiveNodeType !== 'text' && effectiveNodeType !== 'cdata' && effectiveNodeType !== 'none') { - if (typeof value === 'object') { - value[`$xmlns${prefix ? ':' + prefix : ''}`] = namespace; - } else { - value = { [`$xmlns${prefix ? ':' + prefix : ''}`]: namespace, ['#text']: value }; - } + if (effectiveNodeType !== 'text' && effectiveNodeType !== 'cdata' && effectiveNodeType !== 'none') { + value = withNamespace(value, prefix, namespace); } return { diff --git a/test/integration.spec.js b/test/integration.spec.js index 4e662a5..60c8ea4 100644 --- a/test/integration.spec.js +++ b/test/integration.spec.js @@ -1145,8 +1145,8 @@ describe('Integration', () => { const result = sample(schema, options, {}); expect(result.trim()).toMatchInlineSnapshot(` " - test1 - test2 + test1 + test2 " `); }); @@ -1190,8 +1190,8 @@ describe('Integration', () => { const result = sample(schema, options, {}); expect(result.trim()).toMatchInlineSnapshot(` " - test1 - test2 + test1 + test2 " `); }); @@ -1213,8 +1213,8 @@ describe('Integration', () => { expect(result).toMatchInlineSnapshot(` " - <0>test1 - <1>test2 + test1 + test2 " `); @@ -1236,8 +1236,10 @@ describe('Integration', () => { const result = sample(schema, options, {}); expect(result).toMatchInlineSnapshot(` - "test1 - test2 + " + test1 + test2 + " `); }); @@ -1257,8 +1259,10 @@ describe('Integration', () => { const result = sample(schema, options, {}); expect(result).toMatchInlineSnapshot(` - "test1 - test2 + " + test1 + test2 + " `); }); @@ -1677,7 +1681,11 @@ describe('Integration', () => { }; const result = sample(schema, options, {}); - expect(result.trim()).toMatchInlineSnapshot('"Alice"'); + expect(result.trim()).toMatchInlineSnapshot(` +" + Alice +" +`); }); it('should wrap array when nodeType: element is set (spec example)', () => { @@ -1698,9 +1706,11 @@ describe('Integration', () => { const result = sample(schema, options, {}); expect(result.trim()).toMatchInlineSnapshot(` -" - string -" +" + + string + +" `); }); @@ -1722,9 +1732,11 @@ describe('Integration', () => { const result = sample(schema, options, {}); expect(result.trim()).toMatchInlineSnapshot(` -" - string -" +" + + string + +" `); }); @@ -1909,7 +1921,11 @@ describe('Integration', () => { }; const result = sample(schema, options, {}); - expect(result.trim()).toMatchInlineSnapshot('"string"'); + expect(result.trim()).toMatchInlineSnapshot(` +" + string +" +`); }); it('should allow explicit nodeType: none on an array', () => { @@ -1929,7 +1945,11 @@ describe('Integration', () => { }; const result = sample(schema, options, {}); - expect(result.trim()).toMatchInlineSnapshot('"string"'); + expect(result.trim()).toMatchInlineSnapshot(` +" + string +" +`); }); it('should exclude a primitive property with nodeType: none from XML output', () => { @@ -1986,7 +2006,11 @@ describe('Integration', () => { expect(result).not.toContain('secretScore'); expect(result).not.toContain('42'); - expect(result.trim()).toMatchInlineSnapshot('"Test"'); + expect(result.trim()).toMatchInlineSnapshot(` +" + Test +" +`); }); }); diff --git a/test/unit/utils.spec.js b/test/unit/utils.spec.js new file mode 100644 index 0000000..c00ad83 --- /dev/null +++ b/test/unit/utils.spec.js @@ -0,0 +1,64 @@ +import { isAnnotatedRef, isArraySchema, SKIP_SYMBOL, stripSkipped } from '../../src/utils.js'; + +describe('utils', () => { + describe('stripSkipped', () => { + it('should remove skipped properties nested in objects and arrays', () => { + const value = { + keep: 1, + drop: SKIP_SYMBOL, + nested: { keep: 'a', drop: SKIP_SYMBOL }, + list: [{ keep: true, drop: SKIP_SYMBOL }, 'text'], + }; + + expect(stripSkipped(value)).toEqual({ + keep: 1, + nested: { keep: 'a' }, + list: [{ keep: true }, 'text'], + }); + }); + + it('should not modify its input', () => { + const nested = Object.freeze({ keep: 'a', drop: SKIP_SYMBOL }); + const untouched = Object.freeze({ tags: Object.freeze(['a', 'b']) }); + const value = Object.freeze({ drop: SKIP_SYMBOL, nested, untouched }); + + const result = stripSkipped(value); + + expect(result).toEqual({ nested: { keep: 'a' }, untouched: { tags: ['a', 'b'] } }); + expect(result.untouched).toBe(untouched); + expect(nested.drop).toBe(SKIP_SYMBOL); + }); + + it('should return the same object when there is nothing to remove', () => { + const value = { keep: 1, nested: { list: [1, 2] } }; + + expect(stripSkipped(value)).toBe(value); + }); + }); + + describe('isArraySchema', () => { + it('should accept type array, or a list of types that starts with array', () => { + expect(isArraySchema({ type: 'array' })).toBe(true); + expect(isArraySchema({ type: ['array', 'null'] })).toBe(true); + expect(isArraySchema({ type: ['string', 'array'] })).toBe(false); + expect(isArraySchema({ items: { type: 'string' } })).toBe(false); + expect(isArraySchema({ type: 'object' })).toBe(false); + expect(isArraySchema(undefined)).toBe(false); + }); + }); + + describe('isAnnotatedRef', () => { + it('should only accept an allOf of a single $ref that adds nothing of its own', () => { + const ref = { $ref: '#/components/schemas/Pet' }; + + expect(isAnnotatedRef({ allOf: [ref] })).toBe(true); + expect(isAnnotatedRef({ allOf: [ref, { description: 'A pet' }], nullable: true })).toBe(true); + expect(isAnnotatedRef({ allOf: [ref], xml: { name: 'Critter' } })).toBe(false); + expect(isAnnotatedRef({ allOf: [ref], properties: { nick: { type: 'string' } } })).toBe(false); + expect(isAnnotatedRef({ allOf: [ref, { properties: {} }] })).toBe(false); + expect(isAnnotatedRef({ allOf: [ref, ref] })).toBe(false); + expect(isAnnotatedRef({ allOf: [ref, { example: { id: 1 } }] })).toBe(false); + expect(isAnnotatedRef({ allOf: [ref], default: 'active' })).toBe(false); + }); + }); +}); diff --git a/test/xml.spec.js b/test/xml.spec.js new file mode 100644 index 0000000..5631ec9 --- /dev/null +++ b/test/xml.spec.js @@ -0,0 +1,1357 @@ +import { sample } from '../src/openapi-sampler.js'; + +describe('XML', () => { + describe('xml document root', () => { + const options = { + format: 'xml', + }; + + it('should use the root schema xml.name as the root element when it has a single property', () => { + const schema = { + type: 'object', + xml: { name: 'Thing' }, + properties: { + foo: { type: 'string' }, + }, + }; + + expect(sample(schema, options, {})).toMatchInlineSnapshot(` +" + string + +" +`); + }); + + it('should apply the root schema xml.prefix and xml.namespace to the root element', () => { + const schema = { + type: 'object', + xml: { name: 'Pet', prefix: 'p', namespace: 'http://example.com/ns' }, + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + }, + }; + + expect(sample(schema, options, {})).toMatchInlineSnapshot(` +" + string + string + +" +`); + }); + + it('should apply the root schema xml.namespace without a prefix', () => { + const schema = { + type: 'object', + xml: { name: 'Pet', namespace: 'http://example.com/schema' }, + properties: { + name: { type: 'string', example: 'Guru' }, + lang: { type: 'string', example: 'en', xml: { attribute: true } }, + }, + }; + + expect(sample(schema, options, {})).toMatchInlineSnapshot(` +" + Guru + +" +`); + }); + + it('should keep the existing root for a schema without xml.name', () => { + const schema = { + type: 'object', + properties: { + foo: { type: 'string' }, + bar: { type: 'string' }, + }, + }; + + expect(sample(schema, options, {})).toMatchInlineSnapshot(` +" + string + string + +" +`); + }); + + it.each([ + ['integer', { type: 'integer' }, '0'], + ['boolean', { type: 'boolean' }, 'true'], + ['empty string', { type: 'string', example: '' }, ''], + ['single-character string', { type: 'string', example: 'a' }, 'a'], + ['named string', { type: 'string', xml: { name: 'Message' } }, 'string'], + ])('should build XML for a root %s', (_label, schema, expected) => { + expect(sample(schema, options, {}).trim()).toBe(expected); + }); + + it('should not split a root array of strings into characters', () => { + const schema = { + type: 'array', + items: { type: 'string' }, + }; + + expect(sample(schema, options, {})).toMatchInlineSnapshot(` +"string +" +`); + }); + + it('should name root array items after the array xml.name', () => { + const schema = { + type: 'array', + minItems: 2, + items: { type: 'string', example: 'tag' }, + xml: { name: 'Tags', wrapped: true }, + }; + + expect(sample(schema, options, {})).toMatchInlineSnapshot(` +" + tag + tag + +" +`); + }); + }); + + describe('xml root arrays', () => { + const options = { + format: 'xml', + }; + const spec = { + components: { + schemas: { + Pet: { + type: 'object', + xml: { name: 'Animal', prefix: 'p', namespace: 'http://example.com/pet' }, + properties: { name: { type: 'string' } }, + }, + }, + }, + }; + + it('should wrap a nullable root array once', () => { + const schema = { + type: ['array', 'null'], + xml: { name: 'Pets', wrapped: true }, + items: { $ref: '#/components/schemas/Pet' }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + string + + +" +`); + }); + + it('should wrap the items of a root schema with items but no type in one element', () => { + // Without `type: "array"`, the nodeType defaults to element, so the array is its own element + const schema = { + xml: { name: 'Tags' }, + items: { type: 'string' }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + +" +`); + }); + + it('should keep a single root element for several unnamed scalar items', () => { + const schema = { + type: 'array', + minItems: 2, + items: { type: 'string' }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + string + +" +`); + }); + }); + + describe('xml allOf', () => { + const options = { + format: 'xml', + }; + const spec = { + components: { + schemas: { + Base: { + type: 'object', + properties: { + id: { type: 'integer', example: 7 }, + }, + }, + Tag: { + type: 'object', + xml: { name: 'Tag' }, + properties: { + label: { type: 'string', example: 'cute' }, + }, + }, + }, + }, + }; + + it('should merge a $ref into the root allOf instead of wrapping it', () => { + const schema = { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { type: 'object', properties: { name: { type: 'string' } } }, + ], + xml: { name: 'Merged' }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + 7 + string + +" +`); + }); + + it('should keep a composed allOf property in its own element', () => { + const schema = { + type: 'object', + properties: { + pet: { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { type: 'object', properties: { name: { type: 'string' } } }, + ], + }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + 7 + string + + string + +" +`); + }); + + it('should keep an inline allOf property in its own element', () => { + const schema = { + type: 'object', + properties: { + pet: { + allOf: [{ type: 'object', properties: { name: { type: 'string' } } }], + xml: { name: 'Animal' }, + }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + string + + string + +" +`); + }); + + it('should keep naming a property that wraps a single $ref in allOf after the referenced schema', () => { + const schema = { + type: 'object', + properties: { + tag: { + allOf: [{ $ref: '#/components/schemas/Tag' }], + description: 'A tag', + }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + + + string + +" +`); + }); + }); + + describe('xml annotated $ref and oneOf properties', () => { + const options = { + format: 'xml', + }; + const spec = { + components: { + schemas: { + Base: { + type: 'object', + properties: { + id: { type: 'integer', example: 7 }, + }, + }, + Pet: { + type: 'object', + xml: { name: 'Animal', prefix: 'p', namespace: 'http://example.com/pet' }, + properties: { name: { type: 'string' } }, + }, + Account: { + type: 'object', + properties: { + id: { type: 'integer', example: 7 }, + secret: { type: 'string', readOnly: true }, + }, + }, + }, + }, + }; + + it('should name a root allOf of a $ref and an annotation like the $ref', () => { + const schema = { + allOf: [{ $ref: '#/components/schemas/Pet' }, { description: 'A pet' }], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + +" +`); + }); + + it('should name an allOf property of a $ref and an annotation like the $ref', () => { + const schema = { + type: 'object', + properties: { + pet: { allOf: [{ $ref: '#/components/schemas/Pet' }, { nullable: true }] }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + string + + string + +" +`); + }); + + it('should skip readOnly properties of an allOf $ref without throwing', () => { + const schema = { + type: 'object', + properties: { + account: { allOf: [{ $ref: '#/components/schemas/Account' }] }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, { ...options, skipReadOnly: true }, spec)).toMatchInlineSnapshot(` +" + + 7 + + string + +" +`); + }); + + it('should keep a oneOf property whose variant is a composed allOf in its own element', () => { + const schema = { + type: 'object', + properties: { + pet: { + oneOf: [ + { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { type: 'object', properties: { name: { type: 'string' } } }, + ], + }, + ], + }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + 7 + string + + string + +" +`); + }); + + it('should keep a oneOf property whose variant is an inline object in its own element', () => { + const schema = { + type: 'object', + properties: { + pet: { oneOf: [{ type: 'object', properties: { name: { type: 'string' } } }] }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + string + + string + +" +`); + }); + + it('should keep a oneOf property whose variant is a scalar', () => { + const schema = { + type: 'object', + properties: { + code: { oneOf: [{ type: 'string' }, { type: 'integer' }] }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + string + +" +`); + }); + + it('should keep naming a oneOf property whose variant is a $ref after the referenced schema', () => { + const schema = { + type: 'object', + properties: { + pet: { oneOf: [{ $ref: '#/components/schemas/Pet' }] }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + string + + string + +" +`); + }); + }); + + describe('xml allOf with an xml override or own properties', () => { + const options = { + format: 'xml', + }; + const spec = { + components: { + schemas: { + Base: { + type: 'object', + properties: { + id: { type: 'integer', example: 7 }, + }, + }, + Pet: { + type: 'object', + xml: { name: 'Animal', prefix: 'p', namespace: 'http://example.com/pet' }, + properties: { name: { type: 'string' } }, + }, + Tags: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }; + + it('should name a root allOf of a single $ref after its own xml.name', () => { + const schema = { + allOf: [{ $ref: '#/components/schemas/Pet' }], + xml: { name: 'Doc' }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + +" +`); + }); + + it('should name a property allOf of a single $ref after its own xml.name', () => { + const schema = { + type: 'object', + properties: { + pet: { allOf: [{ $ref: '#/components/schemas/Pet' }], xml: { name: 'Critter' } }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + string + + string + +" +`); + }); + + it('should merge a property\'s own properties with its allOf of a single $ref', () => { + const schema = { + type: 'object', + properties: { + pet: { + type: 'object', + properties: { nick: { type: 'string' } }, + allOf: [{ $ref: '#/components/schemas/Base' }], + }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + string + 7 + + string + +" +`); + }); + + it('should merge the root schema\'s own properties with its allOf of a single $ref', () => { + const schema = { + type: 'object', + xml: { name: 'Merged' }, + properties: { nick: { type: 'string' } }, + allOf: [{ $ref: '#/components/schemas/Base' }], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + 7 + +" +`); + }); + + it('should not wrap a composed allOf that is sampled as an array twice', () => { + const schema = { + type: 'object', + properties: { + tags: { + allOf: [ + { $ref: '#/components/schemas/Tags' }, + { items: { type: 'string', maxLength: 3 } }, + ], + }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, { ...options, quiet: true }, spec)).toMatchInlineSnapshot(` +" + str + string + +" +`); + }); + + it('should keep a property whose first type is not array but lists array', () => { + const schema = { + type: 'object', + properties: { + value: { type: ['string', 'array'], items: { type: 'string' } }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + string + +" +`); + }); + + it('should name a root composed allOf without xml.name after its first $ref', () => { + const schema = { + allOf: [ + { $ref: '#/components/schemas/Pet' }, + { type: 'object', properties: { age: { type: 'integer' } } }, + ], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + 0 + +" +`); + }); + + it('should keep the document element of a root composed allOf that merges into one property', () => { + const schema = { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { properties: { id: { maximum: 5 } } }, + ], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + 5 + +" +`); + }); + + it('should wrap a root composed allOf without any $ref in root', () => { + const schema = { + allOf: [ + { type: 'object', properties: { id: { type: 'integer' } } }, + { type: 'object', properties: { name: { type: 'string' } } }, + ], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + 0 + string + +" +`); + }); + }); + + describe('xml root examples and edge cases', () => { + const options = { + format: 'xml', + }; + const spec = { + components: { + schemas: { + Pet: { + type: 'object', + xml: { name: 'Animal', prefix: 'p', namespace: 'http://example.com/pet' }, + properties: { name: { type: 'string' } }, + }, + User: { + type: 'object', + properties: { + id: { type: 'integer', readOnly: true }, + tenant_id: { type: 'integer' }, + username: { type: 'string' }, + }, + }, + PatchUser: { + allOf: [ + { $ref: '#/components/schemas/User' }, + { type: 'object', properties: { tenant_id: { readOnly: true } } }, + ], + }, + }, + }, + }; + + it('should keep a root example array under one element', () => { + const schema = { + xml: { name: 'Tag' }, + example: ['a', 'b'], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + a + b + +" +`); + }); + + it('should declare the namespace of a root example array on its element', () => { + const schema = { + xml: { name: 'Tag', namespace: 'urn:tags' }, + example: ['a', 'b'], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + a + b + +" +`); + }); + + it('should wrap a wrapped root array example in the array element', () => { + const schema = { + type: 'array', + xml: { name: 'Tags', wrapped: true }, + items: { type: 'string' }, + example: ['a', 'b'], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + a + b + +" +`); + }); + + it('should name the items of a wrapped root array example after the items xml.name', () => { + const schema = { + type: 'array', + xml: { name: 'Tags', nodeType: 'element' }, + items: { type: 'string', xml: { name: 'tag', prefix: 't', namespace: 'urn:tag' } }, + example: ['a', 'b'], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + a + b + +" +`); + }); + + it('should keep repeated elements of a single root property under one root element', () => { + const schema = { + type: 'object', + properties: { + tags: { type: 'array', minItems: 2, items: { type: 'string' } }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + string + +" +`); + }); + + it('should not wrap an unwrapped nullable array property twice', () => { + const schema = { + type: 'object', + properties: { + tags: { type: ['array', 'null'], items: { type: 'string' } }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + string + +" +`); + }); + + it('should wrap an untyped array property in its own element', () => { + // Without `type: "array"`, the nodeType defaults to element + const schema = { + type: 'object', + properties: { + tags: { items: { type: 'string' } }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + string + + string + +" +`); + }); + + it('should skip a property made readOnly by a composed allOf over a $ref', () => { + const schema = { $ref: '#/components/schemas/PatchUser' }; + + expect(sample(schema, { ...options, skipReadOnly: true }, spec)).toMatchInlineSnapshot(` +" + string + +" +`); + }); + + it('should throw for a root null sample', () => { + expect(() => sample({ type: 'null' }, options, spec)).toThrow('Unknown format output for building XML.'); + }); + + it('should render a oneOf property whose first variant is null as an empty element', () => { + const schema = { + type: 'object', + properties: { + pet: { oneOf: [{ type: 'null' }, { $ref: '#/components/schemas/Pet' }] }, + owner: { type: 'string' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + string + +" +`); + }); + + it('should name unnamed scalar items of an array nested in an array root', () => { + const schema = { + type: 'array', + items: { type: 'array', items: { type: 'string' } }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +"string +" +`); + }); + + it('should name unnamed scalar items of an additionalProperties array root', () => { + const schema = { + type: 'object', + xml: { name: 'Map' }, + additionalProperties: { type: 'array', items: { type: 'string' } }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + string + + + string + + +" +`); + }); + }); + + describe('xml literal arrays', () => { + const options = { format: 'xml' }; + const spec = { + components: { + schemas: { + Customer: { type: 'object', properties: { id: { type: 'integer' } } }, + Order: { + type: 'object', + xml: { name: 'order', prefix: 'o', namespace: 'urn:o' }, + properties: { id: { type: 'integer' } }, + }, + }, + }, + }; + + it('should name the items of a property example array after the property', () => { + const schema = { + type: 'object', + xml: { name: 'Doc' }, + properties: { + tags: { type: 'array', items: { type: 'string' }, example: ['a', 'b'] }, + }, + }; + + expect(sample(schema, options)).toMatchInlineSnapshot(` +" + a + b + +" +`); + }); + + it('should name the items of a property default array after the property', () => { + const schema = { + type: 'object', + xml: { name: 'Doc' }, + properties: { + tags: { type: 'array', items: { type: 'string' }, default: ['a', 'b'] }, + }, + }; + + expect(sample(schema, options)).toMatchInlineSnapshot(` +" + a + b + +" +`); + }); + + it('should apply the items prefix and namespace to the items of a property example array', () => { + const schema = { + type: 'object', + xml: { name: 'Doc' }, + properties: { + tags: { + type: 'array', + items: { type: 'string', xml: { name: 'tag', prefix: 't', namespace: 'urn:t' } }, + example: ['a', 'b'], + }, + }, + }; + + expect(sample(schema, options)).toMatchInlineSnapshot(` +" + a + b + +" +`); + }); + + it('should name the $ref items of a property example array like sampled items', () => { + const schema = { + type: 'object', + xml: { name: 'Doc' }, + properties: { + customers: { + type: 'array', + items: { $ref: '#/components/schemas/Customer' }, + example: [{ id: 1 }, { id: 2 }], + }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + 1 + + + 2 + + +" +`); + }); + + it('should name the $ref items of a root example array like sampled items', () => { + const schema = { + type: 'array', + items: { $ref: '#/components/schemas/Customer' }, + example: [{ id: 1 }, { id: 2 }], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + 1 + + + 2 + + +" +`); + expect(sample({ type: 'array', items: { $ref: '#/components/schemas/Customer' } }, options, spec)) + .toMatchInlineSnapshot(` +" + 0 + +" +`); + }); + + it('should use the xml.name, prefix and namespace of $ref items in a root example array', () => { + const schema = { + type: 'array', + items: { $ref: '#/components/schemas/Order' }, + example: [{ id: 1 }, { id: 2 }], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + 1 + + + 2 + + +" +`); + }); + }); + + describe('xml root $ref with siblings', () => { + const options = { format: 'xml' }; + const schemas = { + Customer: { type: 'object', properties: { id: { type: 'integer' } } }, + Documentation: { + type: 'object', + xml: { nodeType: 'none' }, + properties: { + content: { type: 'string', contentMediaType: 'text/html', xml: { nodeType: 'cdata' }, example: '

Hi

' }, + }, + }, + Pet: { + type: 'object', + xml: { name: 'Animal', prefix: 'p', namespace: 'urn:p' }, + properties: { name: { type: 'string' } }, + }, + }; + const spec = { openapi: '3.1.0', components: { schemas } }; + + it('should name the document element once from an xml sibling of a root $ref', () => { + const schema = { $ref: '#/components/schemas/Customer', xml: { name: 'Client' } }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + 0 + +" +`); + }); + + it('should ignore an xml sibling of a root $ref in OpenAPI 3.0', () => { + const schema = { $ref: '#/components/schemas/Customer', xml: { name: 'Client' } }; + + expect(sample(schema, options, { openapi: '3.0.3', components: { schemas } })).toMatchInlineSnapshot(` +" + 0 + +" +`); + }); + + it('should name a referenced nodeType none schema after the root $ref xml sibling', () => { + const schema = { + $ref: '#/components/schemas/Documentation', + xml: { nodeType: 'element', name: 'StoredDocument' }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + Hi

]]> +
+" +`); + }); + + it('should name a root composed allOf after the xml sibling of its first $ref', () => { + const schema = { + allOf: [ + { $ref: '#/components/schemas/Pet', xml: { name: 'Critter' } }, + { properties: { age: { type: 'integer' } } }, + ], + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + string + 0 + +" +`); + }); + + it('should name a root composed allOf with an example after its first $ref', () => { + const schema = { + allOf: [ + { $ref: '#/components/schemas/Customer' }, + { properties: { note: { type: 'string' } } }, + ], + example: { id: 1, note: 'n' }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + 1 + n + +" +`); + }); + + it('should not need a spec for a root composed allOf with an example', () => { + // traverse() returns the example without resolving the $ref, so no spec is required + const schema = { + allOf: [ + { $ref: '#/components/schemas/Customer' }, + { properties: { note: { type: 'string' } } }, + ], + example: { id: 1, note: 'n' }, + }; + + expect(sample(schema, options)).toMatchInlineSnapshot(` +" + 1 + n + +" +`); + }); + }); + + describe('xml compositions with their own value', () => { + const options = { format: 'xml' }; + const spec = { + components: { + schemas: { + Status: { type: 'string', enum: ['active', 'inactive'] }, + Money: { type: 'object', properties: { amount: { type: 'number' }, currency: { type: 'string' } } }, + }, + }, + }; + + it('should keep an allOf $ref property with its own default', () => { + const schema = { + type: 'object', + xml: { name: 'Doc' }, + properties: { + status: { allOf: [{ $ref: '#/components/schemas/Status' }], default: 'inactive' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + inactive + +" +`); + }); + + it('should keep a oneOf $ref property with its own example', () => { + const schema = { + type: 'object', + xml: { name: 'Doc' }, + properties: { + status: { oneOf: [{ $ref: '#/components/schemas/Status' }], example: 'inactive' }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + inactive + +" +`); + }); + + it('should keep an object example of an allOf $ref property in its own element', () => { + const schema = { + type: 'object', + xml: { name: 'Doc' }, + properties: { + price: { allOf: [{ $ref: '#/components/schemas/Money' }], example: { amount: 1, currency: 'GBP' } }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + 1 + GBP + + +" +`); + }); + }); + + describe('xml spec objects', () => { + const deepFreeze = value => { + Object.values(value).forEach(child => child && typeof child === 'object' && deepFreeze(child)); + return Object.freeze(value); + }; + const spec = { + components: { + schemas: { + Base: { type: 'object', properties: { id: { type: 'string', readOnly: true } } }, + }, + }, + }; + const schema = { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { type: 'object', example: { meta: { tags: ['a', 'b'] } } }, + ], + }; + + it('should sample a deep-frozen schema and spec', () => { + expect(sample(deepFreeze(structuredClone(schema)), { format: 'xml', skipReadOnly: true }, deepFreeze(structuredClone(spec)))) + .toMatchInlineSnapshot(` +" + + a + b + + +" +`); + }); + + it('should not modify examples in the schema', () => { + const ownSchema = structuredClone(schema); + const { meta } = ownSchema.allOf[1].example; + const tags = meta.tags; + + sample(ownSchema, { format: 'xml', skipReadOnly: true }, spec); + + expect(ownSchema.allOf[1].example.meta).toBe(meta); + expect(meta.tags).toBe(tags); + expect(tags).toEqual(['a', 'b']); + }); + }); + + describe('xml arrays in compositions and unusual values', () => { + const options = { format: 'xml', quiet: true }; + const spec = { + openapi: '3.0.3', + components: { + schemas: { + Tags: { type: 'array', items: { type: 'string' }, example: ['a', 'b'] }, + Item: { type: 'object', xml: { name: 'item' }, properties: { id: { type: 'integer' } } }, + }, + }, + }; + + it('should name the items of an example array under an allOf $ref with its own xml', () => { + const schema = { + type: 'object', + xml: { name: 'Doc' }, + properties: { + tags: { allOf: [{ $ref: '#/components/schemas/Tags' }], xml: { name: 'tag' } }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + a + b + +" +`); + }); + + it('should name the items of an example array whose $ref items cannot be resolved after the property', () => { + const schema = { + type: 'object', + xml: { name: 'Doc' }, + properties: { + pets: { type: 'array', items: { $ref: '#/components/schemas/Missing' }, example: [{ id: 1 }] }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + 1 + + +" +`); + }); + + it('should wrap a root composed allOf with an example and an external $ref in root', () => { + const schema = { + allOf: [{ $ref: 'pets.yaml#/Pet' }, { properties: { id: { type: 'integer' } } }], + example: { id: 1 }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + 1 + +" +`); + }); + + it('should keep an empty element for a null array property', () => { + const schema = { + type: 'object', + xml: { name: 'Doc' }, + properties: { + tags: { type: ['array', 'null'], items: { type: 'string' }, default: null }, + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + +" +`); + }); + + it('should name the composed allOf items of a root array after their first $ref', () => { + const schema = { + type: 'array', + minItems: 2, + items: { + allOf: [ + { $ref: '#/components/schemas/Item' }, + { type: 'object', properties: { note: { type: 'string' } } }, + ], + }, + }; + + expect(sample(schema, options, spec)).toMatchInlineSnapshot(` +" + + 0 + string + + + 0 + string + + +" +`); + }); + }); +});