Skip to content

Commit bf7f6dc

Browse files
committed
feat(xml-builder): use DOMParser for browser XML parsing
1 parent 79c41a2 commit bf7f6dc

File tree

10 files changed

+212
-31
lines changed

10 files changed

+212
-31
lines changed

packages/core/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@
9494
"@smithy/util-body-length-browser": "^4.1.0",
9595
"@smithy/util-middleware": "^4.1.1",
9696
"@smithy/util-utf8": "^4.1.0",
97-
"fast-xml-parser": "5.2.5",
9897
"tslib": "^2.6.2"
9998
},
10099
"devDependencies": {

packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
import { parseXML } from "@aws-sdk/xml-builder";
12
import { FromStringShapeDeserializer } from "@smithy/core/protocols";
23
import { NormalizedSchema } from "@smithy/core/schema";
34
import { getValueFromTextNode } from "@smithy/smithy-client";
45
import type { Schema, SerdeFunctions, ShapeDeserializer } from "@smithy/types";
56
import { toUtf8 } from "@smithy/util-utf8";
6-
import { XMLParser } from "fast-xml-parser";
77

88
import { SerdeContextConfig } from "../ConfigurableSerdeContext";
99
import type { XmlSettings } from "./XmlCodec";
@@ -146,21 +146,9 @@ export class XmlShapeDeserializer extends SerdeContextConfig implements ShapeDes
146146

147147
protected parseXml(xml: string): any {
148148
if (xml.length) {
149-
const parser = new XMLParser({
150-
attributeNamePrefix: "",
151-
htmlEntities: true,
152-
ignoreAttributes: false,
153-
ignoreDeclaration: true,
154-
parseTagValue: false,
155-
trimValues: false,
156-
tagValueProcessor: (_: any, val: any) => (val.trim() === "" && val.includes("\n") ? "" : undefined),
157-
});
158-
parser.addEntity("#xD", "\r");
159-
parser.addEntity("#10", "\n");
160-
161149
let parsedObj;
162150
try {
163-
parsedObj = parser.parse(xml, true);
151+
parsedObj = parseXML(xml);
164152
} catch (e: any) {
165153
if (e && typeof e === "object") {
166154
Object.defineProperty(e, "$responseBodyText", {

packages/core/src/submodules/protocols/xml/parseXmlBody.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import { parseXML } from "@aws-sdk/xml-builder";
12
import { getValueFromTextNode } from "@smithy/smithy-client";
23
import type { HttpResponse, SerdeContext } from "@smithy/types";
3-
import { XMLParser } from "fast-xml-parser";
44

55
import { collectBodyString } from "../common";
66

@@ -10,21 +10,9 @@ import { collectBodyString } from "../common";
1010
export const parseXmlBody = (streamBody: any, context: SerdeContext): any =>
1111
collectBodyString(streamBody, context).then((encoded) => {
1212
if (encoded.length) {
13-
const parser = new XMLParser({
14-
attributeNamePrefix: "",
15-
htmlEntities: true,
16-
ignoreAttributes: false,
17-
ignoreDeclaration: true,
18-
parseTagValue: false,
19-
trimValues: false,
20-
tagValueProcessor: (_: any, val: any) => (val.trim() === "" && val.includes("\n") ? "" : undefined),
21-
});
22-
parser.addEntity("#xD", "\r");
23-
parser.addEntity("#10", "\n");
24-
2513
let parsedObj;
2614
try {
27-
parsedObj = parser.parse(encoded, true);
15+
parsedObj = parseXML(encoded);
2816
} catch (e: any) {
2917
if (e && typeof e === "object") {
3018
Object.defineProperty(e, "$responseBodyText", {

packages/xml-builder/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"description": "XML builder for the AWS SDK",
55
"dependencies": {
66
"@smithy/types": "^4.5.0",
7+
"fast-xml-parser": "5.2.5",
78
"tslib": "^2.6.2"
89
},
910
"scripts": {
@@ -38,6 +39,13 @@
3839
"files": [
3940
"dist-*/**"
4041
],
42+
"browser": {
43+
"./dist-es/xml-parser": "./dist-es/xml-parser.browser"
44+
},
45+
"react-native": {
46+
"./dist-es/xml-parser": "./dist-es/xml-parser",
47+
"./dist-cjs/xml-parser": "./dist-cjs/xml-parser"
48+
},
4149
"homepage": "https://github.com/aws/aws-sdk-js-v3/tree/main/packages/xml-builder",
4250
"repository": {
4351
"type": "git",

packages/xml-builder/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@ export * from "./XmlNode";
66
* @internal
77
*/
88
export * from "./XmlText";
9+
10+
/**
11+
* @internal
12+
*/
13+
export { parseXML } from "./xml-parser";
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
const parser = new DOMParser();
2+
3+
export function parseXML(xmlString: string): any {
4+
const xmlDocument = parser.parseFromString(xmlString, "application/xml");
5+
6+
// Recursive function to convert XML nodes to JS object
7+
const xmlToObj = (node: Node): any => {
8+
if (node.nodeType === Node.TEXT_NODE) {
9+
if (node.textContent?.trim()) {
10+
return node.textContent;
11+
}
12+
}
13+
14+
if (node.nodeType === Node.ELEMENT_NODE) {
15+
const element = node as Element;
16+
if (element.attributes.length === 0 && element.childNodes.length === 0) {
17+
return "";
18+
}
19+
20+
const obj: any = {};
21+
22+
for (const attr of Array.from(element.attributes)) {
23+
obj[`${attr.name}`] = attr.value;
24+
}
25+
26+
for (const child of Array.from(element.childNodes)) {
27+
const childResult = xmlToObj(child);
28+
29+
if (childResult != null) {
30+
const childName = child.nodeName;
31+
if (childName === "#text") {
32+
return childResult;
33+
}
34+
35+
if (obj[childName]) {
36+
if (Array.isArray(obj[childName])) {
37+
obj[childName].push(childResult);
38+
} else {
39+
obj[childName] = [obj[childName], childResult];
40+
}
41+
} else {
42+
obj[childName] = childResult;
43+
}
44+
}
45+
}
46+
47+
return obj;
48+
}
49+
50+
return null;
51+
};
52+
53+
return {
54+
[xmlDocument.documentElement.nodeName]: xmlToObj(xmlDocument.documentElement),
55+
};
56+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, expect, test as it } from "vitest";
2+
3+
import { parseXML } from "./xml-parser";
4+
import { parseXML as parseXMLBrowser } from "./xml-parser.browser";
5+
6+
describe("xml parsing", () => {
7+
for (const { name, parse } of [
8+
{ name: "fast-xml-parser", parse: parseXML },
9+
{ name: "DOMParser", parse: parseXMLBrowser },
10+
]) {
11+
describe(name, () => {
12+
it("should parse a valid xml string without xml header", () => {
13+
const xml = `<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
14+
<AssumeRoleResult>
15+
<Credentials>
16+
<AccessKeyId>STS_AR_ACCESS_KEY_ID</AccessKeyId>
17+
<SecretAccessKey>STS_AR_SECRET_ACCESS_KEY</SecretAccessKey>
18+
<SessionToken>STS_AR_SESSION_TOKEN_us-west-2</SessionToken>
19+
<Expiration>3000-01-01T00:00:00.000Z</Expiration>
20+
</Credentials>
21+
</AssumeRoleResult>
22+
<ResponseMetadata>
23+
<RequestId>01234567-89ab-cdef-0123-456789abcdef</RequestId>
24+
</ResponseMetadata>
25+
</AssumeRoleResponse>`;
26+
const object = parse(xml);
27+
expect(object).toEqual({
28+
AssumeRoleResponse: {
29+
AssumeRoleResult: {
30+
Credentials: {
31+
AccessKeyId: "STS_AR_ACCESS_KEY_ID",
32+
Expiration: "3000-01-01T00:00:00.000Z",
33+
SecretAccessKey: "STS_AR_SECRET_ACCESS_KEY",
34+
SessionToken: "STS_AR_SESSION_TOKEN_us-west-2",
35+
},
36+
},
37+
ResponseMetadata: {
38+
RequestId: "01234567-89ab-cdef-0123-456789abcdef",
39+
},
40+
xmlns: "https://sts.amazonaws.com/doc/2011-06-15/",
41+
},
42+
});
43+
});
44+
45+
it("should parse ListBuckets response XML with xml header", () => {
46+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
47+
<ListAllMyBucketsResult>
48+
<Buckets>
49+
<Bucket>
50+
<BucketArn>string</BucketArn>
51+
<BucketRegion>string</BucketRegion>
52+
<CreationDate>timestamp</CreationDate>
53+
<Name>string</Name>
54+
</Bucket>
55+
</Buckets>
56+
<Owner>
57+
<DisplayName>string</DisplayName>
58+
<ID>string</ID>
59+
</Owner>
60+
<ContinuationToken>string</ContinuationToken>
61+
<Prefix>string</Prefix>
62+
</ListAllMyBucketsResult>`;
63+
const object = parse(xml);
64+
expect(object).toEqual({
65+
ListAllMyBucketsResult: {
66+
Buckets: {
67+
Bucket: {
68+
BucketArn: "string",
69+
BucketRegion: "string",
70+
CreationDate: "timestamp",
71+
Name: "string",
72+
},
73+
},
74+
ContinuationToken: "string",
75+
Owner: {
76+
DisplayName: "string",
77+
ID: "string",
78+
},
79+
Prefix: "string",
80+
},
81+
});
82+
});
83+
84+
it("should parse xml (custom)", () => {
85+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
86+
<struct>
87+
<empty></empty>
88+
<text>abcdefg</text>
89+
<duplicate>dup1</duplicate>
90+
<duplicate>dup2</duplicate>
91+
<duplicate>dup3</duplicate>
92+
<spaced> s p a c e d </spaced>
93+
<nested>
94+
<empty></empty>
95+
<text>abcdefg</text>
96+
<duplicate>dup1</duplicate>
97+
<duplicate>dup2</duplicate>
98+
<duplicate>dup3</duplicate>
99+
<spaced> s p a c e d </spaced>
100+
</nested>
101+
</struct>`;
102+
const object = parse(xml);
103+
expect(object).toEqual({
104+
struct: {
105+
empty: "",
106+
text: "abcdefg",
107+
duplicate: ["dup1", "dup2", "dup3"],
108+
spaced: " s p a c e d ",
109+
nested: {
110+
empty: "",
111+
text: "abcdefg",
112+
duplicate: ["dup1", "dup2", "dup3"],
113+
spaced: " s p a c e d ",
114+
},
115+
},
116+
});
117+
});
118+
});
119+
}
120+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { XMLParser } from "fast-xml-parser";
2+
3+
const parser = new XMLParser({
4+
attributeNamePrefix: "",
5+
htmlEntities: true,
6+
ignoreAttributes: false,
7+
ignoreDeclaration: true,
8+
parseTagValue: false,
9+
trimValues: false,
10+
tagValueProcessor: (_: any, val: any) => (val.trim() === "" && val.includes("\n") ? "" : undefined),
11+
});
12+
parser.addEntity("#xD", "\r");
13+
parser.addEntity("#10", "\n");
14+
15+
export function parseXML(xmlString: string): any {
16+
return parser.parse(xmlString, true);
17+
}

packages/xml-builder/vitest.config.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ export default defineConfig({
44
test: {
55
exclude: ["**/*.{integ,e2e,browser}.spec.ts"],
66
include: ["**/*.spec.ts"],
7-
environment: "node",
7+
environment: "happy-dom",
88
},
99
});

yarn.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23534,7 +23534,6 @@ __metadata:
2353423534
"@tsconfig/recommended": "npm:1.0.1"
2353523535
concurrently: "npm:7.0.0"
2353623536
downlevel-dts: "npm:0.10.1"
23537-
fast-xml-parser: "npm:5.2.5"
2353823537
rimraf: "npm:3.0.2"
2353923538
tslib: "npm:^2.6.2"
2354023539
typescript: "npm:~5.8.3"
@@ -25005,6 +25004,7 @@ __metadata:
2500525004
"@tsconfig/recommended": "npm:1.0.1"
2500625005
concurrently: "npm:7.0.0"
2500725006
downlevel-dts: "npm:0.10.1"
25007+
fast-xml-parser: "npm:5.2.5"
2500825008
rimraf: "npm:3.0.2"
2500925009
tslib: "npm:^2.6.2"
2501025010
typescript: "npm:~5.8.3"

0 commit comments

Comments
 (0)