diff --git a/packages/core/package.json b/packages/core/package.json index d9d4abfa3baa..329e8b6bee29 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -94,7 +94,6 @@ "@smithy/util-body-length-browser": "^4.1.0", "@smithy/util-middleware": "^4.1.1", "@smithy/util-utf8": "^4.1.0", - "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "devDependencies": { diff --git a/packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.ts b/packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.ts index 9aea2a91ee69..212ab2da183b 100644 --- a/packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.ts +++ b/packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.ts @@ -1,9 +1,9 @@ +import { parseXML } from "@aws-sdk/xml-builder"; import { FromStringShapeDeserializer } from "@smithy/core/protocols"; import { NormalizedSchema } from "@smithy/core/schema"; import { getValueFromTextNode } from "@smithy/smithy-client"; import type { Schema, SerdeFunctions, ShapeDeserializer } from "@smithy/types"; import { toUtf8 } from "@smithy/util-utf8"; -import { XMLParser } from "fast-xml-parser"; import { SerdeContextConfig } from "../ConfigurableSerdeContext"; import type { XmlSettings } from "./XmlCodec"; @@ -146,21 +146,9 @@ export class XmlShapeDeserializer extends SerdeContextConfig implements ShapeDes protected parseXml(xml: string): any { if (xml.length) { - const parser = new XMLParser({ - attributeNamePrefix: "", - htmlEntities: true, - ignoreAttributes: false, - ignoreDeclaration: true, - parseTagValue: false, - trimValues: false, - tagValueProcessor: (_: any, val: any) => (val.trim() === "" && val.includes("\n") ? "" : undefined), - }); - parser.addEntity("#xD", "\r"); - parser.addEntity("#10", "\n"); - let parsedObj; try { - parsedObj = parser.parse(xml, true); + parsedObj = parseXML(xml); } catch (e: any) { if (e && typeof e === "object") { Object.defineProperty(e, "$responseBodyText", { diff --git a/packages/core/src/submodules/protocols/xml/parseXmlBody.ts b/packages/core/src/submodules/protocols/xml/parseXmlBody.ts index 2d5bac5999a0..85ef329c0cd7 100644 --- a/packages/core/src/submodules/protocols/xml/parseXmlBody.ts +++ b/packages/core/src/submodules/protocols/xml/parseXmlBody.ts @@ -1,6 +1,6 @@ +import { parseXML } from "@aws-sdk/xml-builder"; import { getValueFromTextNode } from "@smithy/smithy-client"; import type { HttpResponse, SerdeContext } from "@smithy/types"; -import { XMLParser } from "fast-xml-parser"; import { collectBodyString } from "../common"; @@ -10,21 +10,9 @@ import { collectBodyString } from "../common"; export const parseXmlBody = (streamBody: any, context: SerdeContext): any => collectBodyString(streamBody, context).then((encoded) => { if (encoded.length) { - const parser = new XMLParser({ - attributeNamePrefix: "", - htmlEntities: true, - ignoreAttributes: false, - ignoreDeclaration: true, - parseTagValue: false, - trimValues: false, - tagValueProcessor: (_: any, val: any) => (val.trim() === "" && val.includes("\n") ? "" : undefined), - }); - parser.addEntity("#xD", "\r"); - parser.addEntity("#10", "\n"); - let parsedObj; try { - parsedObj = parser.parse(encoded, true); + parsedObj = parseXML(encoded); } catch (e: any) { if (e && typeof e === "object") { Object.defineProperty(e, "$responseBodyText", { diff --git a/packages/xml-builder/package.json b/packages/xml-builder/package.json index b7f4e13a1954..f5cccfd083d5 100644 --- a/packages/xml-builder/package.json +++ b/packages/xml-builder/package.json @@ -4,6 +4,7 @@ "description": "XML builder for the AWS SDK", "dependencies": { "@smithy/types": "^4.5.0", + "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "scripts": { @@ -38,6 +39,13 @@ "files": [ "dist-*/**" ], + "browser": { + "./dist-es/xml-parser": "./dist-es/xml-parser.browser" + }, + "react-native": { + "./dist-es/xml-parser": "./dist-es/xml-parser", + "./dist-cjs/xml-parser": "./dist-cjs/xml-parser" + }, "homepage": "https://github.com/aws/aws-sdk-js-v3/tree/main/packages/xml-builder", "repository": { "type": "git", diff --git a/packages/xml-builder/src/index.ts b/packages/xml-builder/src/index.ts index ed9933828403..dc7fd5f908f4 100644 --- a/packages/xml-builder/src/index.ts +++ b/packages/xml-builder/src/index.ts @@ -6,3 +6,8 @@ export * from "./XmlNode"; * @internal */ export * from "./XmlText"; + +/** + * @internal + */ +export { parseXML } from "./xml-parser"; diff --git a/packages/xml-builder/src/xml-parser.browser.ts b/packages/xml-builder/src/xml-parser.browser.ts new file mode 100644 index 000000000000..7104cb33ecb3 --- /dev/null +++ b/packages/xml-builder/src/xml-parser.browser.ts @@ -0,0 +1,56 @@ +const parser = new DOMParser(); + +export function parseXML(xmlString: string): any { + const xmlDocument = parser.parseFromString(xmlString, "application/xml"); + + // Recursive function to convert XML nodes to JS object + const xmlToObj = (node: Node): any => { + if (node.nodeType === Node.TEXT_NODE) { + if (node.textContent?.trim()) { + return node.textContent; + } + } + + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + if (element.attributes.length === 0 && element.childNodes.length === 0) { + return ""; + } + + const obj: any = {}; + + for (const attr of Array.from(element.attributes)) { + obj[`${attr.name}`] = attr.value; + } + + for (const child of Array.from(element.childNodes)) { + const childResult = xmlToObj(child); + + if (childResult != null) { + const childName = child.nodeName; + if (childName === "#text") { + return childResult; + } + + if (obj[childName]) { + if (Array.isArray(obj[childName])) { + obj[childName].push(childResult); + } else { + obj[childName] = [obj[childName], childResult]; + } + } else { + obj[childName] = childResult; + } + } + } + + return obj; + } + + return null; + }; + + return { + [xmlDocument.documentElement.nodeName]: xmlToObj(xmlDocument.documentElement), + }; +} diff --git a/packages/xml-builder/src/xml-parser.spec.ts b/packages/xml-builder/src/xml-parser.spec.ts new file mode 100644 index 000000000000..b5b95fcc4b31 --- /dev/null +++ b/packages/xml-builder/src/xml-parser.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, test as it } from "vitest"; + +import { parseXML } from "./xml-parser"; +import { parseXML as parseXMLBrowser } from "./xml-parser.browser"; + +describe("xml parsing", () => { + for (const { name, parse } of [ + { name: "fast-xml-parser", parse: parseXML }, + { name: "DOMParser", parse: parseXMLBrowser }, + ]) { + describe(name, () => { + it("should parse a valid xml string without xml header", () => { + const xml = ` + + + STS_AR_ACCESS_KEY_ID + STS_AR_SECRET_ACCESS_KEY + STS_AR_SESSION_TOKEN_us-west-2 + 3000-01-01T00:00:00.000Z + + + + 01234567-89ab-cdef-0123-456789abcdef + +`; + const object = parse(xml); + expect(object).toEqual({ + AssumeRoleResponse: { + AssumeRoleResult: { + Credentials: { + AccessKeyId: "STS_AR_ACCESS_KEY_ID", + Expiration: "3000-01-01T00:00:00.000Z", + SecretAccessKey: "STS_AR_SECRET_ACCESS_KEY", + SessionToken: "STS_AR_SESSION_TOKEN_us-west-2", + }, + }, + ResponseMetadata: { + RequestId: "01234567-89ab-cdef-0123-456789abcdef", + }, + xmlns: "https://sts.amazonaws.com/doc/2011-06-15/", + }, + }); + }); + + it("should parse ListBuckets response XML with xml header", () => { + const xml = ` + + + + string + string + timestamp + string + + + + string + string + + string + string +`; + const object = parse(xml); + expect(object).toEqual({ + ListAllMyBucketsResult: { + Buckets: { + Bucket: { + BucketArn: "string", + BucketRegion: "string", + CreationDate: "timestamp", + Name: "string", + }, + }, + ContinuationToken: "string", + Owner: { + DisplayName: "string", + ID: "string", + }, + Prefix: "string", + }, + }); + }); + + it("should parse xml (custom)", () => { + const xml = ` + + + abcdefg + dup1 + dup2 + dup3 + s p a c e d + + + abcdefg + dup1 + dup2 + dup3 + s p a c e d + +`; + const object = parse(xml); + expect(object).toEqual({ + struct: { + empty: "", + text: "abcdefg", + duplicate: ["dup1", "dup2", "dup3"], + spaced: " s p a c e d ", + nested: { + empty: "", + text: "abcdefg", + duplicate: ["dup1", "dup2", "dup3"], + spaced: " s p a c e d ", + }, + }, + }); + }); + }); + } +}); diff --git a/packages/xml-builder/src/xml-parser.ts b/packages/xml-builder/src/xml-parser.ts new file mode 100644 index 000000000000..5e2aba24a2af --- /dev/null +++ b/packages/xml-builder/src/xml-parser.ts @@ -0,0 +1,17 @@ +import { XMLParser } from "fast-xml-parser"; + +const parser = new XMLParser({ + attributeNamePrefix: "", + htmlEntities: true, + ignoreAttributes: false, + ignoreDeclaration: true, + parseTagValue: false, + trimValues: false, + tagValueProcessor: (_: any, val: any) => (val.trim() === "" && val.includes("\n") ? "" : undefined), +}); +parser.addEntity("#xD", "\r"); +parser.addEntity("#10", "\n"); + +export function parseXML(xmlString: string): any { + return parser.parse(xmlString, true); +} diff --git a/packages/xml-builder/vitest.config.mts b/packages/xml-builder/vitest.config.mts index 4e46707824a5..73fcc11c3178 100644 --- a/packages/xml-builder/vitest.config.mts +++ b/packages/xml-builder/vitest.config.mts @@ -4,6 +4,6 @@ export default defineConfig({ test: { exclude: ["**/*.{integ,e2e,browser}.spec.ts"], include: ["**/*.spec.ts"], - environment: "node", + environment: "happy-dom", }, }); diff --git a/yarn.lock b/yarn.lock index 26bf2867a4eb..44aa15b27508 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23534,7 +23534,6 @@ __metadata: "@tsconfig/recommended": "npm:1.0.1" concurrently: "npm:7.0.0" downlevel-dts: "npm:0.10.1" - fast-xml-parser: "npm:5.2.5" rimraf: "npm:3.0.2" tslib: "npm:^2.6.2" typescript: "npm:~5.8.3" @@ -25005,6 +25004,7 @@ __metadata: "@tsconfig/recommended": "npm:1.0.1" concurrently: "npm:7.0.0" downlevel-dts: "npm:0.10.1" + fast-xml-parser: "npm:5.2.5" rimraf: "npm:3.0.2" tslib: "npm:^2.6.2" typescript: "npm:~5.8.3"