Skip to content

Commit

Permalink
feat: upgrade XML functions to use FXPv4
Browse files Browse the repository at this point in the history
  • Loading branch information
Codeneos committed Jul 20, 2023
1 parent aaa95c1 commit 63c0ba9
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 45 deletions.
2 changes: 1 addition & 1 deletion packages/salesforce/src/soapClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export class SoapClient {
handleCookies: false,
responseDecoders: {
xml: (buffer, encoding) => {
return XML.parse(buffer.toString(encoding), { ignoreNameSpace: true });
return XML.parse(buffer.toString(encoding), { ignoreNamespacePrefix: true });
}
}
}, LogManager.get('SoapTransport'));
Expand Down
3 changes: 1 addition & 2 deletions packages/util/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,8 @@
"dependencies": {
"@salesforce/core": "3.31.18",
"@xmldom/xmldom": "^0.8.8",
"fast-xml-parser": "^3.19.0",
"fast-xml-parser": "^4.2.6",
"fs-extra": "^9.0",
"he": "^1.2.0",
"open": "^8.2.1",
"optional-require": "^1.1.7",
"reflect-metadata": "^0.1.13"
Expand Down
45 changes: 44 additions & 1 deletion packages/util/src/__tests__/xml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ describe('xml', () => {
}
}, undefined, { headless: true })).toBe(xmlStr);
});
it('should encode XML special characters', () => {
const xmlStr = '<tag attr="&amp;&gt;">I&amp;D &apos;100&apos; &gt; &apos;200&apos;</tag>';
expect(XML.stringify({
tag: {
$: { attr: '&>' },
'#text': `I&D '100' > '200'`
}
}, undefined, { headless: true })).toBe(xmlStr);
});
});
describe('#parse', () => {
it('should replace tag-value with attribute nil:true by null', () => {
Expand All @@ -48,11 +57,45 @@ describe('xml', () => {
expect(XML.parse(`<test><tag>2.1</tag></test>`).test.tag).toBe(2.1);
});
it('should strip namespace prefixes when ignoreNameSpace is true', () => {
expect(XML.parse(`<test xmlns:ns='test'><ns:tag>test</ns:tag></test>`, { ignoreNameSpace: true }).test.tag).toBe('test');
expect(XML.parse(`<test xmlns:ns='test'><ns:tag>test</ns:tag></test>`, { ignoreNamespacePrefix: true }).test.tag).toBe('test');
});
it('should not strip namespace prefixes when ignoreNameSpace is not set', () => {
expect(XML.parse(`<test xmlns:ns='test'><ns:tag>test</ns:tag></test>`).test['ns:tag']).toBe('test');
});
it('should not parse strings with leading zero\'s or + as number', () => {
expect(XML.parse(`<test><tag>000001</tag></test>`).test.tag).toBe('000001');
expect(XML.parse(`<test><tag>+1</tag></test>`).test.tag).toBe('+1');
});
it('should run node values through parser', () => {
const valueProcessor = (val: string, path: string) => path === 'test.tag' ? val + '@' : val;
const parsed = XML.parse(`<test><tag>2&amp;</tag></test>`, { valueProcessor });
expect(parsed).toEqual({ test: { tag: '2&@' } });
});
it('should run attribute values through parser', () => {
const valueProcessor = (val: string, path: string) => path === 'test.tag@attr' ? val + '@' : val;
const parsed = XML.parse(`<test><tag attr="1"></tag></test>`, { valueProcessor });
expect(parsed).toEqual({ test: { tag: { $: { attr: '1@' } } }});
});
it('should parse everything as array when array mode true', () => {
const parsed = XML.parse(`<test><tag>test</tag></test>`, { arrayMode: true });
expect(parsed).toEqual({ test: [{ tag: [ 'test' ] }] });
});
it('should parse HTML encoded entities in values', () => {
const parsed = XML.parse(`<test><tag>I&amp;D &#39;100&#39; &gt; &#39;200&#39;</tag></test>`);
expect(parsed).toEqual({ test: { tag: `I&D '100' > '200'` } });
});
it('should parse HTML encoded entities in attributes', () => {
const parsed = XML.parse(`<test><tag value="I&amp;D &apos;100&#39; &gt; &#39;200&apos;" /></test>`);
expect(parsed).toEqual({ test: { tag: { $: { value: `I&D '100' > '200'` } } } });
});
it('should not trim values trimValues = false', () => {
const parsed = XML.parse(`<test><tag> \ntest\n </tag></test>`, { trimValues: false });
expect(parsed).toEqual({ test: { tag: ` \ntest\n ` } });
});
it('should trim values', () => {
const parsed = XML.parse(`<test><tag> \ntest\n </tag></test>`,);
expect(parsed).toEqual({ test: { tag: `test` } });
});
});
describe('#getRootTagName', () => {
it('should ignore XML declaration', () => {
Expand Down
105 changes: 72 additions & 33 deletions packages/util/src/xml.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,42 @@
import * as xmlParser from 'fast-xml-parser';
import { XMLParser, XMLBuilder, X2jOptions, XmlBuilderOptions } from 'fast-xml-parser';
import { DOMParser } from '@xmldom/xmldom';
import { decode, escape } from 'he';
import { visitObject } from './object';

export interface XMLParseOptions {
/**
* When true the parser will trim white spaces values of text nodes. When not set defaults to true.
* When false all whitespace characters from the text node are preserved.
*
* @remark `\r\n` is normalized to `\n`
* @default true
*/
trimValues?: boolean;
/**
* When true the parser will ignore attributes and only parse the node name and value.
* @default false
*/
ignoreAttributes?: boolean;
ignoreNameSpace?: boolean;
/**
* Always put child nodes in an array if true; otherwise an array is created only if there is more than one.
* When true the parser will ignore namespace prefixes in tags and attributes.
* The returned object will not contain any namespace prefixes.
* @default false
*/
arrayMode?: boolean | 'strict' | ((nodePath: string) => any);
ignoreNamespacePrefix?: boolean;
/**
* When true always return arrays for nodes even if there is only one child node.
* If false return the node as an object when there is only one child node.
* Or use a function to determine if the node should be an array or not.
*/
arrayMode?: boolean | ((nodePath: string) => any);
/**
* Process the value of a node before returning it to the node.
* Useful for converting values to a specific type.
* If you return undefined the node is ignored.
* @param value Value of the node
* @param nodePath full path of the node seperated by dots, i.e. `rootTag.innerTag`.
* For attributes the path is prefixed with `@`, i.e. `rootTag.innerTag.@attr`
*/
valueProcessor?: (val: string, nodePath: string) => any;
valueProcessor?: (value: string, nodePath: string) => any;
}

export interface XMLStringfyOptions {
Expand All @@ -37,39 +57,41 @@ export interface TextRange {

export namespace XML {

const options: Partial<xmlParser.X2jOptions> = {
const options: Partial<X2jOptions & XmlBuilderOptions> = {
attributeNamePrefix : '',
attrNodeName: '$',
attributesGroupName: '$',
textNodeName : '#text',
cdataPropName: '__cdata', // default is 'false'
ignoreAttributes : false,
ignoreNameSpace : false,
allowBooleanAttributes : true,
parseNodeValue : true,
parseAttributeValue : true,
trimValues: true,
cdataTagName: '__cdata', // default is 'false'
cdataPositionChar: '\\c',
parseTrueNumberOnly: false,
removeNSPrefix : false,
trimValues: true,
arrayMode: false, // "strict"
ignoreDeclaration: false,
ignorePiTags: true,
processEntities: true,
numberParseOptions: {
leadingZeros: false,
hex: false,
skipLike: /^\+.*/,
}
} as const;

/**
* Global parser options for XML to JSON; changing the defaults affects all parsing in all packages. Change with care.
*/
export const globalParserOptions: Partial<xmlParser.X2jOptions> = {
...options,
tagValueProcessor : val => decode(val),
attrValueProcessor: val => decode(val, { isAttributeValue: true })
export const globalParserOptions: Partial<X2jOptions> = {
...options
} as const;

/**
* Global stringify options for converting JSON to XML; changing the defaults affects JSON to XML formatting in all packages. Change with care.
*/
export const globalStringifyOptions: Partial<xmlParser.J2xOptions> = {
export const globalStringifyOptions: Partial<XmlBuilderOptions> = {
...options,
supressEmptyNode: false,
tagValueProcessor: val => escape(String(val)),
attrValueProcessor: val => escape(String(val))
supressEmptyNode: false
} as const;

/**
Expand All @@ -81,19 +103,36 @@ export namespace XML {
if (typeof xml !== 'string') {
xml = xml.toString();
}
const parserOptions = { ...globalParserOptions, ...options };

const parserOptions : Partial<X2jOptions> = {
...globalParserOptions,
ignoreAttributes: options.ignoreAttributes ?? globalParserOptions.ignoreAttributes,
trimValues: options.trimValues ?? globalParserOptions.trimValues,
removeNSPrefix: options.ignoreNamespacePrefix ?? globalParserOptions.removeNSPrefix
};

if (options.valueProcessor) {
parserOptions.tagValueProcessor = (val, nodePath) => {
return options.valueProcessor!(
decode(val, { isAttributeValue: true }),
nodePath
);
parserOptions.attributeValueProcessor = (attr, value, path) => {
return options.valueProcessor!(value, `${path}@${attr}`);
};
parserOptions.tagValueProcessor = (val, nodePath) => {
return options.valueProcessor!(decode(val), nodePath);
parserOptions.tagValueProcessor = (tag, value, path) => {
return options.valueProcessor!(value, path);
};
}
return visitObject(xmlParser.parse(xml, parserOptions, true), (prop, value, target) => {

if (options.arrayMode) {
parserOptions.isArray = (name, path, leaf, isAttribute) => {
if (isAttribute) {
return false;
}
if (typeof options.arrayMode === 'function') {
return options.arrayMode(path);
}
return options.arrayMode === true;
};
}

return visitObject(new XMLParser(parserOptions).parse(xml), (prop, value, target) => {
if (typeof value === 'object') {
// Parse nil as null as per XML spec
if (value['$']?.['nil'] === true) {
Expand All @@ -110,12 +149,12 @@ export namespace XML {
* @returns
*/
export function stringify(jsonObj: any, indent?: number | string, options: XMLStringfyOptions = {}) : string {
const indentOptions = {
const indentOptions: Partial<XmlBuilderOptions> = {
format: indent !== undefined,
supressEmptyNode: options.stripEmptyNodes,
indentBy: indent !== undefined ? typeof indent === 'string' ? indent : ' '.repeat(indent) : undefined,
};
const xmlString = new xmlParser.j2xParser({...globalStringifyOptions, ...indentOptions}).parse(jsonObj);
const xmlString = new XMLBuilder({ ...globalStringifyOptions, ...indentOptions }).build(jsonObj);
if (options?.headless !== true) {
return `<?xml version="1.0" encoding="UTF-8"?>\n${xmlString}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface AddElementOptions {
/**
* ID of the parent element to add the element to.
*/
parentElementId?: string;
parentElementId?: string;
/**
* ID of the embedded OmniScript element to link the element to.
*/
Expand Down
19 changes: 12 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 63c0ba9

Please sign in to comment.