Skip to content

Commit

Permalink
Merge pull request #24 from mcarvin8/fast-xml-parser
Browse files Browse the repository at this point in the history
fix: switch to fast-xml-parser
mcarvin8 authored Nov 15, 2024
2 parents bf17907 + a7fd1e9 commit 2e39253
Showing 16 changed files with 317 additions and 175 deletions.
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -29,7 +29,7 @@ Salesforce packages follow this structure:
sf plugins install sf-package-combiner@x.y.z
```

## Commands
## Command

<!-- commands -->

@@ -61,3 +61,44 @@ EXAMPLES
```

<!-- commandsstop -->

## Parsing Strings with `package.xml` contents

Currently, I'm working on a feature to allow strings containing package.xml contents to be accepted through the terminal using a new command flag.

Until that is implemented, you could use this simple shell script which could read a string stored in a variable which contains package.xml contents and create a temporary package.xml from that. That temporary package.xml then could be read by this plugin and overwritten as the combined package.

In my 1 use case, the `$COMMIT_MSG` variable is GitLab's predefined variable named `$CI_COMMIT_MESSAGE` which contains the commit message.

```bash
#!/bin/bash
set -e

DEPLOY_PACKAGE="package.xml"

# Define a function to build package.xml from commit message
build_package_from_commit() {
local commit_msg="$1"
local output_file="$2"
PACKAGE_FOUND="False"

# Use sed to match and extract the XML package content
package_xml_content=$(echo "$commit_msg" | sed -n '/<Package xmlns=".*">/,/<\/Package>/p')

if [[ -n "$package_xml_content" ]]; then
echo "Found package.xml contents in the commit message."
echo "$package_xml_content" > "$output_file"
PACKAGE_FOUND="True"
else
echo "WARNING: Package.xml contents NOT found in the commit message."
fi
export PACKAGE_FOUND
}

build_package_from_commit "$COMMIT_MSG" "$DEPLOY_PACKAGE"

# combines the sfdx-git-delta package.xml with the package found in the commit message
if [[ "$PACKAGE_FOUND" == "True" ]]; then
sf sfpc combine -f "package/package.xml" -f "$DEPLOY_PACKAGE" -c "$DEPLOY_PACKAGE"
fi
```
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -6,15 +6,13 @@
"@oclif/core": "^4",
"@salesforce/core": "^8",
"@salesforce/sf-plugins-core": "^12",
"xml2js": "^0.6.2",
"xmlbuilder2": "^3.1.1"
"fast-xml-parser": "^4.5.0"
},
"devDependencies": {
"@oclif/plugin-command-snapshot": "^5.1.9",
"@salesforce/cli-plugins-testkit": "^5.3.10",
"@salesforce/dev-scripts": "^10",
"@types/mocha": "^10.0.9",
"@types/xml2js": "^0.4.14",
"eslint-plugin-sf-plugin": "^1.18.6",
"husky": "^9.1.6",
"mocha": "^10.8.2",
4 changes: 2 additions & 2 deletions src/commands/sfpc/combine.ts
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import { writeFile } from 'node:fs/promises';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';

import { SalesforcePackageXml, SfpcCombineResult } from '../../helpers/types.js';
import { PackageXmlObject, SfpcCombineResult } from '../../helpers/types.js';
import { buildPackage } from '../../helpers/buildPackage.js';
import { readPackageFiles } from '../../helpers/readPackageFiles.js';

@@ -35,7 +35,7 @@ export default class SfpcCombine extends SfCommand<SfpcCombineResult> {

const files = flags['package-file'] ?? null;
const combinedPackage = flags['combined-package'];
let packageContents: SalesforcePackageXml[] = [];
let packageContents: PackageXmlObject[] = [];
let apiVersions: string[] = [];
let warnings: string[] = [];

64 changes: 35 additions & 29 deletions src/helpers/buildPackage.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { create } from 'xmlbuilder2';
import { XMLBuilder } from 'fast-xml-parser';

import { SalesforcePackageXml } from './types.js';
import { PackageXmlObject } from './types.js';

const xmlConf = { indent: ' ', newline: '\n', prettyPrint: true };
const xmlConf = {
format: true,
indentBy: ' ',
suppressEmptyNode: false,
ignoreAttributes: false,
attributeNamePrefix: '@_',
};

export function buildPackage(packageContents: SalesforcePackageXml[], apiVersions: string[]): string {
export function buildPackage(packageContents: PackageXmlObject[], apiVersions: string[]): string {
// Determine the maximum API version from the apiVersions array
const maxVersion = apiVersions.reduce((max, version) => (version > max ? version : max), '0.0');

// Combine the parsed package.xml contents
const mergedPackage: SalesforcePackageXml = { Package: { types: [], version: maxVersion } };
const mergedPackage: PackageXmlObject = { Package: { types: [], version: maxVersion } };

// Process each parsed package XML
for (const pkg of packageContents) {
@@ -33,31 +39,31 @@ export function buildPackage(packageContents: SalesforcePackageXml[], apiVersion
}
mergedPackage.Package.types.sort((a, b) => a.name.localeCompare(b.name));

const root = create({ version: '1.0', encoding: 'UTF-8' }).ele('Package', {
xmlns: 'http://soap.sforce.com/2006/04/metadata',
});

// Create <types> for each type, properly formatting the XML
if (packageContents.length > 0) {
mergedPackage.Package.types.forEach((type) => {
const typeElement = root.ele('types');
type.members
.sort((a, b) => a.localeCompare(b))
.forEach((member) => {
typeElement.ele('members').txt(member).up();
});
typeElement.ele('name').txt(type.name).up();
});
} else {
root.txt('\n');
root.txt('\n');
}
// Construct the XML data as a JSON-like object
const packageXmlObject: PackageXmlObject = {
Package: {
'@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
types: mergedPackage.Package.types.map((type) => ({
members: type.members.sort((a, b) => a.localeCompare(b)),
name: type.name,
})),
version: maxVersion !== '0.0' ? maxVersion : undefined,
},
};

// Build the XML string
const builder = new XMLBuilder(xmlConf);
let xmlContent = builder.build(packageXmlObject) as string;

// Set the maximum version element
if (maxVersion !== '0.0') {
root.ele('version').txt(maxVersion);
// Ensure formatting for an empty package
if (mergedPackage.Package.types.length === 0) {
xmlContent = xmlContent.replace(
'<Package xmlns="http://soap.sforce.com/2006/04/metadata"></Package>',
'<Package xmlns="http://soap.sforce.com/2006/04/metadata">\n\n</Package>'
);
}

// Output the merged package.xml
return root.end(xmlConf);
// Prepend the XML declaration manually
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>\n';
return xmlHeader + xmlContent;
}
122 changes: 78 additions & 44 deletions src/helpers/parsePackage.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,88 @@
/* eslint-disable no-await-in-loop */
import { Parser } from 'xml2js';
import { XMLParser, XMLValidator } from 'fast-xml-parser';

import { SalesforcePackageXml } from './types.js';
import { PackageXmlObject } from './types.js';

// Safe parsing function for package XML using xml2js
export async function parsePackageXml(xmlContent: string): Promise<SalesforcePackageXml | null> {
const XML_PARSER_OPTION = {
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: false,
parseNodeValue: false,
parseAttributeValue: false,
trimValues: true,
};

export function parsePackageXml(xmlContent: string): PackageXmlObject | null {
try {
const parser = new Parser();
// Validate the XML content
const validationResult = XMLValidator.validate(xmlContent);
if (validationResult !== true) {
return null;
}

const parser = new XMLParser(XML_PARSER_OPTION);

// Parse the XML string to an object
const parsed = (await parser.parseStringPromise(xmlContent)) as unknown as SalesforcePackageXml;
const parsed = parser.parse(xmlContent) as unknown as PackageXmlObject;

// Ensure the root <Package> exists
if (!parsed.Package) {
// Ensure the root <Package> exists and is of correct type
if (!parsed || typeof parsed !== 'object' || !parsed.Package) {
return null;
}

// Validate that the root <Package> contains only allowed keys (<types>, <version>)
const allowedKeys = new Set(['types', 'version']);
const packageKeys = Object.keys(parsed.Package).filter((key) => key !== '$'); // Ignore the '$' key
const packageData = parsed.Package as Partial<PackageXmlObject['Package']>;

// Validate and normalize the <types> field
if (!packageData.types) {
return null;
}
const allowedKeys = new Set(['types', 'version']);
const packageKeys = Object.keys(parsed.Package).filter((key) => key !== '@_xmlns');
const hasUnexpectedKeys = packageKeys.some((key) => !allowedKeys.has(key));
if (hasUnexpectedKeys) {
return null;
}
const normalizedTypes = Array.isArray(packageData.types) ? packageData.types : [packageData.types];

parsed.Package.types = parsed.Package.types.map((type) => {
// Validate that there is exactly one <name> element
if (!Array.isArray(type.name) || type.name.length !== 1 || typeof type.name[0] !== 'string') {
throw new Error('Invalid package.xml: Each <types> block must have exactly one <name> element.');
packageData.types = normalizedTypes.map((type): { name: string; members: string[] } => {
if (!type || typeof type !== 'object' || typeof type.name !== 'string') {
throw new Error('Invalid <types> block: Missing or invalid <name> element.');
}
// Validate that only "name" and "members" keys are present
const allowedTypesKeys = new Set(['name', 'members']);
const typeKeys = Object.keys(type);
const hasUnexpectedTypesKeys = typeKeys.some((key) => !allowedTypesKeys.has(key));

if (hasUnexpectedTypesKeys) {
throw new Error('Invalid package.xml: Each <types> block must contain only <name> and <members> tags.');
}
const name = type.name[0];
const members = Array.isArray(type.members) ? type.members.flat() : type.members;
// Ensure members is always a string array
let members: string[];

if (Array.isArray(type.members)) {
members = type.members.filter((member): member is string => typeof member === 'string');
} else if (typeof type.members === 'string') {
members = [type.members];
} else {
members = [];
}

return {
...type,
name,
name: type.name,
members,
};
});

// Enforce a maximum of one <version> tag in the package.xml
if (parsed.Package && Array.isArray(parsed.Package.version)) {
if (parsed.Package.version.length > 1) {
return null; // Invalid structure, more than one <version> tag
}
// Convert to a single string if only one <version> tag is present
if (Array.isArray(parsed.Package.version) && typeof parsed.Package.version[0] === 'string') {
parsed.Package.version = parsed.Package.version[0];
// Ensure a maximum of one <version> tag
if (Array.isArray(packageData.version)) {
if (packageData.version.length > 1) {
return null;
}
packageData.version = packageData.version[0] as string;
}

// Apply a type guard to safely assert the parsed content matches SalesforcePackageXml
if (isSalesforcePackageXml(parsed)) {
return parsed;
// Validate the final structure
if (isPackageXmlObject({ Package: packageData })) {
return { Package: packageData as PackageXmlObject['Package'] };
} else {
return null;
}
@@ -69,23 +91,35 @@ export async function parsePackageXml(xmlContent: string): Promise<SalesforcePac
}
}

function isSalesforcePackageXml(obj: unknown): obj is SalesforcePackageXml {
return (
typeof obj === 'object' &&
obj !== null &&
'Package' in obj &&
typeof (obj as { Package: unknown }).Package === 'object' &&
Array.isArray((obj as { Package: { types: unknown } }).Package.types) &&
(obj as { Package: { types: Array<{ name: unknown; members: unknown }> } }).Package.types.every(
function isPackageXmlObject(obj: unknown): obj is PackageXmlObject {
if (
typeof obj !== 'object' ||
obj === null ||
!('Package' in obj) ||
typeof (obj as { Package: unknown }).Package !== 'object'
) {
return false;
}

const packageData = (obj as { Package: unknown }).Package as Partial<PackageXmlObject['Package']>;

if (
!Array.isArray(packageData.types) ||
!packageData.types.every(
(type) =>
typeof type === 'object' &&
type !== null &&
typeof type.name === 'string' &&
Array.isArray(type.members) &&
type.members.every((member) => typeof member === 'string')
) &&
// Make version optional here: allow it to be a string or undefined
(typeof (obj as { Package: { version?: unknown } }).Package.version === 'string' ||
typeof (obj as { Package: { version?: unknown } }).Package.version === 'undefined')
);
)
) {
return false;
}

if (packageData.version && typeof packageData.version !== 'string') {
return false;
}

return true;
}
8 changes: 4 additions & 4 deletions src/helpers/readPackageFiles.ts
Original file line number Diff line number Diff line change
@@ -2,19 +2,19 @@
import { readFile } from 'node:fs/promises';

import { parsePackageXml } from './parsePackage.js';
import { SalesforcePackageXml } from './types.js';
import { PackageXmlObject } from './types.js';

export async function readPackageFiles(
files: string[] | null
): Promise<{ packageContents: SalesforcePackageXml[]; apiVersions: string[]; warnings: string[] }> {
): Promise<{ packageContents: PackageXmlObject[]; apiVersions: string[]; warnings: string[] }> {
const warnings: string[] = [];
const packageContents: SalesforcePackageXml[] = [];
const packageContents: PackageXmlObject[] = [];
const apiVersions: string[] = [];
if (files) {
for (const filePath of files) {
try {
const fileContent = await readFile(filePath, 'utf-8');
const parsed = await parsePackageXml(fileContent);
const parsed = parsePackageXml(fileContent);
if (parsed) {
packageContents.push(parsed);
// Add the package version to the apiVersions array
13 changes: 8 additions & 5 deletions src/helpers/types.ts
Original file line number Diff line number Diff line change
@@ -2,12 +2,15 @@ export type SfpcCombineResult = {
path: string;
};

export type SalesforcePackageXml = {
export type PackageTypeObject = {
name: string;
members: string[];
};

export type PackageXmlObject = {
Package: {
types: Array<{
name: string;
members: string[];
}>;
'@_xmlns'?: string;
types: PackageTypeObject[];
version?: string;
};
};
Loading

0 comments on commit 2e39253

Please sign in to comment.