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>test10>
- <1>test21>
+ 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(`
+"
+"
+`);
+ });
+ });
+
+ 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
+
+
+"
+`);
+ });
+ });
+});