Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/xml-root-and-allof.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we trim this to 2-3 lines? Changesets are copied word for word into CHANGELOG.md, and the detailed list already lives in the PR description and the tests

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to two sentences, in the style of the 1.7.7/1.7.8 entries.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-sampler": patch
---

Fixed XML samples (`format: 'xml'`) that were not well-formed, threw, or dropped data. The root schema's `xml.name` now names the document element, `example` and `default` arrays name their items like sampled arrays, and an `allOf` or `oneOf` property renders as one element.
9 changes: 8 additions & 1 deletion src/allOf.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { traverse } from './traverse';
import { mergeDeep, SKIP_SYMBOL } from './utils';
import { isAnnotatedRef, mergeDeep, SKIP_SYMBOL, stripSkipped } from './utils';


export function allOfSample(into, children, options, spec, context) {
let res = traverse(into, options, spec);
const subSamples = [];

const flattenXmlRefs = options.format === 'xml' && !isAnnotatedRef({ ...into, allOf: children });

for (let subSchema of children) {
const childSchema = subSchema.$ref ? subSchema : { type: res.type, ...subSchema };
const { type, readOnly, writeOnly, value } = traverse(childSchema, options, spec, {
...context,
isAllOfChild: true,
...(flattenXmlRefs && { flattenXmlRef: true }),
});
if (res.type && type && type !== res.type) {
console.warn('allOf: schemas with different types can\'t be merged');
Expand All @@ -29,6 +32,10 @@ export function allOfSample(into, children, options, spec, context) {
delete res.value[key];
}
}
if (options.format === 'xml') {
// An XML $ref wrapper nests the skipped properties one level down
res.value = stripSkipped(res.value);
}
return res;
} else {
if (res.type === 'array') {
Expand Down
42 changes: 37 additions & 5 deletions src/openapi-sampler.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { traverse, clearCache } from './traverse';
import { sampleArray, sampleBoolean, sampleNumber, sampleObject, sampleString } from './samplers/index';
import { XMLBuilder } from 'fast-xml-parser';
import { firstRefXml, getXMLAttributes, isArraySchema, isComposedAllOf, isSampledAsArray, nameLiteralItems, prefixed, withNamespace } from './utils';

export var _samplers = {};

Expand All @@ -9,12 +10,27 @@ const defaults = {
maxSampleDepth: 15,
};

function convertJsonToXml(obj, schema) {
if (!obj) {
function convertJsonToXml(obj, schema, spec) {
if (obj === undefined || obj === null) {
throw new Error('Unknown format output for building XML.');
}
if (Array.isArray(obj) || Object.keys(obj).length > 1) {
obj = { [schema?.xml?.name || 'root']: obj }; // XML document must contain one root element
// traverse() has already named the element of a root $ref, after the $ref and its siblings
const rootSchema = schema?.$ref ? undefined : schema;
const isComposed = isComposedAllOf(rootSchema);
const xmlSource = isComposed && !rootSchema.xml?.name ? firstRefXml(rootSchema.allOf, spec) : rootSchema;
const { name, prefix, namespace } = getXMLAttributes(xmlSource);
const nodeType = rootSchema?.xml?.nodeType;
const isPrimitive = typeof obj !== 'object';
const isArray = isSampledAsArray(rootSchema);
if (Array.isArray(obj)) {
obj = literalArrayToXml(obj, rootSchema, spec);
}
if (name && !isArray && (!nodeType || nodeType === 'element')) {
obj = { [prefixed(name, prefix)]: withNamespace(obj, prefix, namespace) };
} else if (isPrimitive || isComposed || Object.keys(obj).length > 1) {
obj = { [name || 'root']: obj }; // XML document must contain one root element
} else if (hasRepeatedRootElement(obj)) {
obj = { root: obj }; // repeated (unwrapped array) elements would otherwise be sibling root elements
}
const builder = new XMLBuilder({
ignoreAttributes: false,
Expand All @@ -26,12 +42,28 @@ function convertJsonToXml(obj, schema) {
return builder.build(obj);
}

function hasRepeatedRootElement(obj) {
const [key, value] = Object.entries(obj)[0] || [];
return Array.isArray(value) && value.length > 1 && !key.startsWith('#') && !key.startsWith('$');
}

function literalArrayToXml(values, schema, spec) {
const array = getXMLAttributes(schema);
const items = nameLiteralItems(values, schema, spec, array.name ? prefixed(array.name, array.prefix) : 'root');
const value = { [items.name]: items.values };
const nodeType = schema?.xml?.nodeType || (schema?.xml?.wrapped === true ? 'element' : 'none');
if (isArraySchema(schema) && array.name && nodeType === 'element') {
return { [prefixed(array.name, array.prefix)]: withNamespace(value, array.prefix, array.namespace) };
}
return value;
}

export function sample(schema, options, spec) {
let opts = Object.assign({}, defaults, options);
clearCache();
let result = traverse(schema, opts, spec).value;
if (opts?.format === 'xml') {
return convertJsonToXml(result, schema);
return convertJsonToXml(result, schema, spec);
}
return result;
};
Expand Down
10 changes: 7 additions & 3 deletions src/samplers/array.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { traverse } from '../traverse';
import { applyXMLAttributes } from '../utils';
import { applyXMLAttributes, getXMLAttributes, prefixed } from '../utils';

export function sampleArray(schema, options = {}, spec, context) {
const depth = (context && context.depth || 1);
Expand All @@ -25,7 +25,11 @@ export function sampleArray(schema, options = {}, spec, context) {
let itemSchema = itemSchemaGetter(i);
let { value: sample } = traverse(itemSchema, options, spec, { depth: depth + 1 });
if (options?.format === 'xml') {
const { value, propertyName } = applyXMLAttributes({ value: sample }, itemSchema, context);
let { value, propertyName } = applyXMLAttributes({ value: sample }, itemSchema, context, spec);
if (!propertyName && (value === null || typeof value !== 'object')) {
const { name, prefix } = getXMLAttributes(schema);
propertyName = prefixed(name || 'root', prefix);
}
if (propertyName) {
if (!res?.[propertyName]) {
res = { ...res, [propertyName]: [] };
Expand All @@ -40,7 +44,7 @@ export function sampleArray(schema, options = {}, spec, context) {
}

if (options?.format === 'xml' && depth === 1) {
const { value, propertyName } = applyXMLAttributes({ value: null }, schema, context);
const { value, propertyName } = applyXMLAttributes({ value: null }, schema, context, spec);
if (propertyName) {
if (value) {
res = Array.isArray(res) ? { [propertyName]: { ...value, ...res.map(item => ({ ['#text']: { ...item } })) } } : { [propertyName]: { ...res, ...value } };
Expand Down
2 changes: 1 addition & 1 deletion src/samplers/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
37 changes: 5 additions & 32 deletions src/traverse.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { _samplers } from './openapi-sampler';
import { allOfSample } from './allOf';
import { inferType } from './infer';
import { getResultForCircular, mergeDeep, popSchemaStack } from './utils';
import JsonPointer from 'json-pointer';
import { applyXMLAttributes } from './utils';
import { applyXMLAttributes, getResultForCircular, mergeDeep, popSchemaStack, resolveRef } from './utils';

let $refCache = {};
// for circular JS references we use additional array and not object as we need to compare entire schemas and not strings
Expand All @@ -14,19 +12,6 @@ export function clearCache() {
seenSchemasStack = [];
}

function shouldIgnoreRefSiblings(spec) {
return String(spec.openapi ?? '').startsWith('3.0') || spec.swagger !== undefined;
}

function mergeRefSiblings(referenced, siblings) {
const merged = mergeDeep(referenced, siblings);
// mergeDeep merges arrays by index, which would drop required names of the referenced schema
if (Array.isArray(referenced.required) && Array.isArray(siblings.required)) {
merged.required = [...new Set([...referenced.required, ...siblings.required])];
}
return merged;
}

function inferExample(schema) {
let example;
if (schema.const !== undefined) {
Expand Down Expand Up @@ -70,29 +55,17 @@ export function traverse(schema, options, spec, context) {
}

if (schema.$ref) {
if (!spec) {
throw new Error('Your schema contains $ref. You must provide full specification in the third parameter.');
}
let ref = decodeURIComponent(schema.$ref);
if (ref.startsWith('#')) {
ref = ref.substring(1);
}

const siblings = Object.fromEntries(
Object.entries(schema).filter(([key, value]) => key !== '$ref' && value !== undefined),
);
const referenced = Object.keys(siblings).length && !shouldIgnoreRefSiblings(spec)
? mergeRefSiblings(JsonPointer.get(spec, ref), siblings)
: JsonPointer.get(spec, ref);
const { pointer: ref, referenced } = resolveRef(schema, spec);
let result;

if ($refCache[ref] !== true) {
$refCache[ref] = true;
const traverseResult = traverse(referenced, options, spec, context);
if (options.format === 'xml') {
// In a composed allOf, a $ref's properties merge into one element; a $ref typed as an array keeps its own nodes
if (options.format === 'xml' && !(context?.flattenXmlRef && traverseResult.type !== 'array')) {
const refName = ref.split('/').pop();
const xmlContext = { ...context, propertyName: context?.propertyName || refName };
const { propertyName, value } = applyXMLAttributes(traverseResult, referenced, xmlContext);
const { propertyName, value } = applyXMLAttributes(traverseResult, referenced, xmlContext, spec);
result = { ...traverseResult, value: { [propertyName || 'root']: value } };
} else {
result = traverseResult;
Expand Down
Loading
Loading