From f3e810b539182117b86e6043d1148da9876fb5c4 Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Wed, 14 Oct 2026 17:05:02 +0100
Subject: [PATCH 01/11] fix: xml root element, root arrays and allOf
composition
- Name the document element after the root schema's xml.name, with its
prefix and namespace, also when the object has a single property (as in
the OpenAPI 3.2 `` examples).
- Stop splitting unnamed scalar array items into one element per
character, keep several unnamed root items under one root element and
treat nullable or untyped arrays as arrays.
- Merge $refs composed in an allOf into one element; keep a single
(optionally annotated) $ref named like a plain $ref.
- Render oneOf/anyOf properties whose first variant is an inline object,
a composed allOf or a scalar as their own element.
- Strip skipped readOnly/writeOnly properties nested under a $ref wrapper
in an allOf instead of throwing.
- Wrap root scalar samples in a root element.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
.changeset/xml-root-and-allof.md | 13 +++++++
src/allOf.js | 12 ++++++-
src/openapi-sampler.js | 31 ++++++++++++++--
src/samplers/array.js | 11 ++++--
src/traverse.js | 2 +-
src/utils.js | 61 +++++++++++++++++++++++++++++---
6 files changed, 119 insertions(+), 11 deletions(-)
create mode 100644 .changeset/xml-root-and-allof.md
diff --git a/.changeset/xml-root-and-allof.md b/.changeset/xml-root-and-allof.md
new file mode 100644
index 0000000..356014e
--- /dev/null
+++ b/.changeset/xml-root-and-allof.md
@@ -0,0 +1,13 @@
+---
+"openapi-sampler": patch
+---
+
+Fixed several bugs in XML samples (`format: 'xml'`):
+
+- The root schema's `xml.name`, `xml.prefix` and `xml.namespace` now always name the document element. They were dropped when the object had a single property, unlike the `` examples in the OpenAPI 3.2 specification.
+- A root array of strings no longer splits each string into one element per character, and several unnamed root items stay under one root element.
+- Nullable arrays (`type: ['array', 'null']`) and arrays with `items` but no `type` are treated as arrays, so they are no longer wrapped twice.
+- A `$ref` composed with other schemas in an `allOf` is merged into one element instead of nesting its properties in an extra element. Composed `allOf` properties and array items now get their own element instead of spilling their properties into the parent. An `allOf` of a single `$ref` (with or without annotation-only siblings such as `description` or `nullable`) is still named like the `$ref`.
+- A `oneOf`/`anyOf` property whose first variant is an inline object, a composed `allOf` or a scalar is now rendered as its own element. Scalar variants were previously dropped, and object variants spilled into the parent.
+- `skipReadOnly`/`skipWriteOnly` no longer throw "Cannot convert a Symbol value to a string" for an `allOf` that wraps a `$ref`.
+- A root scalar sample is wrapped in a root element. Previously `0` and `""` threw, `true` rendered as an empty string and `"a"` rendered as `<0>a0>`.
diff --git a/src/allOf.js b/src/allOf.js
index f7d69da..9f34bd6 100644
--- a/src/allOf.js
+++ b/src/allOf.js
@@ -1,15 +1,21 @@
import { traverse } from './traverse';
-import { mergeDeep, SKIP_SYMBOL } from './utils';
+import { isSingleRefAllOf, mergeDeep, SKIP_SYMBOL, stripSkipped } from './utils';
export function allOfSample(into, children, options, spec, context) {
let res = traverse(into, options, spec);
const subSamples = [];
+ // In XML, a $ref is normally wrapped in an element named after it. The children of a
+ // composed allOf describe one element together, so their properties are merged flat.
+ // A lone $ref keeps its wrapper, so it is named like a plain $ref would be.
+ const flattenXmlRefs = options.format === 'xml' && !isSingleRefAllOf(children);
+
for (let subSchema of children) {
const { type, readOnly, writeOnly, value } = traverse({ type: res.type, ...subSchema }, 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');
@@ -28,6 +34,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
+ stripSkipped(res.value);
+ }
return res;
} else {
if (res.type === 'array') {
diff --git a/src/openapi-sampler.js b/src/openapi-sampler.js
index 575b025..6aa25b5 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 { getXMLAttributes, isArraySchema } from './utils';
export var _samplers = {};
@@ -10,11 +11,20 @@ const defaults = {
};
function convertJsonToXml(obj, schema) {
- if (!obj) {
+ 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
+ const { name, prefix, namespace } = getXMLAttributes(schema);
+ const nodeType = schema?.xml?.nodeType;
+ const isPrimitive = typeof obj !== 'object';
+ const isArray = isArraySchema(schema);
+ if (name && !isArray && (!nodeType || nodeType === 'element')) {
+ // The root schema names the document element, whatever its content is
+ obj = { [`${prefix ? prefix + ':' : ''}${name}`]: withNamespace(obj, prefix, namespace) };
+ } else if (isPrimitive || Array.isArray(obj) || Object.keys(obj).length > 1) {
+ obj = { [name || 'root']: obj }; // XML document must contain one root element
+ } else if (isArray && hasRepeatedRootElement(obj)) {
+ obj = { root: obj }; // unwrapped root array items would otherwise be sibling root elements
}
const builder = new XMLBuilder({
ignoreAttributes: false,
@@ -26,6 +36,21 @@ function convertJsonToXml(obj, schema) {
return builder.build(obj);
}
+function hasRepeatedRootElement(obj) {
+ const [value] = Object.values(obj);
+ return Array.isArray(value) && value.length > 1;
+}
+
+function withNamespace(value, prefix, namespace) {
+ if (!namespace) {
+ return value;
+ }
+ const attribute = `$xmlns${prefix ? ':' + prefix : ''}`;
+ return typeof value === 'object' && !Array.isArray(value)
+ ? { ...value, [attribute]: namespace }
+ : { [attribute]: namespace, '#text': value };
+}
+
export function sample(schema, options, spec) {
let opts = Object.assign({}, defaults, options);
clearCache();
diff --git a/src/samplers/array.js b/src/samplers/array.js
index b7405f1..414b0fe 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 } from '../utils';
export function sampleArray(schema, options = {}, spec, context) {
const depth = (context && context.depth || 1);
@@ -25,7 +25,14 @@ 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);
+ const xmlResult = applyXMLAttributes({ value: sample }, itemSchema, context);
+ const { value } = xmlResult;
+ let { propertyName } = xmlResult;
+ if (!propertyName && (value === null || typeof value !== 'object')) {
+ // An unnamed scalar item takes the array's name (spreading it would split a string into characters)
+ const { name, prefix } = getXMLAttributes(schema);
+ propertyName = `${prefix ? prefix + ':' : ''}${name || 'root'}`;
+ }
if (propertyName) {
if (!res?.[propertyName]) {
res = { ...res, [propertyName]: [] };
diff --git a/src/traverse.js b/src/traverse.js
index b6f478a..22fef1a 100644
--- a/src/traverse.js
+++ b/src/traverse.js
@@ -71,7 +71,7 @@ export function traverse(schema, options, spec, context) {
if ($refCache[ref] !== true) {
$refCache[ref] = true;
const traverseResult = traverse(referenced, options, spec, context);
- if (options.format === 'xml') {
+ if (options.format === 'xml' && !context?.flattenXmlRef) {
const refName = ref.split('/').pop();
const xmlContext = { ...context, propertyName: context?.propertyName || refName };
const { propertyName, value } = applyXMLAttributes(traverseResult, referenced, xmlContext);
diff --git a/src/utils.js b/src/utils.js
index bb7efe9..9d4d178 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -73,6 +73,48 @@ export function popSchemaStack(seenSchemasStack, context) {
if (context) seenSchemasStack.pop();
}
+const STRUCTURAL_KEYWORDS = [
+ '$ref', '$dynamicRef', 'properties', 'patternProperties', 'additionalProperties',
+ 'items', 'prefixItems', 'allOf', 'oneOf', 'anyOf',
+];
+
+function isAnnotationOnly(schema) {
+ return !!schema && typeof schema === 'object'
+ && !STRUCTURAL_KEYWORDS.some(keyword => schema[keyword] !== undefined);
+}
+
+// `allOf: [{ $ref }]`, optionally next to annotation-only siblings such as
+// `{ description }` or `{ nullable: true }`, is the usual way to annotate a $ref:
+// it describes the referenced schema, so it is named like a plain $ref.
+export 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 isArraySchema(schema) {
+ const type = schema?.type;
+ if (Array.isArray(type)) return type.includes('array');
+ if (type) return type === 'array';
+ return schema?.items !== undefined || schema?.prefixItems !== undefined;
+}
+
+export function stripSkipped(value) {
+ if (Array.isArray(value)) {
+ return value.map(stripSkipped);
+ }
+ if (value && typeof value === 'object') {
+ for (const key of Object.keys(value)) {
+ if (value[key] === SKIP_SYMBOL) {
+ delete value[key];
+ } else {
+ value[key] = stripSkipped(value[key]);
+ }
+ }
+ }
+ return value;
+}
+
export function getXMLAttributes(schema) {
return {
name: schema?.xml?.name || '',
@@ -94,14 +136,25 @@ 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 && !isSingleRefAllOf(schema.allOf)) {
+ return 'element';
+ }
+
+ const variants = schema?.oneOf || schema?.anyOf;
+ if (Array.isArray(variants) && variants.length) {
+ // The sample is taken from the first variant, so it decides the node type: a $ref
+ // or array variant names its own nodes, any other variant is this property's element.
+ return resolveNodeType(variants[0]) === 'element' ? 'element' : 'none';
+ }
+
if (schema?.oneOf || schema?.anyOf || schema?.allOf) {
return 'none';
}
@@ -139,7 +192,7 @@ export function applyXMLAttributes(result, schema = {}, context = {}) {
break;
case 'none':
- if (schema.type === 'array') {
+ if (isArraySchema(schema)) {
propertyName = null;
if (schema.example !== undefined) {
propertyName = schema.items?.xml?.name || propertyName;
@@ -150,7 +203,7 @@ export function applyXMLAttributes(result, schema = {}, context = {}) {
break;
default:
- if (schema.type === 'array') {
+ if (isArraySchema(schema)) {
if (Array.isArray(value)) {
value = { [propertyName]: [...value] };
}
From fe0f1a6ceb4c6602236e422d54d8960d74a3a2bc Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Wed, 14 Oct 2026 17:07:44 +0100
Subject: [PATCH 02/11] test: cover the xml root, root array, allOf and oneOf
fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Six existing nodeType snapshots now include the root element named by the
root schema's xml.name, matching the corresponding examples in the
OpenAPI 3.2 specification (`…`).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
test/integration.spec.js | 505 ++++++++++++++++++++++++++++++++++++++-
1 file changed, 495 insertions(+), 10 deletions(-)
diff --git a/test/integration.spec.js b/test/integration.spec.js
index 370ad58..c00289a 100644
--- a/test/integration.spec.js
+++ b/test/integration.spec.js
@@ -1399,6 +1399,471 @@ describe('Integration', () => {
});
});
+ 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 treat a root schema with items but no type as an array', () => {
+ 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 nodeType', () => {
const options = {
format: 'xml',
@@ -1537,7 +2002,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)', () => {
@@ -1558,9 +2027,11 @@ describe('Integration', () => {
const result = sample(schema, options, {});
expect(result.trim()).toMatchInlineSnapshot(`
-"
- string
-"
+"
+
+ string
+
+"
`);
});
@@ -1582,9 +2053,11 @@ describe('Integration', () => {
const result = sample(schema, options, {});
expect(result.trim()).toMatchInlineSnapshot(`
-"
- string
-"
+"
+
+ string
+
+"
`);
});
@@ -1769,7 +2242,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', () => {
@@ -1789,7 +2266,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', () => {
@@ -1846,7 +2327,11 @@ describe('Integration', () => {
expect(result).not.toContain('secretScore');
expect(result).not.toContain('42');
- expect(result.trim()).toMatchInlineSnapshot('"Test"');
+ expect(result.trim()).toMatchInlineSnapshot(`
+"-
+ Test
+
"
+`);
});
});
From 8d8011352b2cf7e2bb4eee58f078716ccc3f64d2 Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Thu, 15 Oct 2026 00:08:16 +0100
Subject: [PATCH 03/11] fix: annotated refs with xml, composed arrays and root
example arrays
From a review of the XML fixes:
- An allOf of a single $ref that adds its own xml or properties is merged
and named by the schema, instead of nesting the $ref's element inside it.
- A composed allOf that samples as an array is no longer wrapped twice.
- isArraySchema uses inferType, the type resolution sampling uses, so a
property typed ['string', 'array'] is no longer dropped.
- A root composed allOf without xml.name is named after its first $ref
(its xml.name, prefix and namespace, or the component name).
- Root example arrays produce one well-formed document element, including
with a namespace and for wrapped arrays.
- Repeated root elements are always kept under one root element; two
existing snapshots that produced two document elements now wrap them.
- Tests: the cases above, unwrapped nullable and untyped property arrays,
the issue #140 PatchUser + skipReadOnly case, the root null throw, a
null-first oneOf, nested and additionalProperties arrays, and unit tests
for stripSkipped, isArraySchema and isAnnotatedRef. 205 tests, 73
snapshots; JSON output unchanged.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
.changeset/xml-root-and-allof.md | 10 +++---
src/allOf.js | 7 ++--
src/openapi-sampler.js | 55 +++++++++++++++++++++++++++-----
test/unit/utils.spec.js | 44 +++++++++++++++++++++++++
4 files changed, 101 insertions(+), 15 deletions(-)
create mode 100644 test/unit/utils.spec.js
diff --git a/.changeset/xml-root-and-allof.md b/.changeset/xml-root-and-allof.md
index 356014e..fc78439 100644
--- a/.changeset/xml-root-and-allof.md
+++ b/.changeset/xml-root-and-allof.md
@@ -2,12 +2,14 @@
"openapi-sampler": patch
---
-Fixed several bugs in XML samples (`format: 'xml'`):
+Fixed several bugs in XML samples (`format: 'xml'`). JSON samples are unchanged. XML samples change for the schemas below, so documentation that shows them (for example Redoc's XML response samples) will render the corrected XML:
- The root schema's `xml.name`, `xml.prefix` and `xml.namespace` now always name the document element. They were dropped when the object had a single property, unlike the `` examples in the OpenAPI 3.2 specification.
- A root array of strings no longer splits each string into one element per character, and several unnamed root items stay under one root element.
-- Nullable arrays (`type: ['array', 'null']`) and arrays with `items` but no `type` are treated as arrays, so they are no longer wrapped twice.
-- A `$ref` composed with other schemas in an `allOf` is merged into one element instead of nesting its properties in an extra element. Composed `allOf` properties and array items now get their own element instead of spilling their properties into the parent. An `allOf` of a single `$ref` (with or without annotation-only siblings such as `description` or `nullable`) is still named like the `$ref`.
+- Repeated elements are always kept under one root element, so a root object whose only property is an unwrapped array, and a root `example` array, now produce well-formed XML. A wrapped root array `example` is wrapped in the array's element, as in the specification's wrapped array examples.
+- Arrays are detected with the same type resolution that is used for sampling, so unwrapped nullable arrays (`type: ['array', 'null']`) and arrays with `items` but no `type` are no longer wrapped twice, and a property with `type: ['string', 'array']` is rendered as its first type.
+- A `$ref` composed with other schemas in an `allOf` is merged into one element instead of nesting its properties in an extra element. Composed `allOf` properties and array items now get their own element instead of spilling their properties into the parent, unless the composition is sampled as an array. An `allOf` of a single `$ref` (with or without annotation-only siblings such as `description` or `nullable`) is still named like the `$ref`, unless the schema adds its own `xml` or properties: then it is merged and named by the schema.
+- A root composed `allOf` without an `xml.name` is named after its first `$ref` (its `xml.name`, prefix and namespace, or the component name), so its document element is no longer lost.
- A `oneOf`/`anyOf` property whose first variant is an inline object, a composed `allOf` or a scalar is now rendered as its own element. Scalar variants were previously dropped, and object variants spilled into the parent.
-- `skipReadOnly`/`skipWriteOnly` no longer throw "Cannot convert a Symbol value to a string" for an `allOf` that wraps a `$ref`.
+- `skipReadOnly`/`skipWriteOnly` no longer throw "Cannot convert a Symbol value to a string" for an `allOf` that wraps a `$ref`, including an `allOf` that marks a property of the `$ref` as `readOnly`.
- A root scalar sample is wrapped in a root element. Previously `0` and `""` threw, `true` rendered as an empty string and `"a"` rendered as `<0>a0>`.
diff --git a/src/allOf.js b/src/allOf.js
index 9f34bd6..88a76df 100644
--- a/src/allOf.js
+++ b/src/allOf.js
@@ -1,5 +1,5 @@
import { traverse } from './traverse';
-import { isSingleRefAllOf, mergeDeep, SKIP_SYMBOL, stripSkipped } from './utils';
+import { isAnnotatedRef, mergeDeep, SKIP_SYMBOL, stripSkipped } from './utils';
export function allOfSample(into, children, options, spec, context) {
@@ -8,8 +8,9 @@ export function allOfSample(into, children, options, spec, context) {
// In XML, a $ref is normally wrapped in an element named after it. The children of a
// composed allOf describe one element together, so their properties are merged flat.
- // A lone $ref keeps its wrapper, so it is named like a plain $ref would be.
- const flattenXmlRefs = options.format === 'xml' && !isSingleRefAllOf(children);
+ // A lone $ref keeps its wrapper, so it is named like a plain $ref would be, unless the
+ // schema adds properties or its own xml to it: then it is composed like any other allOf.
+ const flattenXmlRefs = options.format === 'xml' && !isAnnotatedRef({ ...into, allOf: children });
for (let subSchema of children) {
const { type, readOnly, writeOnly, value } = traverse({ type: res.type, ...subSchema }, options, spec, {
diff --git a/src/openapi-sampler.js b/src/openapi-sampler.js
index 6aa25b5..efe2ff6 100644
--- a/src/openapi-sampler.js
+++ b/src/openapi-sampler.js
@@ -1,7 +1,8 @@
import { traverse, clearCache } from './traverse';
import { sampleArray, sampleBoolean, sampleNumber, sampleObject, sampleString } from './samplers/index';
import { XMLBuilder } from 'fast-xml-parser';
-import { getXMLAttributes, isArraySchema } from './utils';
+import JsonPointer from 'json-pointer';
+import { getXMLAttributes, isAnnotatedRef, isArraySchema } from './utils';
export var _samplers = {};
@@ -10,21 +11,28 @@ const defaults = {
maxSampleDepth: 15,
};
-function convertJsonToXml(obj, schema) {
+function convertJsonToXml(obj, schema, spec) {
if (obj === undefined || obj === null) {
throw new Error('Unknown format output for building XML.');
}
- const { name, prefix, namespace } = getXMLAttributes(schema);
+ const isComposed = !!schema?.allOf && !isAnnotatedRef(schema);
+ // A composed allOf merges its $refs into one element, so without a name of its own
+ // the document element is named after its first $ref, like that $ref alone would be
+ const { name, prefix, namespace } = getXMLAttributes(schema?.xml?.name || !isComposed ? schema : firstRefXml(schema.allOf, spec));
const nodeType = schema?.xml?.nodeType;
const isPrimitive = typeof obj !== 'object';
const isArray = isArraySchema(schema);
+ if (Array.isArray(obj)) {
+ // A literal array (e.g. an `example`) has no XML item names yet: name them as sampleArray would
+ obj = literalArrayToXml(obj, schema);
+ }
if (name && !isArray && (!nodeType || nodeType === 'element')) {
// The root schema names the document element, whatever its content is
- obj = { [`${prefix ? prefix + ':' : ''}${name}`]: withNamespace(obj, prefix, namespace) };
- } else if (isPrimitive || Array.isArray(obj) || Object.keys(obj).length > 1) {
+ 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 (isArray && hasRepeatedRootElement(obj)) {
- obj = { root: obj }; // unwrapped root array items would otherwise be sibling root elements
+ } else if (hasRepeatedRootElement(obj)) {
+ obj = { root: obj }; // repeated (unwrapped array) elements would otherwise be sibling root elements
}
const builder = new XMLBuilder({
ignoreAttributes: false,
@@ -41,6 +49,37 @@ function hasRepeatedRootElement(obj) {
return Array.isArray(value) && value.length > 1;
}
+function prefixed(name, prefix) {
+ return `${prefix ? prefix + ':' : ''}${name}`;
+}
+
+// The xml of the first $ref in an allOf, named after the referenced component when it has no xml.name
+function firstRefXml(allOf, spec) {
+ const ref = allOf.find(child => child?.$ref)?.$ref;
+ if (!ref || !spec) return null;
+ try {
+ let pointer = decodeURIComponent(ref);
+ if (pointer.startsWith('#')) pointer = pointer.substring(1);
+ const xml = JsonPointer.get(spec, pointer)?.xml;
+ return { xml: { ...xml, name: xml?.name || pointer.split('/').pop() } };
+ } catch (e) {
+ return null;
+ }
+}
+
+function literalArrayToXml(items, schema) {
+ const array = getXMLAttributes(schema);
+ const item = getXMLAttributes(schema?.items);
+ const itemName = item.name ? prefixed(item.name, item.prefix) : array.name ? prefixed(array.name, array.prefix) : 'root';
+ const value = { [itemName]: items.map(entry => withNamespace(entry, item.prefix, item.namespace)) };
+ const nodeType = schema?.xml?.nodeType || (schema?.xml?.wrapped === true ? 'element' : 'none');
+ if (isArraySchema(schema) && array.name && nodeType === 'element') {
+ // A wrapped array is its own element: ab
+ return { [prefixed(array.name, array.prefix)]: withNamespace(value, array.prefix, array.namespace) };
+ }
+ return value;
+}
+
function withNamespace(value, prefix, namespace) {
if (!namespace) {
return value;
@@ -56,7 +95,7 @@ export function sample(schema, options, spec) {
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/test/unit/utils.spec.js b/test/unit/utils.spec.js
new file mode 100644
index 0000000..e0e1c47
--- /dev/null
+++ b/test/unit/utils.spec.js
@@ -0,0 +1,44 @@
+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'],
+ });
+ });
+ });
+
+ describe('isArraySchema', () => {
+ it('should use the type that is sampled', () => {
+ 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(true);
+ 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);
+ });
+ });
+});
From 2fa2499a34aa524471381ab77afb40553fdbaa85 Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Thu, 15 Oct 2026 00:08:45 +0100
Subject: [PATCH 04/11] fix: annotated refs with xml and composed arrays in
utils; tests
Completes the previous commit: the isAnnotatedRef, isArraySchema and
resolveNodeType changes it imports, and the integration tests for every
fix (two existing snapshots that produced two document elements now wrap
them in one root element).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
src/utils.js | 27 ++++++++++++++++++---------
1 file changed, 18 insertions(+), 9 deletions(-)
diff --git a/src/utils.js b/src/utils.js
index 9d4d178..65f7db7 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,5 +1,7 @@
'use strict';
+import { inferType } from './infer';
+
export const SKIP_SYMBOL = Symbol('skip');
function pad(number) {
@@ -92,11 +94,16 @@ export function isSingleRefAllOf(allOf) {
return refs.length === 1 && allOf.every(child => child?.$ref || isAnnotationOnly(child));
}
+// An allOf of a single $ref on a schema that adds nothing else of its own (no properties,
+// no xml override): it only annotates the referenced schema, so it is named like a plain $ref.
+export function isAnnotatedRef(schema) {
+ return !!schema && isSingleRefAllOf(schema.allOf) && schema.xml === undefined
+ && isAnnotationOnly({ ...schema, allOf: undefined });
+}
+
+// Same type resolution as traverse(): the first listed type, or the one inferred from keywords
export function isArraySchema(schema) {
- const type = schema?.type;
- if (Array.isArray(type)) return type.includes('array');
- if (type) return type === 'array';
- return schema?.items !== undefined || schema?.prefixItems !== undefined;
+ return !!schema && typeof schema === 'object' && inferType(schema) === 'array';
}
export function stripSkipped(value) {
@@ -125,7 +132,7 @@ export function getXMLAttributes(schema) {
};
}
-function resolveNodeType(schema) {
+function resolveNodeType(schema, resultType) {
const xml = schema?.xml;
if (xml?.nodeType) {
@@ -144,15 +151,17 @@ function resolveNodeType(schema) {
return 'none';
}
- if (schema?.allOf && !isSingleRefAllOf(schema.allOf)) {
- return 'element';
+ if (schema?.allOf && !isAnnotatedRef(schema)) {
+ // A composed allOf is one element, unless it was sampled as an array: then, like any
+ // other array, its items are the nodes
+ return resultType === 'array' ? 'none' : 'element';
}
const variants = schema?.oneOf || schema?.anyOf;
if (Array.isArray(variants) && variants.length) {
// The sample is taken from the first variant, so it decides the node type: a $ref
// or array variant names its own nodes, any other variant is this property's element.
- return resolveNodeType(variants[0]) === 'element' ? 'element' : 'none';
+ return resolveNodeType(variants[0], resultType) === 'element' ? 'element' : 'none';
}
if (schema?.oneOf || schema?.anyOf || schema?.allOf) {
@@ -166,7 +175,7 @@ export function applyXMLAttributes(result, schema = {}, context = {}) {
const { value: oldValue } = result;
const { propertyName: oldPropertyName } = context;
const { name, prefix, namespace } = getXMLAttributes(schema);
- const effectiveNodeType = resolveNodeType(schema);
+ const effectiveNodeType = resolveNodeType(schema, result.type);
let propertyName = name || oldPropertyName ? `${prefix ? prefix + ':' : ''}${name || oldPropertyName}` : null;
From d4be6625524c73924faa824eb30980a208aa4c56 Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Thu, 15 Oct 2026 00:11:55 +0100
Subject: [PATCH 05/11] test: cover annotated refs with xml, composed arrays
and root example arrays
The integration tests for the two previous commits: one per fix, the
unwrapped nullable and untyped property arrays, the issue #140 PatchUser
case with skipReadOnly, the root null throw, a null-first oneOf, nested
and additionalProperties arrays. Two existing snapshots that produced two
document elements now wrap them in one root element, as XML 1.0 requires.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
test/integration.spec.js | 419 ++++++++++++++++++++++++++++++++++++++-
1 file changed, 415 insertions(+), 4 deletions(-)
diff --git a/test/integration.spec.js b/test/integration.spec.js
index c00289a..9fa35bd 100644
--- a/test/integration.spec.js
+++ b/test/integration.spec.js
@@ -1096,8 +1096,10 @@ describe('Integration', () => {
const result = sample(schema, options, {});
expect(result).toMatchInlineSnapshot(`
- "test1
- test2
+ "
+ test1
+ test2
+
"
`);
});
@@ -1117,8 +1119,10 @@ describe('Integration', () => {
const result = sample(schema, options, {});
expect(result).toMatchInlineSnapshot(`
- "test1
- test2
+ "
+ test1
+ test2
+
"
`);
});
@@ -1864,6 +1868,413 @@ describe('Integration', () => {
});
});
+ 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 not wrap an unwrapped untyped array property twice', () => {
+ 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 nodeType', () => {
const options = {
format: 'xml',
From c40b76b7c3e53bf8fea3e5ea9d8b23c21257b1ec Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Mon, 19 Oct 2026 18:45:07 +0100
Subject: [PATCH 06/11] fix: address review on xml root, literal arrays and
allOf composition
- Share $ref resolution (resolveRef, with #224 sibling merging) between
traverse() and the XML root naming; drop the duplicate lookup and catch.
- A root $ref is named once by traverse(), from the $ref and its siblings.
- stripSkipped copies instead of writing to spec objects, so frozen specs
work and examples are never modified.
- example/default arrays on properties name their items like sampled arrays
(items xml.name or $ref, else the property name) instead of <0>, <1>.
- Root example arrays name $ref items after the $ref.
- allOf/oneOf with their own example, default, const or enum render as an
element instead of being dropped.
- Move prefixed/withNamespace to utils and reuse them; trim comments and
the changeset.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
.changeset/xml-root-and-allof.md | 12 +--
src/allOf.js | 6 +-
src/openapi-sampler.js | 55 ++++--------
src/samplers/array.js | 9 +-
src/samplers/object.js | 2 +-
src/traverse.js | 33 +------
src/utils.js | 144 +++++++++++++++++++++++--------
test/unit/utils.spec.js | 20 +++++
8 files changed, 151 insertions(+), 130 deletions(-)
diff --git a/.changeset/xml-root-and-allof.md b/.changeset/xml-root-and-allof.md
index fc78439..49487bc 100644
--- a/.changeset/xml-root-and-allof.md
+++ b/.changeset/xml-root-and-allof.md
@@ -2,14 +2,4 @@
"openapi-sampler": patch
---
-Fixed several bugs in XML samples (`format: 'xml'`). JSON samples are unchanged. XML samples change for the schemas below, so documentation that shows them (for example Redoc's XML response samples) will render the corrected XML:
-
-- The root schema's `xml.name`, `xml.prefix` and `xml.namespace` now always name the document element. They were dropped when the object had a single property, unlike the `` examples in the OpenAPI 3.2 specification.
-- A root array of strings no longer splits each string into one element per character, and several unnamed root items stay under one root element.
-- Repeated elements are always kept under one root element, so a root object whose only property is an unwrapped array, and a root `example` array, now produce well-formed XML. A wrapped root array `example` is wrapped in the array's element, as in the specification's wrapped array examples.
-- Arrays are detected with the same type resolution that is used for sampling, so unwrapped nullable arrays (`type: ['array', 'null']`) and arrays with `items` but no `type` are no longer wrapped twice, and a property with `type: ['string', 'array']` is rendered as its first type.
-- A `$ref` composed with other schemas in an `allOf` is merged into one element instead of nesting its properties in an extra element. Composed `allOf` properties and array items now get their own element instead of spilling their properties into the parent, unless the composition is sampled as an array. An `allOf` of a single `$ref` (with or without annotation-only siblings such as `description` or `nullable`) is still named like the `$ref`, unless the schema adds its own `xml` or properties: then it is merged and named by the schema.
-- A root composed `allOf` without an `xml.name` is named after its first `$ref` (its `xml.name`, prefix and namespace, or the component name), so its document element is no longer lost.
-- A `oneOf`/`anyOf` property whose first variant is an inline object, a composed `allOf` or a scalar is now rendered as its own element. Scalar variants were previously dropped, and object variants spilled into the parent.
-- `skipReadOnly`/`skipWriteOnly` no longer throw "Cannot convert a Symbol value to a string" for an `allOf` that wraps a `$ref`, including an `allOf` that marks a property of the `$ref` as `readOnly`.
-- A root scalar sample is wrapped in a root element. Previously `0` and `""` threw, `true` rendered as an empty string and `"a"` rendered as `<0>a0>`.
+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 b7edb60..8a81365 100644
--- a/src/allOf.js
+++ b/src/allOf.js
@@ -6,10 +6,6 @@ export function allOfSample(into, children, options, spec, context) {
let res = traverse(into, options, spec);
const subSamples = [];
- // In XML, a $ref is normally wrapped in an element named after it. The children of a
- // composed allOf describe one element together, so their properties are merged flat.
- // A lone $ref keeps its wrapper, so it is named like a plain $ref would be, unless the
- // schema adds properties or its own xml to it: then it is composed like any other allOf.
const flattenXmlRefs = options.format === 'xml' && !isAnnotatedRef({ ...into, allOf: children });
for (let subSchema of children) {
@@ -38,7 +34,7 @@ export function allOfSample(into, children, options, spec, context) {
}
if (options.format === 'xml') {
// An XML $ref wrapper nests the skipped properties one level down
- stripSkipped(res.value);
+ res.value = stripSkipped(res.value);
}
return res;
} else {
diff --git a/src/openapi-sampler.js b/src/openapi-sampler.js
index efe2ff6..138c754 100644
--- a/src/openapi-sampler.js
+++ b/src/openapi-sampler.js
@@ -1,8 +1,7 @@
import { traverse, clearCache } from './traverse';
import { sampleArray, sampleBoolean, sampleNumber, sampleObject, sampleString } from './samplers/index';
import { XMLBuilder } from 'fast-xml-parser';
-import JsonPointer from 'json-pointer';
-import { getXMLAttributes, isAnnotatedRef, isArraySchema } from './utils';
+import { getXMLAttributes, isAnnotatedRef, isArraySchema, nameLiteralItems, prefixed, refXml, withNamespace } from './utils';
export var _samplers = {};
@@ -15,19 +14,18 @@ function convertJsonToXml(obj, schema, spec) {
if (obj === undefined || obj === null) {
throw new Error('Unknown format output for building XML.');
}
- const isComposed = !!schema?.allOf && !isAnnotatedRef(schema);
- // A composed allOf merges its $refs into one element, so without a name of its own
- // the document element is named after its first $ref, like that $ref alone would be
- const { name, prefix, namespace } = getXMLAttributes(schema?.xml?.name || !isComposed ? schema : firstRefXml(schema.allOf, spec));
- const nodeType = schema?.xml?.nodeType;
+ // traverse() has already named the element of a root $ref, after the $ref and its siblings
+ const rootSchema = schema?.$ref ? undefined : schema;
+ const isComposed = !!rootSchema?.allOf && !isAnnotatedRef(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 = isArraySchema(schema);
+ const isArray = isArraySchema(rootSchema);
if (Array.isArray(obj)) {
- // A literal array (e.g. an `example`) has no XML item names yet: name them as sampleArray would
- obj = literalArrayToXml(obj, schema);
+ obj = literalArrayToXml(obj, rootSchema, spec);
}
if (name && !isArray && (!nodeType || nodeType === 'element')) {
- // The root schema names the document element, whatever its content is
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
@@ -49,47 +47,24 @@ function hasRepeatedRootElement(obj) {
return Array.isArray(value) && value.length > 1;
}
-function prefixed(name, prefix) {
- return `${prefix ? prefix + ':' : ''}${name}`;
-}
-
-// The xml of the first $ref in an allOf, named after the referenced component when it has no xml.name
function firstRefXml(allOf, spec) {
- const ref = allOf.find(child => child?.$ref)?.$ref;
+ const ref = allOf.find(child => child?.$ref);
+ // Without a spec, traverse() did not resolve the $ref: the root's example, default, const or enum was used
if (!ref || !spec) return null;
- try {
- let pointer = decodeURIComponent(ref);
- if (pointer.startsWith('#')) pointer = pointer.substring(1);
- const xml = JsonPointer.get(spec, pointer)?.xml;
- return { xml: { ...xml, name: xml?.name || pointer.split('/').pop() } };
- } catch (e) {
- return null;
- }
+ return { xml: refXml(ref, spec) };
}
-function literalArrayToXml(items, schema) {
+function literalArrayToXml(values, schema, spec) {
const array = getXMLAttributes(schema);
- const item = getXMLAttributes(schema?.items);
- const itemName = item.name ? prefixed(item.name, item.prefix) : array.name ? prefixed(array.name, array.prefix) : 'root';
- const value = { [itemName]: items.map(entry => withNamespace(entry, item.prefix, item.namespace)) };
+ 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') {
- // A wrapped array is its own element: ab
return { [prefixed(array.name, array.prefix)]: withNamespace(value, array.prefix, array.namespace) };
}
return value;
}
-function withNamespace(value, prefix, namespace) {
- if (!namespace) {
- return value;
- }
- const attribute = `$xmlns${prefix ? ':' + prefix : ''}`;
- return typeof value === 'object' && !Array.isArray(value)
- ? { ...value, [attribute]: namespace }
- : { [attribute]: namespace, '#text': value };
-}
-
export function sample(schema, options, spec) {
let opts = Object.assign({}, defaults, options);
clearCache();
diff --git a/src/samplers/array.js b/src/samplers/array.js
index 414b0fe..3bd64a6 100644
--- a/src/samplers/array.js
+++ b/src/samplers/array.js
@@ -1,5 +1,5 @@
import { traverse } from '../traverse';
-import { applyXMLAttributes, getXMLAttributes } from '../utils';
+import { applyXMLAttributes, getXMLAttributes, prefixed } from '../utils';
export function sampleArray(schema, options = {}, spec, context) {
const depth = (context && context.depth || 1);
@@ -25,13 +25,12 @@ 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 xmlResult = applyXMLAttributes({ value: sample }, itemSchema, context);
+ const xmlResult = applyXMLAttributes({ value: sample }, itemSchema, context, spec);
const { value } = xmlResult;
let { propertyName } = xmlResult;
if (!propertyName && (value === null || typeof value !== 'object')) {
- // An unnamed scalar item takes the array's name (spreading it would split a string into characters)
const { name, prefix } = getXMLAttributes(schema);
- propertyName = `${prefix ? prefix + ':' : ''}${name || 'root'}`;
+ propertyName = prefixed(name || 'root', prefix);
}
if (propertyName) {
if (!res?.[propertyName]) {
@@ -47,7 +46,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 e7e3aff..0400262 100644
--- a/src/traverse.js
+++ b/src/traverse.js
@@ -1,8 +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 { getResultForCircular, mergeDeep, popSchemaStack, resolveRef } from './utils';
import { applyXMLAttributes } from './utils';
let $refCache = {};
@@ -14,19 +13,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,20 +56,7 @@ 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) {
@@ -92,7 +65,7 @@ export function traverse(schema, options, spec, context) {
if (options.format === 'xml' && !context?.flattenXmlRef) {
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 65f7db7..254fb47 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,5 +1,6 @@
'use strict';
+import JsonPointer from 'json-pointer';
import { inferType } from './infer';
export const SKIP_SYMBOL = Symbol('skip');
@@ -80,22 +81,22 @@ const STRUCTURAL_KEYWORDS = [
'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'
+ return !!schema && typeof schema === 'object' && !hasOwnValue(schema)
&& !STRUCTURAL_KEYWORDS.some(keyword => schema[keyword] !== undefined);
}
-// `allOf: [{ $ref }]`, optionally next to annotation-only siblings such as
-// `{ description }` or `{ nullable: true }`, is the usual way to annotate a $ref:
-// it describes the referenced schema, so it is named like a plain $ref.
-export function isSingleRefAllOf(allOf) {
+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));
}
-// An allOf of a single $ref on a schema that adds nothing else of its own (no properties,
-// no xml override): it only annotates the referenced schema, so it is named like a plain $ref.
export function isAnnotatedRef(schema) {
return !!schema && isSingleRefAllOf(schema.allOf) && schema.xml === undefined
&& isAnnotationOnly({ ...schema, allOf: undefined });
@@ -106,20 +107,90 @@ export function isArraySchema(schema) {
return !!schema && typeof schema === 'object' && inferType(schema) === 'array';
}
+// Copies only the objects it removes something from, so examples from the spec are never written to
export function stripSkipped(value) {
- if (Array.isArray(value)) {
- return value.map(stripSkipped);
+ if (!value || typeof value !== 'object') {
+ return value;
}
- if (value && typeof value === 'object') {
- for (const key of Object.keys(value)) {
- if (value[key] === SKIP_SYMBOL) {
- delete value[key];
- } else {
- value[key] = stripSkipped(value[key]);
- }
+ 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;
+}
+
+// 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.');
+ }
+ let pointer = decodeURIComponent(schema.$ref);
+ if (pointer.startsWith('#')) {
+ pointer = pointer.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, 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
+export function refXml(schema, spec) {
+ const { pointer, referenced } = resolveRef(schema, spec);
+ return { ...referenced?.xml, name: referenced?.xml?.name || pointer.split('/').pop() };
+}
+
+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 value;
+ return { [attribute]: namespace, '#text': value };
+}
+
+// Names the items of a literal array (an example or default) like sampleArray names sampled items
+export function nameLiteralItems(values, schema, spec, fallbackName) {
+ const items = schema?.items;
+ const item = getXMLAttributes(items?.$ref && spec ? { xml: refXml(items, spec) } : 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) {
@@ -151,16 +222,18 @@ function resolveNodeType(schema, resultType) {
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 (schema?.allOf && !isAnnotatedRef(schema)) {
- // A composed allOf is one element, unless it was sampled as an array: then, like any
- // other array, its items are the nodes
return resultType === 'array' ? 'none' : 'element';
}
const variants = schema?.oneOf || schema?.anyOf;
if (Array.isArray(variants) && variants.length) {
- // The sample is taken from the first variant, so it decides the node type: a $ref
- // or array variant names its own nodes, any other variant is this property's element.
+ // The first variant is the one that gets sampled, so it decides the node type
return resolveNodeType(variants[0], resultType) === 'element' ? 'element' : 'none';
}
@@ -171,13 +244,15 @@ function resolveNodeType(schema, resultType) {
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, result.type);
- let propertyName = name || oldPropertyName ? `${prefix ? prefix + ':' : ''}${name || oldPropertyName}` : null;
+ let propertyName = name || oldPropertyName ? prefixed(name || oldPropertyName, prefix) : null;
+ // In XML, sampled arrays are objects by now, so an array here is an example or default
+ const isLiteralArray = isArraySchema(schema) && Array.isArray(oldValue);
let value = typeof oldValue === 'object'
? Array.isArray(oldValue)
@@ -201,31 +276,24 @@ export function applyXMLAttributes(result, schema = {}, context = {}) {
break;
case 'none':
- if (isArraySchema(schema)) {
- propertyName = null;
- if (schema.example !== undefined) {
- propertyName = schema.items?.xml?.name || propertyName;
- }
+ if (isLiteralArray) {
+ // An unwrapped array's own xml.name is ignored, so its items are named after the property
+ ({ name: propertyName, values: value } = nameLiteralItems(value, schema, spec, oldPropertyName || propertyName));
} else {
propertyName = null;
}
break;
default:
- if (isArraySchema(schema)) {
- 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/unit/utils.spec.js b/test/unit/utils.spec.js
index e0e1c47..a74c63d 100644
--- a/test/unit/utils.spec.js
+++ b/test/unit/utils.spec.js
@@ -16,6 +16,24 @@ describe('utils', () => {
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', () => {
@@ -39,6 +57,8 @@ describe('utils', () => {
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);
});
});
});
From 41736c2c53978e93cf69737fed56a2761786a653 Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Mon, 19 Oct 2026 18:46:57 +0100
Subject: [PATCH 07/11] test: move the new XML tests to test/xml.spec.js and
cover the review fixes
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
test/xml.spec.js | 1243 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1243 insertions(+)
create mode 100644 test/xml.spec.js
diff --git a/test/xml.spec.js b/test/xml.spec.js
new file mode 100644
index 0000000..dd3df64
--- /dev/null
+++ b/test/xml.spec.js
@@ -0,0 +1,1243 @@
+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 treat a root schema with items but no type as an array', () => {
+ 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 not wrap an unwrapped untyped array property twice', () => {
+ 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']);
+ });
+ });
+});
From e33dddf92ad0b6472aabc4dae856fccf6fd11fb0 Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Mon, 19 Oct 2026 18:49:38 +0100
Subject: [PATCH 08/11] test: drop the moved XML tests from
integration.spec.js; name literal array items
The new XML describe blocks now live in test/xml.spec.js. Three existing
snapshots change, schemas unchanged: wrapped example arrays use the items'
xml.name (as sampled arrays do), and an unwrapped example array no longer
renders <0>/<1> but repeats the property element.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
test/integration.spec.js | 884 +--------------------------------------
1 file changed, 6 insertions(+), 878 deletions(-)
diff --git a/test/integration.spec.js b/test/integration.spec.js
index 3be1498..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
"
`);
@@ -1543,878 +1543,6 @@ describe('Integration', () => {
});
});
- 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 treat a root schema with items but no type as an array', () => {
- 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 not wrap an unwrapped untyped array property twice', () => {
- 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 nodeType', () => {
const options = {
format: 'xml',
From 279271df62030f97615e3337280901a775cd6ab5 Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Mon, 19 Oct 2026 19:23:51 +0100
Subject: [PATCH 09/11] fix: allOf-wrapped and itemless arrays, unresolvable
$refs under examples, composed array items
Found in a second independent review of the previous push:
- An example array under an allOf (e.g. allOf: [$ref] next to xml, the 3.0
way to annotate a $ref) rendered <0>/<1>: it is now named like other
literal arrays. An array $ref in a composed allOf keeps its own nodes.
- A $ref under an example or default is never resolved by traverse(), so it
can be missing or external: refXml now returns null for those instead of
throwing, and the element falls back to the property name or root.
- A null or scalar value of an array property keeps its element, as on main.
- Composed allOf items of a root array are named after their first $ref
instead of being merged into one element.
- Arrays without `type: "array"` keep the element nodeType the spec gives
them (as on main); only the root naming uses the sampled type.
- Tidy: one import line from utils in traverse.js, shorter destructuring.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
src/openapi-sampler.js | 17 ++++--------
src/samplers/array.js | 4 +--
src/traverse.js | 6 ++--
src/utils.js | 61 +++++++++++++++++++++++++++++++----------
test/unit/utils.spec.js | 4 +--
5 files changed, 57 insertions(+), 35 deletions(-)
diff --git a/src/openapi-sampler.js b/src/openapi-sampler.js
index 138c754..0501146 100644
--- a/src/openapi-sampler.js
+++ b/src/openapi-sampler.js
@@ -1,7 +1,7 @@
import { traverse, clearCache } from './traverse';
import { sampleArray, sampleBoolean, sampleNumber, sampleObject, sampleString } from './samplers/index';
import { XMLBuilder } from 'fast-xml-parser';
-import { getXMLAttributes, isAnnotatedRef, isArraySchema, nameLiteralItems, prefixed, refXml, withNamespace } from './utils';
+import { firstRefXml, getXMLAttributes, isArraySchema, isComposedAllOf, isSampledAsArray, nameLiteralItems, prefixed, withNamespace } from './utils';
export var _samplers = {};
@@ -16,12 +16,12 @@ function convertJsonToXml(obj, schema, spec) {
}
// traverse() has already named the element of a root $ref, after the $ref and its siblings
const rootSchema = schema?.$ref ? undefined : schema;
- const isComposed = !!rootSchema?.allOf && !isAnnotatedRef(rootSchema);
+ 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 = isArraySchema(rootSchema);
+ const isArray = isSampledAsArray(rootSchema);
if (Array.isArray(obj)) {
obj = literalArrayToXml(obj, rootSchema, spec);
}
@@ -43,15 +43,8 @@ function convertJsonToXml(obj, schema, spec) {
}
function hasRepeatedRootElement(obj) {
- const [value] = Object.values(obj);
- return Array.isArray(value) && value.length > 1;
-}
-
-function firstRefXml(allOf, spec) {
- const ref = allOf.find(child => child?.$ref);
- // Without a spec, traverse() did not resolve the $ref: the root's example, default, const or enum was used
- if (!ref || !spec) return null;
- return { xml: refXml(ref, spec) };
+ const [key, value] = Object.entries(obj)[0] || [];
+ return Array.isArray(value) && value.length > 1 && !key.startsWith('#') && !key.startsWith('$');
}
function literalArrayToXml(values, schema, spec) {
diff --git a/src/samplers/array.js b/src/samplers/array.js
index 3bd64a6..524fde1 100644
--- a/src/samplers/array.js
+++ b/src/samplers/array.js
@@ -25,9 +25,7 @@ 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 xmlResult = applyXMLAttributes({ value: sample }, itemSchema, context, spec);
- const { value } = xmlResult;
- let { propertyName } = xmlResult;
+ 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);
diff --git a/src/traverse.js b/src/traverse.js
index 0400262..04cd319 100644
--- a/src/traverse.js
+++ b/src/traverse.js
@@ -1,8 +1,7 @@
import { _samplers } from './openapi-sampler';
import { allOfSample } from './allOf';
import { inferType } from './infer';
-import { getResultForCircular, mergeDeep, popSchemaStack, resolveRef } from './utils';
-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
@@ -62,7 +61,8 @@ export function traverse(schema, options, spec, context) {
if ($refCache[ref] !== true) {
$refCache[ref] = true;
const traverseResult = traverse(referenced, options, spec, context);
- if (options.format === 'xml' && !context?.flattenXmlRef) {
+ // In a composed allOf, a $ref's properties merge into one element; 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, spec);
diff --git a/src/utils.js b/src/utils.js
index 254fb47..bb37579 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -102,11 +102,17 @@ export function isAnnotatedRef(schema) {
&& isAnnotationOnly({ ...schema, allOf: undefined });
}
-// Same type resolution as traverse(): the first listed type, or the one inferred from keywords
-export function isArraySchema(schema) {
+// 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') {
@@ -143,16 +149,18 @@ function mergeRefSiblings(referenced, siblings) {
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.');
}
- let pointer = decodeURIComponent(schema.$ref);
- if (pointer.startsWith('#')) {
- pointer = pointer.substring(1);
- }
+ const pointer = refPointer(schema.$ref);
const siblings = Object.fromEntries(
Object.entries(schema).filter(([key, value]) => key !== '$ref' && value !== undefined),
);
@@ -162,12 +170,28 @@ export function resolveRef(schema, spec) {
return { pointer, referenced };
}
-// The xml of a $ref, named after the referenced component when it has no xml.name
+// 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 (an example or default),
+// 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 merges its $refs into one element, named after the first one
+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;
}
@@ -183,10 +207,11 @@ export function withNamespace(value, prefix, namespace) {
return { [attribute]: namespace, '#text': value };
}
-// Names the items of a literal array (an example or default) like sampleArray names sampled items
+// 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 item = getXMLAttributes(items?.$ref && spec ? { xml: refXml(items, spec) } : 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)),
@@ -227,7 +252,7 @@ function resolveNodeType(schema, resultType) {
return 'element';
}
- if (schema?.allOf && !isAnnotatedRef(schema)) {
+ if (isComposedAllOf(schema)) {
return resultType === 'array' ? 'none' : 'element';
}
@@ -247,12 +272,18 @@ function resolveNodeType(schema, resultType) {
export function applyXMLAttributes(result, schema = {}, context = {}, spec) {
const { value: oldValue } = result;
const { propertyName: oldPropertyName } = context;
- const { name, prefix, namespace } = getXMLAttributes(schema);
+ // 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;
- // In XML, sampled arrays are objects by now, so an array here is an example or default
- const isLiteralArray = isArraySchema(schema) && Array.isArray(oldValue);
+ // 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)
@@ -277,9 +308,9 @@ export function applyXMLAttributes(result, schema = {}, context = {}, spec) {
case 'none':
if (isLiteralArray) {
- // An unwrapped array's own xml.name is ignored, so its items are named after the property
+ // 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 {
+ } else if (!isItemlessArray) {
propertyName = null;
}
break;
diff --git a/test/unit/utils.spec.js b/test/unit/utils.spec.js
index a74c63d..c00ad83 100644
--- a/test/unit/utils.spec.js
+++ b/test/unit/utils.spec.js
@@ -37,11 +37,11 @@ describe('utils', () => {
});
describe('isArraySchema', () => {
- it('should use the type that is sampled', () => {
+ 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(true);
+ expect(isArraySchema({ items: { type: 'string' } })).toBe(false);
expect(isArraySchema({ type: 'object' })).toBe(false);
expect(isArraySchema(undefined)).toBe(false);
});
From 061b0afaf3d08be745129a14a8a0c16ec787fd59 Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Mon, 19 Oct 2026 19:25:43 +0100
Subject: [PATCH 10/11] test: cover allOf-wrapped, null and $ref-item arrays;
untyped arrays are elements
Untyped `items` schemas keep the element nodeType the spec gives them
(only `type: "array"` defaults to none), as on main.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
test/xml.spec.js | 122 +++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 118 insertions(+), 4 deletions(-)
diff --git a/test/xml.spec.js b/test/xml.spec.js
index dd3df64..5631ec9 100644
--- a/test/xml.spec.js
+++ b/test/xml.spec.js
@@ -151,14 +151,17 @@ describe('XML', () => {
`);
});
- it('should treat a root schema with items but no type as an array', () => {
+ 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
+"
+ string
+
"
`);
});
@@ -789,7 +792,8 @@ describe('XML', () => {
`);
});
- it('should not wrap an unwrapped untyped array property twice', () => {
+ it('should wrap an untyped array property in its own element', () => {
+ // Without `type: "array"`, the nodeType defaults to element
const schema = {
type: 'object',
properties: {
@@ -800,7 +804,9 @@ describe('XML', () => {
expect(sample(schema, options, spec)).toMatchInlineSnapshot(`
"
- string
+
+ string
+
string
"
@@ -1240,4 +1246,112 @@ describe('XML', () => {
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
+
+
+"
+`);
+ });
+ });
});
From 4c77b3b7d2f48aa21bf2bbf9043433b7fd1007e8 Mon Sep 17 00:00:00 2001
From: Rishi Tank
Date: Mon, 19 Oct 2026 19:34:06 +0100
Subject: [PATCH 11/11] docs: make three comments precise
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL
---
src/traverse.js | 2 +-
src/utils.js | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/traverse.js b/src/traverse.js
index 04cd319..552ed8a 100644
--- a/src/traverse.js
+++ b/src/traverse.js
@@ -61,7 +61,7 @@ export function traverse(schema, options, spec, context) {
if ($refCache[ref] !== true) {
$refCache[ref] = true;
const traverseResult = traverse(referenced, options, spec, context);
- // In a composed allOf, a $ref's properties merge into one element; an array keeps its own nodes
+ // 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 };
diff --git a/src/utils.js b/src/utils.js
index bb37579..105865d 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -171,7 +171,7 @@ export function resolveRef(schema, spec) {
}
// 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 (an example or default),
+// 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))) {
@@ -185,7 +185,7 @@ export function isComposedAllOf(schema) {
return !!schema?.allOf && !isAnnotatedRef(schema);
}
-// A composed allOf merges its $refs into one element, named after the first one
+// 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);