diff --git a/sdk/core/core-client/package.json b/sdk/core/core-client/package.json index a83efdf88d88..17664f7a35ef 100644 --- a/sdk/core/core-client/package.json +++ b/sdk/core/core-client/package.json @@ -47,7 +47,7 @@ "test:node": "npm run build:test:node && npm run unit-test:node && npm run integration-test:node", "test": "npm run clean && npm run build:ts && npm run bundle:test:node && npm run unit-test:node && npm run bundle:test:browser && npm run unit-test:browser && npm run integration-test:node && npm run integration-test:browser", "unit-test:browser": "karma start --single-run", - "unit-test:node": "mocha --require source-map-support/register --reporter ../../../common/tools/mocha-multi-reporter.js dist-test/index.node.js", + "unit-test:node": "cross-env TS_NODE_FILES=true mocha -r esm -r ts-node/register --timeout 50000 --reporter ../../../common/tools/mocha-multi-reporter.js --colors --exclude \"test/**/*.browser.ts\" \"test/**/*.ts\"", "unit-test": "npm run unit-test:node && npm run unit-test:browser" }, "files": [ diff --git a/sdk/core/core-client/review/core-client.api.md b/sdk/core/core-client/review/core-client.api.md index 12381c2c7424..e0a19460ef71 100644 --- a/sdk/core/core-client/review/core-client.api.md +++ b/sdk/core/core-client/review/core-client.api.md @@ -97,9 +97,8 @@ export const deserializationPolicyName = "deserializationPolicy"; // @public export interface DeserializationPolicyOptions { expectedContentTypes?: DeserializationContentTypes; - parseXML?: (str: string, opts?: { - includeRoot?: boolean; - }) => Promise; + parseXML?: (str: string, opts?: XmlOptions) => Promise; + serializerOptions?: SerializerOptions; } // @public (undocumented) @@ -203,6 +202,7 @@ export interface OperationArguments { export interface OperationOptions { abortSignal?: AbortSignalLike; requestOptions?: OperationRequestOptions; + serializerOptions?: SerializerOptions; tracingOptions?: OperationTracingOptions; } @@ -312,7 +312,7 @@ export interface SequenceMapperType { // @public export interface Serializer { // (undocumented) - deserialize(mapper: Mapper, responseBody: any, objectName: string): any; + deserialize(mapper: Mapper, responseBody: any, objectName: string, options?: SerializerOptions): any; // (undocumented) readonly isXML: boolean; // (undocumented) @@ -320,11 +320,16 @@ export interface Serializer { [key: string]: any; }; // (undocumented) - serialize(mapper: Mapper, object: any, objectName?: string): any; + serialize(mapper: Mapper, object: any, objectName?: string, options?: SerializerOptions): any; // (undocumented) validateConstraints(mapper: Mapper, value: any, objectName: string): void; } +// @public +export interface SerializerOptions { + xml: XmlOptions; +} + // @public export class ServiceClient { constructor(options?: ServiceClientOptions); @@ -337,14 +342,10 @@ export interface ServiceClientOptions { baseUri?: string; credential?: TokenCredential; httpsClient?: HttpsClient; - parseXML?: (str: string, opts?: { - includeRoot?: boolean; - }) => Promise; + parseXML?: (str: string, opts?: XmlOptions) => Promise; pipeline?: Pipeline; requestContentType?: string; - stringifyXML?: (obj: any, opts?: { - rootName?: string; - }) => string; + stringifyXML?: (obj: any, opts?: XmlOptions) => string; } // @public (undocumented) @@ -359,6 +360,19 @@ export interface SpanConfig { packagePrefix: string; } +// @public +export const XML_ATTRKEY = "$"; + +// @public +export const XML_CHARKEY = "_"; + +// @public +export interface XmlOptions { + includeRoot?: boolean; + rootName?: string; + xmlCharKey?: string; +} + // (No @packageDocumentation comment for this package) diff --git a/sdk/core/core-client/src/deserializationPolicy.ts b/sdk/core/core-client/src/deserializationPolicy.ts index bf828fab6b80..31f969f292c4 100644 --- a/sdk/core/core-client/src/deserializationPolicy.ts +++ b/sdk/core/core-client/src/deserializationPolicy.ts @@ -12,7 +12,11 @@ import { OperationRequest, OperationResponseMap, FullOperationResponse, - OperationSpec + OperationSpec, + SerializerOptions, + XmlOptions, + XML_CHARKEY, + RequiredSerializerOptions } from "./interfaces"; import { MapperTypeNames } from "./serializer"; import { isStreamOperation } from "./interfaceHelpers"; @@ -38,7 +42,12 @@ export interface DeserializationPolicyOptions { /** * A function that is able to parse XML. Required for XML support. */ - parseXML?: (str: string, opts?: { includeRoot?: boolean }) => Promise; + parseXML?: (str: string, opts?: XmlOptions) => Promise; + + /** + * Configures behavior of xml parser and builder. + */ + serializerOptions?: SerializerOptions; } /** @@ -66,12 +75,26 @@ export function deserializationPolicy(options: DeserializationPolicyOptions = {} const jsonContentTypes = options.expectedContentTypes?.json ?? defaultJsonContentTypes; const xmlContentTypes = options.expectedContentTypes?.xml ?? defaultXmlContentTypes; const parseXML = options.parseXML; + const serializerOptions = options.serializerOptions; + const updatedOptions: RequiredSerializerOptions = { + xml: { + rootName: serializerOptions?.xml.rootName ?? "", + includeRoot: serializerOptions?.xml.includeRoot ?? false, + xmlCharKey: serializerOptions?.xml.xmlCharKey ?? XML_CHARKEY + } + }; return { name: deserializationPolicyName, async sendRequest(request: PipelineRequest, next: SendRequest): Promise { const response = await next(request); - return deserializeResponseBody(jsonContentTypes, xmlContentTypes, response, parseXML); + return deserializeResponseBody( + jsonContentTypes, + xmlContentTypes, + response, + updatedOptions, + parseXML + ); } }; } @@ -110,9 +133,16 @@ async function deserializeResponseBody( jsonContentTypes: string[], xmlContentTypes: string[], response: PipelineResponse, - parseXML?: (str: string, opts?: { includeRoot?: boolean }) => Promise + options: RequiredSerializerOptions, + parseXML?: (str: string, opts?: XmlOptions) => Promise ): Promise { - const parsedResponse = await parse(jsonContentTypes, xmlContentTypes, response, parseXML); + const parsedResponse = await parse( + jsonContentTypes, + xmlContentTypes, + response, + options, + parseXML + ); if (!shouldDeserializeResponse(parsedResponse)) { return parsedResponse; } @@ -281,7 +311,8 @@ async function parse( jsonContentTypes: string[], xmlContentTypes: string[], operationResponse: FullOperationResponse, - parseXML?: (str: string, opts?: { includeRoot?: boolean }) => Promise + opts: RequiredSerializerOptions, + parseXML?: (str: string, opts?: XmlOptions) => Promise ): Promise { if (!operationResponse.request.streamResponseBody && operationResponse.bodyAsText) { const text = operationResponse.bodyAsText; @@ -301,7 +332,7 @@ async function parse( if (!parseXML) { throw new Error("Parsing XML not supported."); } - const body = await parseXML(text); + const body = await parseXML(text, opts.xml); operationResponse.parsedBody = body; return operationResponse; } diff --git a/sdk/core/core-client/src/index.ts b/sdk/core/core-client/src/index.ts index b57bab8d70cb..56b35a7a543e 100644 --- a/sdk/core/core-client/src/index.ts +++ b/sdk/core/core-client/src/index.ts @@ -39,7 +39,11 @@ export { OperationResponse, FullOperationResponse, PolymorphicDiscriminator, - SpanConfig + SpanConfig, + XML_ATTRKEY, + XML_CHARKEY, + XmlOptions, + SerializerOptions } from "./interfaces"; export { deserializationPolicy, diff --git a/sdk/core/core-client/src/interfaces.ts b/sdk/core/core-client/src/interfaces.ts index e913cf240d8c..cda3b91e5439 100644 --- a/sdk/core/core-client/src/interfaces.ts +++ b/sdk/core/core-client/src/interfaces.ts @@ -10,6 +10,45 @@ import { PipelineRequest } from "@azure/core-https"; +/** + * Default key used to access the XML attributes. + */ +export const XML_ATTRKEY = "$"; +/** + * Default key used to access the XML value content. + */ +export const XML_CHARKEY = "_"; +/** + * Options to govern behavior of xml parser and builder. + */ +export interface XmlOptions { + /** + * indicates the name of the root element in the resulting XML when building XML. + */ + rootName?: string; + /** + * indicates whether the root element is to be included or not in the output when parsing XML. + */ + includeRoot?: boolean; + /** + * key used to access the XML value content when parsing XML. + */ + xmlCharKey?: string; +} +/** + * Options to configure serialization/de-serialization behavior. + */ +export interface SerializerOptions { + /** + * Options to configure xml parser/builder behavior. + */ + xml: XmlOptions; +} + +export type RequiredSerializerOptions = { + [K in keyof SerializerOptions]: Required; +}; + /** * This interface extends a generic `PipelineRequest` to include * additional metadata about the request. @@ -57,6 +96,10 @@ export interface OperationOptions { * Options used when tracing is enabled. */ tracingOptions?: OperationTracingOptions; + /** + * Options to override serialization/de-serialization behavior. + */ + serializerOptions?: SerializerOptions; } /** @@ -100,7 +143,7 @@ export interface OperationArguments { [parameterName: string]: unknown; /** - * The optional arugments that are provided to an operation. + * The optional arguments that are provided to an operation. */ options?: OperationOptions; } @@ -306,8 +349,13 @@ export interface Serializer { readonly modelMappers: { [key: string]: any }; readonly isXML: boolean; validateConstraints(mapper: Mapper, value: any, objectName: string): void; - serialize(mapper: Mapper, object: any, objectName?: string): any; - deserialize(mapper: Mapper, responseBody: any, objectName: string): any; + serialize(mapper: Mapper, object: any, objectName?: string, options?: SerializerOptions): any; + deserialize( + mapper: Mapper, + responseBody: any, + objectName: string, + options?: SerializerOptions + ): any; } export interface MapperConstraints { @@ -401,23 +449,23 @@ export interface BaseMapper { */ xmlElementName?: string; /** - * Whether or not the current propery should have a wrapping XML element + * Whether or not the current property should have a wrapping XML element */ xmlIsWrapped?: boolean; /** - * Whether or not the current propery is readonly + * Whether or not the current property is readonly */ readOnly?: boolean; /** - * Whether or not the current propery is a constant + * Whether or not the current property is a constant */ isConstant?: boolean; /** - * Whether or not the current propery is required + * Whether or not the current property is required */ required?: boolean; /** - * Whether or not the current propery allows mull as a value + * Whether or not the current property allows mull as a value */ nullable?: boolean; /** diff --git a/sdk/core/core-client/src/serializer.ts b/sdk/core/core-client/src/serializer.ts index 9088495e6805..5f6ffb09c6b7 100644 --- a/sdk/core/core-client/src/serializer.ts +++ b/sdk/core/core-client/src/serializer.ts @@ -12,7 +12,11 @@ import { CompositeMapper, PolymorphicDiscriminator, EnumMapper, - BaseMapper + BaseMapper, + SerializerOptions, + XML_CHARKEY, + XML_ATTRKEY, + RequiredSerializerOptions } from "./interfaces"; class SerializerImpl implements Serializer { @@ -95,9 +99,23 @@ class SerializerImpl implements Serializer { * * @param {string} objectName Name of the serialized object * + * @param {options} options additional options to serialization + * * @returns {object|string|Array|number|boolean|Date|stream} A valid serialized Javascript object */ - serialize(mapper: Mapper, object: any, objectName?: string): any { + serialize( + mapper: Mapper, + object: any, + objectName?: string, + options: SerializerOptions = { xml: {} } + ): any { + const updatedOptions: RequiredSerializerOptions = { + xml: { + rootName: options.xml.rootName ?? "", + includeRoot: options.xml.includeRoot ?? false, + xmlCharKey: options.xml.xmlCharKey ?? XML_CHARKEY + } + }; let payload: any = {}; const mapperType = mapper.type.name as string; if (!objectName) { @@ -159,7 +177,8 @@ class SerializerImpl implements Serializer { mapper as SequenceMapper, object, objectName, - Boolean(this.isXML) + Boolean(this.isXML), + updatedOptions ); } else if (mapperType.match(/^Dictionary$/i) !== null) { payload = serializeDictionaryType( @@ -167,7 +186,8 @@ class SerializerImpl implements Serializer { mapper as DictionaryMapper, object, objectName, - Boolean(this.isXML) + Boolean(this.isXML), + updatedOptions ); } else if (mapperType.match(/^Composite$/i) !== null) { payload = serializeCompositeType( @@ -175,7 +195,8 @@ class SerializerImpl implements Serializer { mapper as CompositeMapper, object, objectName, - Boolean(this.isXML) + Boolean(this.isXML), + updatedOptions ); } } @@ -191,9 +212,23 @@ class SerializerImpl implements Serializer { * * @param {string} objectName Name of the deserialized object * + * @param options Controls behavior of XML parser and builder. + * * @returns {object|string|Array|number|boolean|Date|stream} A valid deserialized Javascript object */ - deserialize(mapper: Mapper, responseBody: any, objectName: string): any { + deserialize( + mapper: Mapper, + responseBody: any, + objectName: string, + options: SerializerOptions = { xml: {} } + ): any { + const updatedOptions: RequiredSerializerOptions = { + xml: { + rootName: options.xml.rootName ?? "", + includeRoot: options.xml.includeRoot ?? false, + xmlCharKey: options.xml.xmlCharKey ?? XML_CHARKEY + } + }; if (responseBody === undefined || responseBody === null) { if (this.isXML && mapper.type.name === "Sequence" && !mapper.xmlIsWrapped) { // Edge case for empty XML non-wrapped lists. xml2js can't distinguish @@ -215,16 +250,23 @@ class SerializerImpl implements Serializer { } if (mapperType.match(/^Composite$/i) !== null) { - payload = deserializeCompositeType(this, mapper as CompositeMapper, responseBody, objectName); + payload = deserializeCompositeType( + this, + mapper as CompositeMapper, + responseBody, + objectName, + updatedOptions + ); } else { if (this.isXML) { + const xmlCharKey = updatedOptions.xml.xmlCharKey; /** * If the mapper specifies this as a non-composite type value but the responseBody contains - * both header ("$") and body ("_") properties, then just reduce the responseBody value to - * the body ("_") property. + * both header ("$" i.e., XML_ATTRKEY) and body ("#" i.e., XML_CHARKEY) properties, + * then just reduce the responseBody value to the body ("#" i.e., XML_CHARKEY) property. */ - if (responseBody["$"] !== undefined && responseBody["_"] !== undefined) { - responseBody = responseBody["_"]; + if (responseBody[XML_ATTRKEY] !== undefined && responseBody[xmlCharKey] !== undefined) { + responseBody = responseBody[xmlCharKey]; } } @@ -252,13 +294,20 @@ class SerializerImpl implements Serializer { } else if (mapperType.match(/^Base64Url$/i) !== null) { payload = base64UrlToByteArray(responseBody); } else if (mapperType.match(/^Sequence$/i) !== null) { - payload = deserializeSequenceType(this, mapper as SequenceMapper, responseBody, objectName); + payload = deserializeSequenceType( + this, + mapper as SequenceMapper, + responseBody, + objectName, + updatedOptions + ); } else if (mapperType.match(/^Dictionary$/i) !== null) { payload = deserializeDictionaryType( this, mapper as DictionaryMapper, responseBody, - objectName + objectName, + updatedOptions ); } } @@ -274,7 +323,7 @@ class SerializerImpl implements Serializer { /** * Method that creates and returns a Serializer. * @param modelMappers Known models to map - * @param isXML If XML shuold be supported + * @param isXML If XML should be supported */ export function createSerializer( modelMappers: { [key: string]: any } = {}, @@ -501,7 +550,8 @@ function serializeSequenceType( mapper: SequenceMapper, object: any, objectName: string, - isXml: boolean + isXml: boolean, + options: RequiredSerializerOptions ): any { if (!Array.isArray(object)) { throw new Error(`${objectName} must be of type Array.`); @@ -515,15 +565,18 @@ function serializeSequenceType( } const tempArray = []; for (let i = 0; i < object.length; i++) { - const serializedValue = serializer.serialize(elementType, object[i], objectName); + const serializedValue = serializer.serialize(elementType, object[i], objectName, options); if (isXml && elementType.xmlNamespace) { const xmlnsKey = elementType.xmlNamespacePrefix ? `xmlns:${elementType.xmlNamespacePrefix}` : "xmlns"; if (elementType.type.name === "Composite") { - tempArray[i] = { ...serializedValue, $: { [xmlnsKey]: elementType.xmlNamespace } }; + tempArray[i] = { ...serializedValue }; + tempArray[i][XML_ATTRKEY] = { [xmlnsKey]: elementType.xmlNamespace }; } else { - tempArray[i] = { _: serializedValue, $: { [xmlnsKey]: elementType.xmlNamespace } }; + tempArray[i] = {}; + tempArray[i][options.xml.xmlCharKey] = serializedValue; + tempArray[i][XML_ATTRKEY] = { [xmlnsKey]: elementType.xmlNamespace }; } } else { tempArray[i] = serializedValue; @@ -537,7 +590,8 @@ function serializeDictionaryType( mapper: DictionaryMapper, object: any, objectName: string, - isXml: boolean + isXml: boolean, + options: RequiredSerializerOptions ): any { if (typeof object !== "object") { throw new Error(`${objectName} must be of type object.`); @@ -551,16 +605,17 @@ function serializeDictionaryType( } const tempDictionary: { [key: string]: any } = {}; for (const key of Object.keys(object)) { - const serializedValue = serializer.serialize(valueType, object[key], objectName); + const serializedValue = serializer.serialize(valueType, object[key], objectName, options); // If the element needs an XML namespace we need to add it within the $ property - tempDictionary[key] = getXmlObjectValue(valueType, serializedValue, isXml); + tempDictionary[key] = getXmlObjectValue(valueType, serializedValue, isXml, options); } // Add the namespace to the root element if needed if (isXml && mapper.xmlNamespace) { const xmlnsKey = mapper.xmlNamespacePrefix ? `xmlns:${mapper.xmlNamespacePrefix}` : "xmlns"; - - return { ...tempDictionary, $: { [xmlnsKey]: mapper.xmlNamespace } }; + const result = tempDictionary; + result[XML_ATTRKEY] = { [xmlnsKey]: mapper.xmlNamespace }; + return result; } return tempDictionary; @@ -647,7 +702,8 @@ function serializeCompositeType( mapper: CompositeMapper, object: any, objectName: string, - isXml: boolean + isXml: boolean, + options: RequiredSerializerOptions ): any { if (getPolymorphicDiscriminatorRecursively(serializer, mapper)) { mapper = getPolymorphicMapper(serializer, mapper, object, "clientName"); @@ -692,7 +748,10 @@ function serializeCompositeType( const xmlnsKey = mapper.xmlNamespacePrefix ? `xmlns:${mapper.xmlNamespacePrefix}` : "xmlns"; - parentObject.$ = { ...parentObject.$, [xmlnsKey]: mapper.xmlNamespace }; + parentObject[XML_ATTRKEY] = { + ...parentObject[XML_ATTRKEY], + [xmlnsKey]: mapper.xmlNamespace + }; } const propertyObjectName = propertyMapper.serializedName !== "" @@ -712,16 +771,17 @@ function serializeCompositeType( const serializedValue = serializer.serialize( propertyMapper, toSerialize, - propertyObjectName + propertyObjectName, + options ); if (serializedValue !== undefined && propName !== undefined && propName !== null) { - const value = getXmlObjectValue(propertyMapper, serializedValue, isXml); + const value = getXmlObjectValue(propertyMapper, serializedValue, isXml, options); if (isXml && propertyMapper.xmlIsAttribute) { - // $ is the key attributes are kept under in xml2js. + // XML_ATTRKEY, i.e., $ is the key attributes are kept under in xml2js. // This keeps things simple while preventing name collision // with names in user documents. - parentObject.$ = parentObject.$ || {}; - parentObject.$[propName] = serializedValue; + parentObject[XML_ATTRKEY] = parentObject[XML_ATTRKEY] || {}; + parentObject[XML_ATTRKEY][propName] = serializedValue; } else if (isXml && propertyMapper.xmlIsWrapped) { parentObject[propName] = { [propertyMapper.xmlElementName!]: value }; } else { @@ -740,7 +800,8 @@ function serializeCompositeType( payload[clientPropName] = serializer.serialize( additionalPropertiesMapper, object[clientPropName], - objectName + '["' + clientPropName + '"]' + objectName + '["' + clientPropName + '"]', + options ); } } @@ -751,7 +812,12 @@ function serializeCompositeType( return object; } -function getXmlObjectValue(propertyMapper: Mapper, serializedValue: any, isXml: boolean) { +function getXmlObjectValue( + propertyMapper: Mapper, + serializedValue: any, + isXml: boolean, + options: RequiredSerializerOptions +) { if (!isXml || !propertyMapper.xmlNamespace) { return serializedValue; } @@ -762,20 +828,30 @@ function getXmlObjectValue(propertyMapper: Mapper, serializedValue: any, isXml: const xmlNamespace = { [xmlnsKey]: propertyMapper.xmlNamespace }; if (["Composite"].includes(propertyMapper.type.name)) { - return { $: xmlNamespace, ...serializedValue }; + if (serializedValue[XML_ATTRKEY]) { + return serializedValue; + } else { + const result: any = { ...serializedValue }; + result[XML_ATTRKEY] = xmlNamespace; + return result; + } } - return { _: serializedValue, $: xmlNamespace }; + const result: any = {}; + result[options.xml.xmlCharKey] = serializedValue; + result[XML_ATTRKEY] = xmlNamespace; + return result; } -function isSpecialXmlProperty(propertyName: string): boolean { - return ["$", "_"].includes(propertyName); +function isSpecialXmlProperty(propertyName: string, options: RequiredSerializerOptions): boolean { + return [XML_ATTRKEY, options.xml.xmlCharKey].includes(propertyName); } function deserializeCompositeType( serializer: Serializer, mapper: CompositeMapper, responseBody: any, - objectName: string + objectName: string, + options: RequiredSerializerOptions ): any { if (getPolymorphicDiscriminatorRecursively(serializer, mapper)) { mapper = getPolymorphicMapper(serializer, mapper, responseBody, "serializedName"); @@ -803,7 +879,8 @@ function deserializeCompositeType( dictionary[headerKey.substring(headerCollectionPrefix.length)] = serializer.deserialize( (propertyMapper as DictionaryMapper).type.value, responseBody[headerKey], - propertyObjectName + propertyObjectName, + options ); } @@ -811,11 +888,12 @@ function deserializeCompositeType( } instance[key] = dictionary; } else if (serializer.isXML) { - if (propertyMapper.xmlIsAttribute && responseBody.$) { + if (propertyMapper.xmlIsAttribute && responseBody[XML_ATTRKEY]) { instance[key] = serializer.deserialize( propertyMapper, - responseBody.$[xmlName!], - propertyObjectName + responseBody[XML_ATTRKEY][xmlName!], + propertyObjectName, + options ); } else { const propertyName = xmlElementName || xmlName || serializedName; @@ -836,10 +914,20 @@ function deserializeCompositeType( */ const wrapped = responseBody[xmlName!]; const elementList = wrapped?.[xmlElementName!] ?? []; - instance[key] = serializer.deserialize(propertyMapper, elementList, propertyObjectName); + instance[key] = serializer.deserialize( + propertyMapper, + elementList, + propertyObjectName, + options + ); } else { const property = responseBody[propertyName!]; - instance[key] = serializer.deserialize(propertyMapper, property, propertyObjectName); + instance[key] = serializer.deserialize( + propertyMapper, + property, + propertyObjectName, + options + ); } } } else { @@ -874,12 +962,18 @@ function deserializeCompositeType( // paging if (Array.isArray(responseBody[key]) && modelProps[key].serializedName === "") { propertyInstance = responseBody[key]; - instance = serializer.deserialize(propertyMapper, propertyInstance, propertyObjectName); + instance = serializer.deserialize( + propertyMapper, + propertyInstance, + propertyObjectName, + options + ); } else if (propertyInstance !== undefined || propertyMapper.defaultValue !== undefined) { serializedValue = serializer.deserialize( propertyMapper, propertyInstance, - propertyObjectName + propertyObjectName, + options ); instance[key] = serializedValue; } @@ -903,7 +997,8 @@ function deserializeCompositeType( instance[responsePropName] = serializer.deserialize( additionalPropertiesMapper, responseBody[responsePropName], - objectName + '["' + responsePropName + '"]' + objectName + '["' + responsePropName + '"]', + options ); } } @@ -912,7 +1007,7 @@ function deserializeCompositeType( if ( instance[key] === undefined && !handledPropertyNames.includes(key) && - !isSpecialXmlProperty(key) + !isSpecialXmlProperty(key, options) ) { instance[key] = responseBody[key]; } @@ -926,7 +1021,8 @@ function deserializeDictionaryType( serializer: Serializer, mapper: DictionaryMapper, responseBody: any, - objectName: string + objectName: string, + options: RequiredSerializerOptions ): any { /* jshint validthis: true */ const value = mapper.type.value; @@ -939,7 +1035,7 @@ function deserializeDictionaryType( if (responseBody) { const tempDictionary: { [key: string]: any } = {}; for (const key of Object.keys(responseBody)) { - tempDictionary[key] = serializer.deserialize(value, responseBody[key], objectName); + tempDictionary[key] = serializer.deserialize(value, responseBody[key], objectName, options); } return tempDictionary; } @@ -950,7 +1046,8 @@ function deserializeSequenceType( serializer: Serializer, mapper: SequenceMapper, responseBody: any, - objectName: string + objectName: string, + options: RequiredSerializerOptions ): any { /* jshint validthis: true */ const element = mapper.type.element; @@ -968,7 +1065,12 @@ function deserializeSequenceType( const tempArray = []; for (let i = 0; i < responseBody.length; i++) { - tempArray[i] = serializer.deserialize(element, responseBody[i], `${objectName}[${i}]`); + tempArray[i] = serializer.deserialize( + element, + responseBody[i], + `${objectName}[${i}]`, + options + ); } return tempArray; } diff --git a/sdk/core/core-client/src/serviceClient.ts b/sdk/core/core-client/src/serviceClient.ts index 59c3949fc1cc..67567c844b60 100644 --- a/sdk/core/core-client/src/serviceClient.ts +++ b/sdk/core/core-client/src/serviceClient.ts @@ -21,7 +21,11 @@ import { OperationResponseMap, FullOperationResponse, DictionaryMapper, - CompositeMapper + CompositeMapper, + XmlOptions, + XML_CHARKEY, + XML_ATTRKEY, + RequiredSerializerOptions } from "./interfaces"; import { getPathStringFromParameter, isStreamOperation } from "./interfaceHelpers"; import { MapperTypeNames } from "./serializer"; @@ -60,12 +64,12 @@ export interface ServiceClientOptions { /** * A method that is able to turn an XML object model into a string. */ - stringifyXML?: (obj: any, opts?: { rootName?: string }) => string; + stringifyXML?: (obj: any, opts?: XmlOptions) => string; /** * A method that is able to parse XML. */ - parseXML?: (str: string, opts?: { includeRoot?: boolean }) => Promise; + parseXML?: (str: string, opts?: XmlOptions) => Promise; } /** @@ -87,7 +91,7 @@ export class ServiceClient { /** * Decoupled method for processing XML into a string. */ - private readonly _stringifyXML?: (obj: any, opts?: { rootName?: string }) => string; + private readonly _stringifyXML?: (obj: any, opts?: XmlOptions) => string; /** * The HTTP client that will be used to send requests. @@ -249,10 +253,20 @@ export function serializeRequestBody( request: OperationRequest, operationArguments: OperationArguments, operationSpec: OperationSpec, - stringifyXML: (obj: any, opts?: { rootName?: string }) => string = function() { + stringifyXML: (obj: any, opts?: XmlOptions) => string = function() { throw new Error("XML serialization unsupported!"); } ): void { + const serializerOptions = operationArguments.options?.serializerOptions; + const updatedOptions: RequiredSerializerOptions = { + xml: { + rootName: serializerOptions?.xml.rootName ?? "", + includeRoot: serializerOptions?.xml.includeRoot ?? false, + xmlCharKey: serializerOptions?.xml.xmlCharKey ?? XML_CHARKEY + } + }; + + const xmlCharKey = updatedOptions.xml.xmlCharKey; if (operationSpec.requestBody && operationSpec.requestBody.mapper) { request.body = getOperationArgumentValueFromParameter( operationArguments, @@ -278,14 +292,21 @@ export function serializeRequestBody( request.body = operationSpec.serializer.serialize( bodyMapper, request.body, - requestBodyParameterPathString + requestBodyParameterPathString, + updatedOptions ); const isStream = typeName === MapperTypeNames.Stream; if (operationSpec.isXML) { const xmlnsKey = xmlNamespacePrefix ? `xmlns:${xmlNamespacePrefix}` : "xmlns"; - const value = getXmlValueWithNamespace(xmlNamespace, xmlnsKey, typeName, request.body); + const value = getXmlValueWithNamespace( + xmlNamespace, + xmlnsKey, + typeName, + request.body, + updatedOptions + ); if (typeName === MapperTypeNames.Sequence) { request.body = stringifyXML( @@ -295,11 +316,12 @@ export function serializeRequestBody( xmlnsKey, xmlNamespace ), - { rootName: xmlName || serializedName } + { rootName: xmlName || serializedName, xmlCharKey } ); } else if (!isStream) { request.body = stringifyXML(value, { - rootName: xmlName || serializedName + rootName: xmlName || serializedName, + xmlCharKey }); } } else if ( @@ -335,7 +357,8 @@ export function serializeRequestBody( request.formData[formDataParameterPropertyName] = operationSpec.serializer.serialize( formDataParameter.mapper, formDataParameterValue, - getPathStringFromParameter(formDataParameter) + getPathStringFromParameter(formDataParameter), + updatedOptions ); } } @@ -349,12 +372,16 @@ function getXmlValueWithNamespace( xmlNamespace: string | undefined, xmlnsKey: string, typeName: string, - serializedValue: any + serializedValue: any, + options: RequiredSerializerOptions ): any { // Composite and Sequence schemas already got their root namespace set during serialization // We just need to add xmlns to the other schema types if (xmlNamespace && !["Composite", "Sequence", "Dictionary"].includes(typeName)) { - return { _: serializedValue, $: { [xmlnsKey]: xmlNamespace } }; + const result: any = {}; + result[options.xml.xmlCharKey] = serializedValue; + result[XML_ATTRKEY] = { [xmlnsKey]: xmlNamespace }; + return result; } return serializedValue; @@ -364,7 +391,7 @@ function createDefaultPipeline( options: { baseUri?: string; credential?: TokenCredential; - parseXML?: (str: string, opts?: { includeRoot?: boolean }) => Promise; + parseXML?: (str: string, opts?: XmlOptions) => Promise; } = {} ): Pipeline { return createClientPipeline({ @@ -428,7 +455,9 @@ function prepareXMLRootList( return { [elementName]: obj }; } - return { [elementName]: obj, $: { [xmlNamespaceKey]: xmlNamespace } }; + const result = { [elementName]: obj }; + result[XML_ATTRKEY] = { [xmlNamespaceKey]: xmlNamespace }; + return result; } function flattenResponse( diff --git a/sdk/core/core-client/test/deserializationPolicy.spec.ts b/sdk/core/core-client/test/deserializationPolicy.spec.ts index 80fa16e56e02..68f09c1efa53 100644 --- a/sdk/core/core-client/test/deserializationPolicy.spec.ts +++ b/sdk/core/core-client/test/deserializationPolicy.spec.ts @@ -9,7 +9,8 @@ import { OperationRequest, createSerializer, CompositeMapper, - FullOperationResponse + FullOperationResponse, + SerializerOptions } from "../src"; import { createPipelineRequest, @@ -372,6 +373,45 @@ describe("deserializationPolicy", function() { }); }); + it(`should deserialize underscore xml element with custom xml char key`, async function() { + const response = await getDeserializedResponse({ + headers: { "content-type": "application/xml" }, + bodyAsText: `v<_>underscore`, + serializerOptions: { xml: { xmlCharKey: "#" } } + }); + assert.exists(response); + assert.isUndefined(response.readableStreamBody); + assert.isUndefined(response.blobBody); + assert.isUndefined(response.parsedHeaders); + assert.strictEqual(response.bodyAsText, `v<_>underscore`); + assert.deepEqual(response.parsedBody, { + h: "v", + _: "underscore" + }); + }); + + it(`with custom xml char key`, async function() { + const response = await getDeserializedResponse({ + headers: { "content-type": "application/xml" }, + bodyAsText: `3`, + serializerOptions: { xml: { xmlCharKey: "#" } } + }); + + assert.exists(response); + assert.isUndefined(response.readableStreamBody); + assert.isUndefined(response.blobBody); + assert.strictEqual(response.bodyAsText, `3`); + assert.isUndefined(response.parsedHeaders); + assert.deepEqual(response.parsedBody, { + apples: { + $: { + taste: "good" + }, + "#": "3" + } + }); + }); + it(`with default response headers`, async function() { const BodyMapper: CompositeMapper = { serializedName: "getproperties-body", @@ -660,11 +700,13 @@ async function getDeserializedResponse( status?: number; bodyAsText?: string; xmlContentTypes?: string[]; + serializerOptions?: SerializerOptions; } = {} ): Promise { const policy = deserializationPolicy({ expectedContentTypes: { xml: options.xmlContentTypes }, - parseXML + parseXML, + serializerOptions: options.serializerOptions }); const request: OperationRequest = createPipelineRequest({ url: "https://example.com" }); request.additionalInfo = { diff --git a/sdk/core/core-client/test/serviceClient.spec.ts b/sdk/core/core-client/test/serviceClient.spec.ts index 821f39881dac..0763ba154751 100644 --- a/sdk/core/core-client/test/serviceClient.spec.ts +++ b/sdk/core/core-client/test/serviceClient.spec.ts @@ -943,6 +943,54 @@ describe("ServiceClient", function() { assert.deepEqual(httpRequest.body, `{"alpha":"hello","beta":"world"}`); }); + it("should serialize an XML request body with custom xml char key", () => { + const httpRequest = createPipelineRequest({ url: "https://example.com" }); + serializeRequestBody( + httpRequest, + { + requestBody: { + "#": "pound value" + }, + options: { + serializerOptions: { + xml: { + xmlCharKey: "#" + } + } + } + }, + { + httpMethod: "POST", + requestBody: { + parameterPath: "requestBody", + mapper: { + serializedName: "Body", + xmlName: "entry", + type: { + name: "Composite", + className: "Body", + modelProperties: { + "#": { + serializedName: "#", + xmlName: "#", + type: { name: "String" } + } + } + } + } + }, + responses: { 200: {} }, + serializer: createSerializer(undefined, true /** isXML */), + isXML: true + }, + stringifyXML + ); + assert.strictEqual( + httpRequest.body, + `pound value` + ); + }); + it("should serialize a string send to a text/plain endpoint as just a string", () => { const httpRequest = createPipelineRequest({ url: "https://example.com" }); serializeRequestBody( diff --git a/sdk/core/core-http/src/serializer.ts b/sdk/core/core-http/src/serializer.ts index 4dc49ab60c54..eaea838e76f5 100644 --- a/sdk/core/core-http/src/serializer.ts +++ b/sdk/core/core-http/src/serializer.ts @@ -589,7 +589,9 @@ function serializeDictionaryType( if (isXml && mapper.xmlNamespace) { const xmlnsKey = mapper.xmlNamespacePrefix ? `xmlns:${mapper.xmlNamespacePrefix}` : "xmlns"; - return { ...tempDictionary, $: { [xmlnsKey]: mapper.xmlNamespace } }; + const result = tempDictionary; + result[XML_ATTRKEY] = { [xmlnsKey]: mapper.xmlNamespace }; + return result; } return tempDictionary; @@ -1186,23 +1188,23 @@ export interface BaseMapper { */ xmlElementName?: string; /** - * Whether or not the current propery should have a wrapping XML element + * Whether or not the current property should have a wrapping XML element */ xmlIsWrapped?: boolean; /** - * Whether or not the current propery is readonly + * Whether or not the current property is readonly */ readOnly?: boolean; /** - * Whether or not the current propery is a constant + * Whether or not the current property is a constant */ isConstant?: boolean; /** - * Whether or not the current propery is required + * Whether or not the current property is required */ required?: boolean; /** - * Whether or not the current propery allows mull as a value + * Whether or not the current property allows mull as a value */ nullable?: boolean; /** diff --git a/sdk/core/core-http/src/util/utils.ts b/sdk/core/core-http/src/util/utils.ts index 0f2407a528dc..22a496698a2a 100644 --- a/sdk/core/core-http/src/util/utils.ts +++ b/sdk/core/core-http/src/util/utils.ts @@ -6,6 +6,7 @@ import { HttpOperationResponse } from "../httpOperationResponse"; import { RestError } from "../restError"; import { WebResourceLike } from "../webResource"; import { Constants } from "./constants"; +import { XML_ATTRKEY } from "./serializer.common"; const validUuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/i; @@ -203,7 +204,9 @@ export function prepareXMLRootList( return { [elementName]: obj }; } - return { [elementName]: obj, $: { [xmlNamespaceKey]: xmlNamespace } }; + const result = { [elementName]: obj }; + result[XML_ATTRKEY] = { [xmlNamespaceKey]: xmlNamespace }; + return result; } /** diff --git a/sdk/core/core-http/src/webResource.ts b/sdk/core/core-http/src/webResource.ts index 73fe9470edb7..bbf46eb0d256 100644 --- a/sdk/core/core-http/src/webResource.ts +++ b/sdk/core/core-http/src/webResource.ts @@ -447,7 +447,7 @@ export class WebResource implements WebResourceLike { this.headers.set("Content-Type", "application/json; charset=utf-8"); } - // set the request body. request.js automatically sets the Content-Length request header, so we need not set it explicilty + // set the request body. request.js automatically sets the Content-Length request header, so we need not set it explicitly this.body = options.body; if (options.body !== undefined && options.body !== null) { // body as a stream special case. set the body as-is and check for some special request headers specific to sending a stream. diff --git a/sdk/core/core-tracing/package.json b/sdk/core/core-tracing/package.json index d00f38482fa4..756d1c714b49 100644 --- a/sdk/core/core-tracing/package.json +++ b/sdk/core/core-tracing/package.json @@ -25,7 +25,7 @@ "integration-test:node": "echo skipped", "integration-test": "npm run integration-test:node && npm run integration-test:browser", "lint:fix": "eslint package.json api-extractor.json src test --ext .ts --fix --fix-type [problem,suggestion]", - "lint": "eslint package.json api-extractor.json src test --ext .ts -f html -o core-tracing-lintReport.html", + "lint": "eslint package.json api-extractor.json src test --ext .ts", "pack": "npm pack 2>&1", "prebuild": "npm run clean", "test:browser": "npm run build:test && npm run unit-test:browser && npm run integration-test:browser", diff --git a/sdk/core/core-xml/review/core-xml.api.md b/sdk/core/core-xml/review/core-xml.api.md index f02a4f7c64e9..527aaae02cf1 100644 --- a/sdk/core/core-xml/review/core-xml.api.md +++ b/sdk/core/core-xml/review/core-xml.api.md @@ -5,14 +5,23 @@ ```ts // @public -export function parseXML(str: string, opts?: { - includeRoot?: boolean; -}): Promise; +export function parseXML(str: string, opts?: XmlOptions): Promise; + +// @public +export function stringifyXML(obj: any, opts?: XmlOptions): string; + +// @public +export const XML_ATTRKEY = "$"; + +// @public +export const XML_CHARKEY = "_"; // @public -export function stringifyXML(obj: any, opts?: { +export interface XmlOptions { + includeRoot?: boolean; rootName?: string; -}): string; + xmlCharKey?: string; +} // (No @packageDocumentation comment for this package) diff --git a/sdk/core/core-xml/src/index.ts b/sdk/core/core-xml/src/index.ts index c2671da880b5..9bd015ddc3dd 100644 --- a/sdk/core/core-xml/src/index.ts +++ b/sdk/core/core-xml/src/index.ts @@ -2,3 +2,4 @@ // Licensed under the MIT license. export { stringifyXML, parseXML } from "./xml"; +export { XML_ATTRKEY, XML_CHARKEY, XmlOptions } from "./xml.common"; diff --git a/sdk/core/core-xml/src/xml.browser.ts b/sdk/core/core-xml/src/xml.browser.ts index 91059acb0874..80736f957b8a 100644 --- a/sdk/core/core-xml/src/xml.browser.ts +++ b/sdk/core/core-xml/src/xml.browser.ts @@ -2,20 +2,27 @@ // Licensed under the MIT license. /// +import { XML_ATTRKEY, XML_CHARKEY, XmlOptions } from "./xml.common"; +// tslint:disable-next-line:no-null-keyword const doc = document.implementation.createDocument(null, null, null); const parser = new DOMParser(); -export function parseXML(str: string, opts?: { includeRoot?: boolean }): Promise { +export function parseXML(str: string, opts: XmlOptions = {}): Promise { try { + const updatedOptions: Required = { + rootName: opts.rootName ?? "", + includeRoot: opts.includeRoot ?? false, + xmlCharKey: opts.xmlCharKey ?? XML_CHARKEY + }; const dom = parser.parseFromString(str, "application/xml"); throwIfError(dom); let obj; - if (opts && opts.includeRoot) { - obj = domToObject(dom); + if (updatedOptions.includeRoot) { + obj = domToObject(dom, updatedOptions); } else { - obj = domToObject(dom.childNodes[0]); + obj = domToObject(dom.childNodes[0], updatedOptions); } return Promise.resolve(obj); @@ -53,7 +60,7 @@ function asElementWithAttributes(node: Node): Element | undefined { return isElement(node) && node.hasAttributes() ? node : undefined; } -function domToObject(node: Node): any { +function domToObject(node: Node, options: Required): any { let result: any = {}; const childNodeCount: number = node.childNodes.length; @@ -68,15 +75,15 @@ function domToObject(node: Node): any { const elementWithAttributes: Element | undefined = asElementWithAttributes(node); if (elementWithAttributes) { - result["$"] = {}; + result[XML_ATTRKEY] = {}; for (let i = 0; i < elementWithAttributes.attributes.length; i++) { const attr = elementWithAttributes.attributes[i]; - result["$"][attr.nodeName] = attr.nodeValue; + result[XML_ATTRKEY][attr.nodeName] = attr.nodeValue; } if (onlyChildTextValue) { - result["_"] = onlyChildTextValue; + result[options.xmlCharKey] = onlyChildTextValue; } } else if (childNodeCount === 0) { result = ""; @@ -89,7 +96,7 @@ function domToObject(node: Node): any { const child = node.childNodes[i]; // Ignore leading/trailing whitespace nodes if (child.nodeType !== Node.TEXT_NODE) { - const childObject: any = domToObject(child); + const childObject: any = domToObject(child, options); if (!result[child.nodeName]) { result[child.nodeName] = childObject; } else if (Array.isArray(result[child.nodeName])) { @@ -106,9 +113,13 @@ function domToObject(node: Node): any { const serializer = new XMLSerializer(); -export function stringifyXML(content: any, opts?: { rootName?: string }): string { - const rootName = (opts && opts.rootName) || "root"; - const dom = buildNode(content, rootName)[0]; +export function stringifyXML(content: any, opts: XmlOptions = {}): string { + const updatedOptions: Required = { + rootName: opts.rootName ?? "root", + includeRoot: opts.includeRoot ?? false, + xmlCharKey: opts.xmlCharKey ?? XML_CHARKEY + }; + const dom = buildNode(content, updatedOptions.rootName, updatedOptions)[0]; return ( '' + serializer.serializeToString(dom) ); @@ -124,7 +135,7 @@ function buildAttributes(attrs: { [key: string]: { toString(): string } }): Attr return result; } -function buildNode(obj: any, elementName: string): Node[] { +function buildNode(obj: any, elementName: string, options: Required): Node[] { if ( obj === undefined || obj === null || @@ -138,7 +149,7 @@ function buildNode(obj: any, elementName: string): Node[] { } else if (Array.isArray(obj)) { const result = []; for (const arrayElem of obj) { - for (const child of buildNode(arrayElem, elementName)) { + for (const child of buildNode(arrayElem, elementName, options)) { result.push(child); } } @@ -146,14 +157,14 @@ function buildNode(obj: any, elementName: string): Node[] { } else if (typeof obj === "object") { const elem = doc.createElement(elementName); for (const key of Object.keys(obj)) { - if (key === "$") { + if (key === XML_ATTRKEY) { for (const attr of buildAttributes(obj[key])) { elem.attributes.setNamedItem(attr); } - } else if (key === "_") { + } else if (key === options.xmlCharKey) { elem.textContent = obj[key].toString(); } else { - for (const child of buildNode(obj[key], key)) { + for (const child of buildNode(obj[key], key, options)) { elem.appendChild(child); } } diff --git a/sdk/core/core-xml/src/xml.common.ts b/sdk/core/core-xml/src/xml.common.ts new file mode 100644 index 000000000000..71140d53a5fe --- /dev/null +++ b/sdk/core/core-xml/src/xml.common.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Default key used to access the XML attributes. + */ +export const XML_ATTRKEY = "$"; +/** + * Default key used to access the XML value content. + */ +export const XML_CHARKEY = "_"; + +/** + * Options to govern behavior of xml parser and builder. + */ +export interface XmlOptions { + /** + * indicates the name of the root element in the resulting XML when building XML. + */ + rootName?: string; + /** + * indicates whether the root element is to be included or not in the output when parsing XML. + */ + includeRoot?: boolean; + /** + * key used to access the XML value content when parsing XML. + */ + xmlCharKey?: string; +} diff --git a/sdk/core/core-xml/src/xml.ts b/sdk/core/core-xml/src/xml.ts index e1d877793857..ccfede59acfc 100644 --- a/sdk/core/core-xml/src/xml.ts +++ b/sdk/core/core-xml/src/xml.ts @@ -2,23 +2,23 @@ // Licensed under the MIT license. import * as xml2js from "xml2js"; +import { XML_ATTRKEY, XML_CHARKEY, XmlOptions } from "./xml.common"; // Note: The reason we re-define all of the xml2js default settings (version 2.0) here is because the default settings object exposed // by the xm2js library is mutable. See https://github.com/Leonidas-from-XIV/node-xml2js/issues/536 // By creating a new copy of the settings each time we instantiate the parser, // we are safeguarding against the possibility of the default settings being mutated elsewhere unintentionally. -const xml2jsDefaultOptionsV2 = { +const xml2jsDefaultOptionsV2: xml2js.OptionsV2 = { explicitCharkey: false, trim: false, normalize: false, normalizeTags: false, - attrkey: "$", - charkey: "_", + attrkey: XML_ATTRKEY, explicitArray: true, ignoreAttrs: false, mergeAttrs: false, explicitRoot: true, - validator: null, + validator: undefined, xmlns: false, explicitChildren: false, preserveChildrenOrder: false, @@ -27,17 +27,17 @@ const xml2jsDefaultOptionsV2 = { includeWhiteChars: false, async: false, strict: true, - attrNameProcessors: null, - attrValueProcessors: null, - tagNameProcessors: null, - valueProcessors: null, + attrNameProcessors: undefined, + attrValueProcessors: undefined, + tagNameProcessors: undefined, + valueProcessors: undefined, rootName: "root", xmldec: { version: "1.0", encoding: "UTF-8", standalone: true }, - doctype: null, + doctype: undefined, renderOpts: { pretty: true, indent: " ", @@ -63,11 +63,12 @@ xml2jsBuilderSettings.renderOpts = { /** * Converts given JSON object to XML string * @param obj JSON object to be converted into XML string - * @param opts Options that govern the parsing of given JSON object + * @param opts Options that govern the XML building of given JSON object * `rootName` indicates the name of the root element in the resulting XML */ -export function stringifyXML(obj: any, opts?: { rootName?: string }): string { - xml2jsBuilderSettings.rootName = (opts || {}).rootName; +export function stringifyXML(obj: any, opts: XmlOptions = {}): string { + xml2jsBuilderSettings.rootName = opts.rootName; + xml2jsBuilderSettings.charkey = opts.xmlCharKey ?? XML_CHARKEY; const builder = new xml2js.Builder(xml2jsBuilderSettings); return builder.buildObject(obj); } @@ -78,8 +79,9 @@ export function stringifyXML(obj: any, opts?: { rootName?: string }): string { * @param opts Options that govern the parsing of given xml string * `includeRoot` indicates whether the root element is to be included or not in the output */ -export function parseXML(str: string, opts?: { includeRoot?: boolean }): Promise { - xml2jsParserSettings.explicitRoot = !!(opts && opts.includeRoot); +export function parseXML(str: string, opts: XmlOptions = {}): Promise { + xml2jsParserSettings.explicitRoot = !!opts.includeRoot; + xml2jsParserSettings.charkey = opts.xmlCharKey ?? XML_CHARKEY; const xmlParser = new xml2js.Parser(xml2jsParserSettings); return new Promise((resolve, reject) => { if (!str) { diff --git a/sdk/core/core-xml/test/xml.spec.ts b/sdk/core/core-xml/test/xml.spec.ts index d125f308c634..daf98d2ab3c3 100644 --- a/sdk/core/core-xml/test/xml.spec.ts +++ b/sdk/core/core-xml/test/xml.spec.ts @@ -402,6 +402,15 @@ describe("XML serializer", function() { `yum` ); }); + + it("with underscore element", async function() { + const str = "v<_>underscore"; + const parsed = await parseXML(str, { xmlCharKey: "#" }); + assert.deepStrictEqual(parsed, { + h: "v", + _: "underscore" + }); + }); }); it("should handle errors gracefully", async function() {