From c89275f11fce06c2fb8dfa39218eea8284094240 Mon Sep 17 00:00:00 2001 From: Alex Varchuk Date: Wed, 20 Nov 2024 15:16:04 +0200 Subject: [PATCH] feat: add support xml samples (#173) --- package-lock.json | 27 +++ package.json | 2 + src/openapi-sampler.js | 23 +- src/samplers/array.js | 27 ++- src/samplers/object.js | 14 +- src/traverse.js | 10 +- src/types.d.ts | 3 +- src/utils.js | 57 +++++ test/integration.spec.js | 474 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 632 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5037c38..cbb51e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.7", + "fast-xml-parser": "^4.5.0", "json-pointer": "0.6.2" }, "devDependencies": { @@ -6205,6 +6206,27 @@ "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==", "dev": true }, + "node_modules/fast-xml-parser": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", + "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -13438,6 +13460,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "node_modules/subarg": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", diff --git a/package.json b/package.json index 7274adc..dd90332 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "types": "./src/types.d.ts", "scripts": { "lint": "eslint .", + "lint:fix": "eslint . --fix", "test": "npm run lint && jest", "test:watch": "jest --watch", "coverage": "jest --coverage", @@ -67,6 +68,7 @@ }, "dependencies": { "@types/json-schema": "^7.0.7", + "fast-xml-parser": "^4.5.0", "json-pointer": "0.6.2" }, "overrides": { diff --git a/src/openapi-sampler.js b/src/openapi-sampler.js index ff4f828..e47cdb7 100644 --- a/src/openapi-sampler.js +++ b/src/openapi-sampler.js @@ -1,5 +1,6 @@ import { traverse, clearCache } from './traverse'; import { sampleArray, sampleBoolean, sampleNumber, sampleObject, sampleString } from './samplers/index'; +import { XMLBuilder } from 'fast-xml-parser'; export var _samplers = {}; @@ -8,10 +9,30 @@ const defaults = { maxSampleDepth: 15, }; +function convertJsonToXml(obj, schema) { + if (!obj) { + 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 builder = new XMLBuilder({ + ignoreAttributes : false, + format: true, + attributeNamePrefix: '$', + textNodeName: '#text', + }); + return builder.build(obj); +} + export function sample(schema, options, spec) { let opts = Object.assign({}, defaults, options); clearCache(); - return traverse(schema, opts, spec).value; + let result = traverse(schema, opts, spec).value; + if (opts?.format === 'xml') { + return convertJsonToXml(result, schema); + } + return result; }; export function _registerSampler(type, sampler) { diff --git a/src/samplers/array.js b/src/samplers/array.js index dbd4c43..75ce89c 100644 --- a/src/samplers/array.js +++ b/src/samplers/array.js @@ -1,4 +1,6 @@ import { traverse } from '../traverse'; +import { applyXMLAttributes } from '../utils'; + export function sampleArray(schema, options = {}, spec, context) { const depth = (context && context.depth || 1); @@ -22,7 +24,30 @@ export function sampleArray(schema, options = {}, spec, context) { for (let i = 0; i < arrayLength; i++) { let itemSchema = itemSchemaGetter(i); let { value: sample } = traverse(itemSchema, options, spec, {depth: depth + 1}); - res.push(sample); + if (options?.format === 'xml') { + const { value, propertyName } = applyXMLAttributes({value: sample}, itemSchema, context); + if (propertyName) { + if (!res?.[propertyName]) { + res = { ...res, [propertyName]: [] }; + } + res[propertyName].push(value); + } else { + res = {...res, ...value}; + } + } else { + res.push(sample); + } + } + + if (options?.format === 'xml' && depth === 1) { + const { value, propertyName } = applyXMLAttributes({value: null}, schema, context); + if (propertyName) { + if (value) { + res = Array.isArray(res) ? { [propertyName]: {...value, ...res.map(item => ({['#text']: {...item}}))} } : { [propertyName]: {...res, ...value }}; + } else { + res = { [propertyName]: res }; + } + } } return res; } diff --git a/src/samplers/object.js b/src/samplers/object.js index 93d232a..09204ff 100644 --- a/src/samplers/object.js +++ b/src/samplers/object.js @@ -1,4 +1,6 @@ import { traverse } from '../traverse'; +import { applyXMLAttributes } from '../utils'; + export function sampleObject(schema, options = {}, spec, context) { let res = {}; const depth = (context && context.depth || 1); @@ -27,7 +29,17 @@ export function sampleObject(schema, options = {}, spec, context) { if (options.skipWriteOnly && sample.writeOnly) { return; } - res[propertyName] = sample.value; + + if (options?.format === 'xml') { + const { propertyName: newPropertyName, value } = applyXMLAttributes(sample, schema.properties[propertyName], { propertyName }); + if (newPropertyName) { + res[newPropertyName] = value; + } else { + res = { ...res, ...value }; + } + } else { + res[propertyName] = sample.value; + } }); } diff --git a/src/traverse.js b/src/traverse.js index 96f248a..31d2b94 100644 --- a/src/traverse.js +++ b/src/traverse.js @@ -3,6 +3,7 @@ import { allOfSample } from './allOf'; import { inferType } from './infer'; import { getResultForCircular, mergeDeep, popSchemaStack } from './utils'; import JsonPointer from 'json-pointer'; +import { applyXMLAttributes } 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 @@ -69,7 +70,14 @@ export function traverse(schema, options, spec, context) { if ($refCache[ref] !== true) { $refCache[ref] = true; - result = traverse(referenced, options, spec, context); + const traverseResult = traverse(referenced, options, spec, context); + if (options.format === 'xml') { + const {propertyName, value} = applyXMLAttributes(traverseResult, referenced, context); + result = {...traverseResult, value: {[propertyName || 'root']: value}}; + } else { + result = traverseResult; + } + $refCache[ref] = false; } else { const referencedType = inferType(referenced); diff --git a/src/types.d.ts b/src/types.d.ts index 6da5a4b..3c167a4 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -5,7 +5,8 @@ export interface Options { readonly skipReadOnly?: boolean; readonly skipWriteOnly?: boolean; readonly quiet?: boolean; - readonly enablePatterns?: boolean + readonly enablePatterns?: boolean; + readonly format?: 'json' | 'xml'; } export function sample(schema: JSONSchema7, options?: Options, document?: object): unknown; diff --git a/src/utils.js b/src/utils.js index 04f90aa..988f621 100644 --- a/src/utils.js +++ b/src/utils.js @@ -71,6 +71,63 @@ export function popSchemaStack(seenSchemasStack, context) { if (context) seenSchemasStack.pop(); } +export function getXMLAttributes(schema) { + return { + name: schema?.xml?.name || '', + prefix: schema?.xml?.prefix || '', + namespace: schema?.xml?.namespace || null, + attribute: schema?.xml?.attribute ?? false, + wrapped: schema?.xml?.wrapped ?? false, + }; +} + +export function applyXMLAttributes(result, schema = {}, context = {}) { + const { value: oldValue } = result; + const { propertyName: oldPropertyName } = context; + const { name, prefix, namespace, attribute, wrapped } = + getXMLAttributes(schema); + let propertyName = name || oldPropertyName ? `${prefix ? prefix + ':' : ''}${name || oldPropertyName}` : null; + + let value = typeof oldValue === 'object' + ? Array.isArray(oldValue) + ? [...oldValue] + : { ...oldValue } + : oldValue; + + if (attribute && propertyName) { + propertyName = `$${propertyName}`; + } + + if (namespace) { + if (typeof value === 'object') { + value[`$xmlns${prefix ? ':' + prefix : ''}`] = namespace; + } else { + value = { [`$xmlns${prefix ? ':' + prefix : ''}`]: namespace, ['#text']: value }; + } + } + + if (schema.type === 'array') { + if (wrapped && Array.isArray(value)) { + value = { [propertyName]: [...value] }; + } + if (!wrapped) { + propertyName = null; + } + + if (schema.example !== undefined && !wrapped) { + propertyName = schema.items.xml?.name || propertyName; + } + } + if (schema.oneOf || schema.anyOf || schema.allOf || schema.$ref) { + propertyName = null; + } + + return { + propertyName, + value, + }; +} + function hashCode(str) { var hash = 0; if (str.length == 0) return hash; diff --git a/test/integration.spec.js b/test/integration.spec.js index 9439323..65c86c4 100644 --- a/test/integration.spec.js +++ b/test/integration.spec.js @@ -876,4 +876,478 @@ describe('Integration', () => { expect(result).toEqual(expected); }); }); + + describe('xml', () => { + const options = { + format: 'xml', + }; + it('should build XML for an array with wrapped elements without examples', () => { + const schema = { + type: 'object', + properties: { + books: { + type: 'array', + items: { type: 'string', xml: { name: 'book' } }, + xml: { wrapped: true, name: 'booksLibrary' }, + }, + }, + }; + const result = sample(schema, options, {}); + + expect(result.trim()).toMatchInlineSnapshot(` + " + string + " + `); + }); + + it('should build XML for an array with unwrapped elements without examples', () => { + const schema = { + type: 'object', + properties: { + books: { + type: 'array', + items: { type: 'string', xml: { name: 'book' } }, + xml: { wrapped: false }, + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result.trim()).toMatchInlineSnapshot('"string"'); + }); + + it('should build XML for an array with wrapped parent name and undefined item name', () => { + const schema = { + type: 'object', + properties: { + books: { + type: 'array', + items: { type: 'string' }, + xml: { wrapped: true, name: 'booksLibrary' }, + example: ['test1', 'test2'], + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result.trim()).toMatchInlineSnapshot(` + " + test1 + test2 + " + `); + }); + + it('should build XML for an array with wrapped parent and defined item name', () => { + const schema = { + type: 'object', + properties: { + books: { + type: 'array', + items: { type: 'string', xml: { name: 'book' } }, + xml: { wrapped: true, name: 'booksLibrary' }, + example: ['test1', 'test2'], + }, + }, + }; + + const result = sample(schema, options, {}); + expect(result.trim()).toMatchInlineSnapshot(` + " + test1 + test2 + " + `); + }); + + it('should build XML for an array with undefined parent and item names', () => { + const schema = { + type: 'object', + properties: { + books: { + type: 'array', + items: { type: 'string' }, + xml: { wrapped: true }, + example: ['test1', 'test2'], + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result.trim()).toMatchInlineSnapshot(` + " + test1 + test2 + " + `); + }); + + it('should build XML for an array with undefined parent name and defined item name', () => { + const schema = { + type: 'object', + properties: { + books: { + type: 'array', + items: { type: 'string', xml: { name: 'book' } }, + xml: { wrapped: true }, + example: ['test1', 'test2'], + }, + }, + }; + + const result = sample(schema, options, {}); + expect(result.trim()).toMatchInlineSnapshot(` + " + test1 + test2 + " + `); + }); + + it('should build XML for an array with defined parent and undefined item names', () => { + const schema = { + type: 'object', + properties: { + books: { + type: 'array', + items: { type: 'string' }, + xml: { name: 'booksLibrary' }, // it is ignored because wrapped is false by default + example: ['test1', 'test2'], + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result).toMatchInlineSnapshot(` + " + <0>test1 + <1>test2 + + " + `); + }); + + it('should build XML for an array with defined parent and item names', () => { + const schema = { + type: 'object', + properties: { + books: { + type: 'array', + items: { type: 'string', xml: { name: 'book' } }, + xml: { name: 'booksLibrary' }, + example: ['test1', 'test2'], + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result).toMatchInlineSnapshot(` + "test1 + test2 + " + `); + }); + + it('should build XML for an array with undefined parent and defined item names without wrapper', () => { + const schema = { + type: 'object', + properties: { + books: { + type: 'array', + items: { type: 'string', xml: { name: 'book' } }, + example: ['test1', 'test2'], + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result).toMatchInlineSnapshot(` + "test1 + test2 + " + `); + }); + + it('should build XML for an array with correct item prefix logic', () => { + const schema = { + type: 'object', + properties: { + books: { + type: 'array', + items: { type: 'string', xml: { prefix: 'child' } }, + xml: { + wrapped: true, + name: 'booksLibrary', + prefix: 'parent', + }, + }, + }, + }; + const result = sample(schema, options, {}); + + expect(result).toMatchInlineSnapshot(` +" + string + +" +`); + }); + + it('should build XML for an nested object', () => { + const schema = { + type: 'object', + properties: { + location: { + type: 'object', + properties: { + city: { + type: 'string', + xml: { + name: 'eventCity', + prefix: 'loc', + }, + example: 'Atlantis', + }, + index: { + type: 'integer', + xml: { + name: 'id', + prefix: 'index', + }, + example: '123', + }, + places: { + type: 'array', + items: { + type: 'string', + xml: { + name: 'eventPlaces', + }, + }, + example: ['place1', 'place2'], + }, + }, + xml: { + name: 'eventLocation', + prefix: 'loc', + }, + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result).toMatchInlineSnapshot(` + " + Atlantis + 123 + place1 + place2 + + " + `); + }); + + it('should build XML for an nested object without examples', () => { + const schema = { + type: 'object', + properties: { + location: { + type: 'object', + properties: { + city: { + type: 'string', + xml: { + name: 'eventCity', + prefix: 'loc', + }, + }, + index: { + type: 'integer', + xml: { + name: 'id', + prefix: 'index', + }, + }, + places: { + type: 'array', + items: { + type: 'string', + xml: { + name: 'eventPlaces', + }, + }, + }, + }, + xml: { + name: 'eventLocation', + prefix: 'loc', + }, + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result).toMatchInlineSnapshot(` + " + string + 0 + string + + " + `); + }); + + it('should build XML for an nested object with namespace', () => { + const schema = { + type: 'object', + properties: { + location: { + type: 'object', + properties: { + city: { + type: 'string', + xml: { + name: 'eventCity', + prefix: 'c', + namespace: 'http://example.com/city', + }, + example: 'Atlantis', + }, + }, + xml: { + name: 'eventLocation', + prefix: 'l', + namespace: 'http://example.com/location', + }, + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result).toMatchInlineSnapshot(` + " + Atlantis + + " + `); + }); + + it('should build XML for an nested object when all properties are attributes', () => { + const schema = { + type: 'object', + properties: { + location: { + type: 'object', + properties: { + coordinates: { + type: 'object', + properties: { + lat: { + type: 'number', + xml: { + name: 'latitude', + prefix: 'loc', + attribute: true, + }, + example: 12.345, + }, + long: { + type: 'number', + xml: { + attribute: true, + name: 'longitude', + prefix: 'loc', + }, + example: 67.89, + }, + }, + xml: { + name: 'geoCoordinates', + prefix: 'geo', + }, + }, + }, + xml: { + name: 'eventLocation', + prefix: 'loc', + }, + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result).toMatchInlineSnapshot(` + " + + + " + `); + }); + + it('should build XML for an nested object when one of the property is attribute', () => { + const schema = { + type: 'object', + properties: { + location: { + type: 'object', + properties: { + coordinates: { + type: 'object', + properties: { + lat: { + type: 'number', + xml: { + name: 'latitude', + prefix: 'loc', + attribute: true, + }, + example: 12.345, + }, + long: { + type: 'number', + xml: { + attribute: false, + name: 'longitude', + prefix: 'loc', + }, + example: 67.89, + }, + }, + xml: { + name: 'geoCoordinates', + prefix: 'geo', + }, + }, + }, + xml: { + name: 'eventLocation', + prefix: 'loc', + }, + }, + }, + }; + + const result = sample(schema, options, {}); + + expect(result).toMatchInlineSnapshot(` + " + + 67.89 + + + " + `); + }); + }); });