diff --git a/sdk/core/core-http/CHANGELOG.md b/sdk/core/core-http/CHANGELOG.md index e81626775532..627cc0828060 100644 --- a/sdk/core/core-http/CHANGELOG.md +++ b/sdk/core/core-http/CHANGELOG.md @@ -3,7 +3,8 @@ ## 1.2.0 (2020-10-19) - Explicitly set `manual` redirect handling for node fetch. And fixing redirectPipeline [PR 11863](https://github.com/Azure/azure-sdk-for-js/pull/11863) -- Add support for multiple error response codes.[PR 11841](https://github.com/Azure/azure-sdk-for-js/) +- Add support for multiple error response codes. [PR 11841](https://github.com/Azure/azure-sdk-for-js/) +- Allow customizing serializer behavior via optional `SerialzierOptions` parameters. Particularly allow using a different `XML_CHARKEY` than the default `_` when parsing XML [PR 12065](https://github.com/Azure/azure-sdk-for-js/pull/12065) ## 1.1.9 (2020-09-30) diff --git a/sdk/core/core-http/review/core-http.api.md b/sdk/core/core-http/review/core-http.api.md index e33f8d30aaa5..163f82501fc3 100644 --- a/sdk/core/core-http/review/core-http.api.md +++ b/sdk/core/core-http/review/core-http.api.md @@ -187,10 +187,10 @@ export interface DeserializationOptions { } // @public -export function deserializationPolicy(deserializationContentTypes?: DeserializationContentTypes): RequestPolicyFactory; +export function deserializationPolicy(deserializationContentTypes?: DeserializationContentTypes, parsingOptions?: SerializerOptions): RequestPolicyFactory; // @public (undocumented) -export function deserializeResponseBody(jsonContentTypes: string[], xmlContentTypes: string[], response: HttpOperationResponse): Promise; +export function deserializeResponseBody(jsonContentTypes: string[], xmlContentTypes: string[], response: HttpOperationResponse, options?: SerializerOptions): Promise; // @public (undocumented) export interface DictionaryMapper extends BaseMapper { @@ -514,9 +514,7 @@ export interface ParameterValue { } // @public -export function parseXML(str: string, opts?: { - includeRoot?: boolean; -}): Promise; +export function parseXML(str: string, opts?: SerializerOptions): Promise; // @public export interface PipelineOptions { @@ -599,6 +597,7 @@ export interface RequestOptionsBase { }; onDownloadProgress?: (progress: TransferProgressEvent) => void; onUploadProgress?: (progress: TransferProgressEvent) => void; + serializerOptions?: SerializerOptions; shouldDeserialize?: boolean | ((response: HttpOperationResponse) => boolean); spanOptions?: SpanOptions; timeout?: number; @@ -728,18 +727,25 @@ export class Serializer { constructor(modelMappers?: { [key: string]: any; }, isXML?: boolean | undefined); - deserialize(mapper: Mapper, responseBody: any, objectName: string): any; + deserialize(mapper: Mapper, responseBody: any, objectName: string, options?: SerializerOptions): any; // (undocumented) readonly isXML?: boolean | undefined; // (undocumented) readonly modelMappers: { [key: string]: any; }; - 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 { + includeRoot?: boolean; + rootName?: string; + xmlCharKey?: string; +} + // @public export interface ServiceCallback { (err: Error | RestError | null, result?: TResult, request?: WebResourceLike, response?: HttpOperationResponse): void; @@ -785,9 +791,7 @@ export interface SimpleMapperType { } // @public -export function stringifyXML(obj: any, opts?: { - rootName?: string; -}): string; +export function stringifyXML(obj: any, opts?: SerializerOptions): string; // @public export function stripRequest(request: WebResourceLike): WebResourceLike; @@ -954,6 +958,12 @@ export interface WebResourceLike { withCredentials: boolean; } +// @public +export const XML_ATTRKEY = "$"; + +// @public +export const XML_CHARKEY = "_"; + // (No @packageDocumentation comment for this package) diff --git a/sdk/core/core-http/src/coreHttp.ts b/sdk/core/core-http/src/coreHttp.ts index 0189f881fc09..e9809d3f9205 100644 --- a/sdk/core/core-http/src/coreHttp.ts +++ b/sdk/core/core-http/src/coreHttp.ts @@ -125,3 +125,4 @@ export { TopicCredentials } from "./credentials/topicCredentials"; export { Authenticator } from "./credentials/credentials"; export { parseXML, stringifyXML } from "./util/xml"; +export { XML_ATTRKEY, XML_CHARKEY, SerializerOptions } from "./util/serializer.common"; diff --git a/sdk/core/core-http/src/policies/deserializationPolicy.ts b/sdk/core/core-http/src/policies/deserializationPolicy.ts index d6e64e640a24..b3e647ba668f 100644 --- a/sdk/core/core-http/src/policies/deserializationPolicy.ts +++ b/sdk/core/core-http/src/policies/deserializationPolicy.ts @@ -14,6 +14,7 @@ import { RequestPolicyFactory, RequestPolicyOptions } from "./requestPolicy"; +import { XML_CHARKEY, SerializerOptions } from "../util/serializer.common"; /** * Options to configure API response deserialization. @@ -49,11 +50,17 @@ export interface DeserializationContentTypes { * pass through the HTTP pipeline. */ export function deserializationPolicy( - deserializationContentTypes?: DeserializationContentTypes + deserializationContentTypes?: DeserializationContentTypes, + parsingOptions?: SerializerOptions ): RequestPolicyFactory { return { create: (nextPolicy: RequestPolicy, options: RequestPolicyOptions) => { - return new DeserializationPolicy(nextPolicy, deserializationContentTypes, options); + return new DeserializationPolicy( + nextPolicy, + options, + deserializationContentTypes, + parsingOptions + ); } }; } @@ -75,26 +82,29 @@ export const DefaultDeserializationOptions: DeserializationOptions = { export class DeserializationPolicy extends BaseRequestPolicy { public readonly jsonContentTypes: string[]; public readonly xmlContentTypes: string[]; + public readonly xmlCharKey: string; constructor( nextPolicy: RequestPolicy, - deserializationContentTypes: DeserializationContentTypes | undefined, - options: RequestPolicyOptions + requestPolicyOptions: RequestPolicyOptions, + deserializationContentTypes?: DeserializationContentTypes, + parsingOptions: SerializerOptions = {} ) { - super(nextPolicy, options); + super(nextPolicy, requestPolicyOptions); this.jsonContentTypes = (deserializationContentTypes && deserializationContentTypes.json) || defaultJsonContentTypes; this.xmlContentTypes = (deserializationContentTypes && deserializationContentTypes.xml) || defaultXmlContentTypes; + this.xmlCharKey = parsingOptions.xmlCharKey ?? XML_CHARKEY; } public async sendRequest(request: WebResourceLike): Promise { - return this._nextPolicy - .sendRequest(request) - .then((response: HttpOperationResponse) => - deserializeResponseBody(this.jsonContentTypes, this.xmlContentTypes, response) - ); + return this._nextPolicy.sendRequest(request).then((response: HttpOperationResponse) => + deserializeResponseBody(this.jsonContentTypes, this.xmlContentTypes, response, { + xmlCharKey: this.xmlCharKey + }) + ); } } @@ -137,74 +147,84 @@ function shouldDeserializeResponse(parsedResponse: HttpOperationResponse): boole export function deserializeResponseBody( jsonContentTypes: string[], xmlContentTypes: string[], - response: HttpOperationResponse + response: HttpOperationResponse, + options: SerializerOptions = {} ): Promise { - return parse(jsonContentTypes, xmlContentTypes, response).then((parsedResponse) => { - if (!shouldDeserializeResponse(parsedResponse)) { - return parsedResponse; - } + const updatedOptions: Required = { + rootName: options.rootName ?? "", + includeRoot: options.includeRoot ?? false, + xmlCharKey: options.xmlCharKey ?? XML_CHARKEY + }; + return parse(jsonContentTypes, xmlContentTypes, response, updatedOptions).then( + (parsedResponse) => { + if (!shouldDeserializeResponse(parsedResponse)) { + return parsedResponse; + } - const operationSpec = parsedResponse.request.operationSpec; - if (!operationSpec || !operationSpec.responses) { - return parsedResponse; - } + const operationSpec = parsedResponse.request.operationSpec; + if (!operationSpec || !operationSpec.responses) { + return parsedResponse; + } - const responseSpec = getOperationResponse(parsedResponse); + const responseSpec = getOperationResponse(parsedResponse); - const { error, shouldReturnResponse } = handleErrorResponse( - parsedResponse, - operationSpec, - responseSpec - ); - if (error) { - throw error; - } else if (shouldReturnResponse) { - return parsedResponse; - } + const { error, shouldReturnResponse } = handleErrorResponse( + parsedResponse, + operationSpec, + responseSpec + ); + if (error) { + throw error; + } else if (shouldReturnResponse) { + return parsedResponse; + } - // An operation response spec does exist for current status code, so - // use it to deserialize the response. - if (responseSpec) { - if (responseSpec.bodyMapper) { - let valueToDeserialize: any = parsedResponse.parsedBody; - if (operationSpec.isXML && responseSpec.bodyMapper.type.name === MapperType.Sequence) { - valueToDeserialize = - typeof valueToDeserialize === "object" - ? valueToDeserialize[responseSpec.bodyMapper.xmlElementName!] - : []; + // An operation response spec does exist for current status code, so + // use it to deserialize the response. + if (responseSpec) { + if (responseSpec.bodyMapper) { + let valueToDeserialize: any = parsedResponse.parsedBody; + if (operationSpec.isXML && responseSpec.bodyMapper.type.name === MapperType.Sequence) { + valueToDeserialize = + typeof valueToDeserialize === "object" + ? valueToDeserialize[responseSpec.bodyMapper.xmlElementName!] + : []; + } + try { + parsedResponse.parsedBody = operationSpec.serializer.deserialize( + responseSpec.bodyMapper, + valueToDeserialize, + "operationRes.parsedBody", + options + ); + } catch (error) { + const restError = new RestError( + `Error ${error} occurred in deserializing the responseBody - ${parsedResponse.bodyAsText}`, + undefined, + parsedResponse.status, + parsedResponse.request, + parsedResponse + ); + throw restError; + } + } else if (operationSpec.httpMethod === "HEAD") { + // head methods never have a body, but we return a boolean to indicate presence/absence of the resource + parsedResponse.parsedBody = response.status >= 200 && response.status < 300; } - try { - parsedResponse.parsedBody = operationSpec.serializer.deserialize( - responseSpec.bodyMapper, - valueToDeserialize, - "operationRes.parsedBody" - ); - } catch (error) { - const restError = new RestError( - `Error ${error} occurred in deserializing the responseBody - ${parsedResponse.bodyAsText}`, - undefined, - parsedResponse.status, - parsedResponse.request, - parsedResponse + + if (responseSpec.headersMapper) { + parsedResponse.parsedHeaders = operationSpec.serializer.deserialize( + responseSpec.headersMapper, + parsedResponse.headers.rawHeaders(), + "operationRes.parsedHeaders", + options ); - throw restError; } - } else if (operationSpec.httpMethod === "HEAD") { - // head methods never have a body, but we return a boolean to indicate presence/absence of the resource - parsedResponse.parsedBody = response.status >= 200 && response.status < 300; } - if (responseSpec.headersMapper) { - parsedResponse.parsedHeaders = operationSpec.serializer.deserialize( - responseSpec.headersMapper, - parsedResponse.headers.rawHeaders(), - "operationRes.parsedHeaders" - ); - } + return parsedResponse; } - - return parsedResponse; - }); + ); } function isOperationSpecEmpty(operationSpec: OperationSpec): boolean { @@ -300,7 +320,8 @@ function handleErrorResponse( function parse( jsonContentTypes: string[], xmlContentTypes: string[], - operationResponse: HttpOperationResponse + operationResponse: HttpOperationResponse, + opts: Required ): Promise { const errorHandler = (err: Error & { code: string }): Promise => { const msg = `Error "${err}" occurred while parsing the response body - ${operationResponse.bodyAsText}.`; @@ -330,7 +351,7 @@ function parse( resolve(operationResponse); }).catch(errorHandler); } else if (contentComponents.some((component) => xmlContentTypes.indexOf(component) !== -1)) { - return parseXML(text) + return parseXML(text, opts) .then((body) => { operationResponse.parsedBody = body; return operationResponse; diff --git a/sdk/core/core-http/src/serializer.ts b/sdk/core/core-http/src/serializer.ts index e4c74fc50efc..779f2788a382 100644 --- a/sdk/core/core-http/src/serializer.ts +++ b/sdk/core/core-http/src/serializer.ts @@ -4,6 +4,7 @@ import * as base64 from "./util/base64"; import * as utils from "./util/utils"; +import { XML_ATTRKEY, XML_CHARKEY, SerializerOptions } from "./util/serializer.common"; export class Serializer { constructor( @@ -85,9 +86,21 @@ export class Serializer { * * @param {string} objectName Name of the serialized object * + * @param {options} options additional options to deserialization + * * @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 = {} + ): any { + const updatedOptions: Required = { + rootName: options.rootName ?? "", + includeRoot: options.includeRoot ?? false, + xmlCharKey: options.xmlCharKey ?? XML_CHARKEY + }; let payload: any = {}; const mapperType = mapper.type.name as string; if (!objectName) { @@ -149,7 +162,8 @@ export class Serializer { mapper as SequenceMapper, object, objectName, - Boolean(this.isXML) + Boolean(this.isXML), + updatedOptions ); } else if (mapperType.match(/^Dictionary$/i) !== null) { payload = serializeDictionaryType( @@ -157,7 +171,8 @@ export class Serializer { mapper as DictionaryMapper, object, objectName, - Boolean(this.isXML) + Boolean(this.isXML), + updatedOptions ); } else if (mapperType.match(/^Composite$/i) !== null) { payload = serializeCompositeType( @@ -165,7 +180,8 @@ export class Serializer { mapper as CompositeMapper, object, objectName, - Boolean(this.isXML) + Boolean(this.isXML), + updatedOptions ); } } @@ -181,9 +197,21 @@ export class 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 = {} + ): any { + const updatedOptions: Required = { + rootName: options.rootName ?? "", + includeRoot: options.includeRoot ?? false, + xmlCharKey: options.xmlCharKey ?? XML_CHARKEY + }; if (responseBody == undefined) { if (this.isXML && mapper.type.name === "Sequence" && !mapper.xmlIsWrapped) { // Edge case for empty XML non-wrapped lists. xml2js can't distinguish @@ -205,16 +233,23 @@ export class 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.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]; } } @@ -242,13 +277,20 @@ export class 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 ); } } @@ -482,7 +524,8 @@ function serializeSequenceType( mapper: SequenceMapper, object: any, objectName: string, - isXml: boolean + isXml: boolean, + options: Required ): any[] { if (!Array.isArray(object)) { throw new Error(`${objectName} must be of type Array.`); @@ -496,16 +539,19 @@ 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.xmlCharKey] = serializedValue; + tempArray[i][XML_ATTRKEY] = { [xmlnsKey]: elementType.xmlNamespace }; } } else { tempArray[i] = serializedValue; @@ -519,7 +565,8 @@ function serializeDictionaryType( mapper: DictionaryMapper, object: any, objectName: string, - isXml: boolean + isXml: boolean, + options: Required ): { [key: string]: any } { if (typeof object !== "object") { throw new Error(`${objectName} must be of type object.`); @@ -533,9 +580,9 @@ 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 @@ -629,7 +676,8 @@ function serializeCompositeType( mapper: CompositeMapper, object: any, objectName: string, - isXml: boolean + isXml: boolean, + options: Required ): any { if (getPolymorphicDiscriminatorRecursively(serializer, mapper)) { mapper = getPolymorphicMapper(serializer, mapper, object, "clientName"); @@ -673,7 +721,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 !== "" @@ -693,17 +744,18 @@ function serializeCompositeType( const serializedValue = serializer.serialize( propertyMapper, toSerialize, - propertyObjectName + propertyObjectName, + options ); if (serializedValue !== undefined && propName != undefined) { - 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 { @@ -722,7 +774,8 @@ function serializeCompositeType( payload[clientPropName] = serializer.serialize( additionalPropertiesMapper, object[clientPropName], - objectName + '["' + clientPropName + '"]' + objectName + '["' + clientPropName + '"]', + options ); } } @@ -733,7 +786,12 @@ function serializeCompositeType( return object; } -function getXmlObjectValue(propertyMapper: Mapper, serializedValue: any, isXml: boolean) { +function getXmlObjectValue( + propertyMapper: Mapper, + serializedValue: any, + isXml: boolean, + options: Required +) { if (!isXml || !propertyMapper.xmlNamespace) { return serializedValue; } @@ -744,20 +802,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.xmlCharKey] = serializedValue; + result[XML_ATTRKEY] = xmlNamespace; + return result; } -function isSpecialXmlProperty(propertyName: string): boolean { - return ["$", "_"].includes(propertyName); +function isSpecialXmlProperty(propertyName: string, options: Required): boolean { + return [XML_ATTRKEY, options.xmlCharKey].includes(propertyName); } function deserializeCompositeType( serializer: Serializer, mapper: CompositeMapper, responseBody: any, - objectName: string + objectName: string, + options: Required ): any { if (getPolymorphicDiscriminatorRecursively(serializer, mapper)) { mapper = getPolymorphicMapper(serializer, mapper, responseBody, "serializedName"); @@ -785,7 +853,8 @@ function deserializeCompositeType( dictionary[headerKey.substring(headerCollectionPrefix.length)] = serializer.deserialize( (propertyMapper as DictionaryMapper).type.value, responseBody[headerKey], - propertyObjectName + propertyObjectName, + options ); } @@ -793,11 +862,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; @@ -818,10 +888,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 { @@ -856,12 +936,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; } @@ -885,7 +971,8 @@ function deserializeCompositeType( instance[responsePropName] = serializer.deserialize( additionalPropertiesMapper, responseBody[responsePropName], - objectName + '["' + responsePropName + '"]' + objectName + '["' + responsePropName + '"]', + options ); } } @@ -894,7 +981,7 @@ function deserializeCompositeType( if ( instance[key] === undefined && !handledPropertyNames.includes(key) && - !isSpecialXmlProperty(key) + !isSpecialXmlProperty(key, options) ) { instance[key] = responseBody[key]; } @@ -908,7 +995,8 @@ function deserializeDictionaryType( serializer: Serializer, mapper: DictionaryMapper, responseBody: any, - objectName: string + objectName: string, + options: Required ): { [key: string]: any } { const value = mapper.type.value; if (!value || typeof value !== "object") { @@ -920,7 +1008,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; } @@ -931,7 +1019,8 @@ function deserializeSequenceType( serializer: Serializer, mapper: SequenceMapper, responseBody: any, - objectName: string + objectName: string, + options: Required ): any[] { const element = mapper.type.element; if (!element || typeof element !== "object") { @@ -948,7 +1037,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-http/src/serviceClient.ts b/sdk/core/core-http/src/serviceClient.ts index 9e3140176e00..96042f864a9c 100644 --- a/sdk/core/core-http/src/serviceClient.ts +++ b/sdk/core/core-http/src/serviceClient.ts @@ -60,6 +60,7 @@ import { DefaultKeepAliveOptions, keepAlivePolicy } from "./policies/keepAlivePo import { tracingPolicy } from "./policies/tracingPolicy"; import { disableResponseDecompressionPolicy } from "./policies/disableResponseDecompressionPolicy"; import { ndJsonPolicy } from "./policies/ndJsonPolicy"; +import { XML_ATTRKEY, SerializerOptions, XML_CHARKEY } from "./util/serializer.common"; /** * Options to configure a proxy for outgoing requests (Node.js only). @@ -303,6 +304,7 @@ export class ServiceClient { operationArguments.options = undefined; } + const serializerOptions = operationArguments.options?.serializerOptions; const httpRequest: WebResourceLike = new WebResource(); let result: Promise; @@ -332,7 +334,8 @@ export class ServiceClient { urlParameterValue = operationSpec.serializer.serialize( urlParameter.mapper, urlParameterValue, - getPathStringFromParameter(urlParameter) + getPathStringFromParameter(urlParameter), + serializerOptions ); if (!urlParameter.skipEncoding) { urlParameterValue = encodeURIComponent(urlParameterValue); @@ -355,7 +358,8 @@ export class ServiceClient { queryParameterValue = operationSpec.serializer.serialize( queryParameter.mapper, queryParameterValue, - getPathStringFromParameter(queryParameter) + getPathStringFromParameter(queryParameter), + serializerOptions ); if ( queryParameter.collectionFormat !== undefined && @@ -427,7 +431,8 @@ export class ServiceClient { headerValue = operationSpec.serializer.serialize( headerParameter.mapper, headerValue, - getPathStringFromParameter(headerParameter) + getPathStringFromParameter(headerParameter), + serializerOptions ); const headerCollectionPrefix = (headerParameter.mapper as DictionaryMapper) .headerCollectionPrefix; @@ -530,6 +535,14 @@ export function serializeRequestBody( operationArguments: OperationArguments, operationSpec: OperationSpec ): void { + const serializerOptions = operationArguments.options?.serializerOptions ?? {}; + const updatedOptions: Required = { + rootName: serializerOptions.rootName ?? "", + includeRoot: serializerOptions.includeRoot ?? false, + xmlCharKey: serializerOptions.xmlCharKey ?? XML_CHARKEY + }; + + const xmlCharKey = serializerOptions.xmlCharKey; if (operationSpec.requestBody && operationSpec.requestBody.mapper) { httpRequest.body = getOperationArgumentValueFromParameter( serviceClient, @@ -557,7 +570,8 @@ export function serializeRequestBody( httpRequest.body = operationSpec.serializer.serialize( bodyMapper, httpRequest.body, - requestBodyParameterPathString + requestBodyParameterPathString, + updatedOptions ); const isStream = typeName === MapperType.Stream; @@ -568,7 +582,8 @@ export function serializeRequestBody( xmlNamespace, xmlnsKey, typeName, - httpRequest.body + httpRequest.body, + updatedOptions ); if (typeName === MapperType.Sequence) { httpRequest.body = stringifyXML( @@ -578,11 +593,15 @@ export function serializeRequestBody( xmlnsKey, xmlNamespace ), - { rootName: xmlName || serializedName } + { + rootName: xmlName || serializedName, + xmlCharKey + } ); } else if (!isStream) { httpRequest.body = stringifyXML(value, { - rootName: xmlName || serializedName + rootName: xmlName || serializedName, + xmlCharKey }); } } else if ( @@ -620,7 +639,8 @@ export function serializeRequestBody( httpRequest.formData[formDataParameterPropertyName] = operationSpec.serializer.serialize( formDataParameter.mapper, formDataParameterValue, - getPathStringFromParameter(formDataParameter) + getPathStringFromParameter(formDataParameter), + updatedOptions ); } } @@ -634,12 +654,16 @@ function getXmlValueWithNamespace( xmlNamespace: string | undefined, xmlnsKey: string, typeName: string, - serializedValue: any + serializedValue: any, + options: Required ): 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.xmlCharKey] = serializedValue; + result[XML_ATTRKEY] = { [xmlnsKey]: xmlNamespace }; + return result; } return serializedValue; @@ -840,6 +864,7 @@ export function getOperationArgumentValueFromParameterPath( if (typeof parameterPath === "string") { parameterPath = [parameterPath]; } + const serializerOptions = operationArguments.options?.serializerOptions; if (Array.isArray(parameterPath)) { if (parameterPath.length > 0) { if (parameterMapper.isConstant) { @@ -867,7 +892,7 @@ export function getOperationArgumentValueFromParameterPath( parameterPath, parameterMapper ); - serializer.serialize(parameterMapper, value, parameterPathString); + serializer.serialize(parameterMapper, value, parameterPathString, serializerOptions); } } else { if (parameterMapper.required) { @@ -891,7 +916,7 @@ export function getOperationArgumentValueFromParameterPath( propertyPath, propertyMapper ); - serializer.serialize(propertyMapper, propertyValue, propertyPathString); + serializer.serialize(propertyMapper, propertyValue, propertyPathString, serializerOptions); if (propertyValue !== undefined && propertyValue !== null) { if (!value) { value = {}; diff --git a/sdk/core/core-http/src/util/serializer.common.ts b/sdk/core/core-http/src/util/serializer.common.ts new file mode 100644 index 000000000000..209cee85cb15 --- /dev/null +++ b/sdk/core/core-http/src/util/serializer.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 SerializerOptions { + /** + * 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-http/src/util/utils.ts b/sdk/core/core-http/src/util/utils.ts index 20f7e062f425..0f2407a528dc 100644 --- a/sdk/core/core-http/src/util/utils.ts +++ b/sdk/core/core-http/src/util/utils.ts @@ -203,7 +203,7 @@ export function prepareXMLRootList( return { [elementName]: obj }; } - return { [elementName]: obj, $: {[xmlNamespaceKey]: xmlNamespace} }; + return { [elementName]: obj, $: { [xmlNamespaceKey]: xmlNamespace } }; } /** diff --git a/sdk/core/core-http/src/util/xml.browser.ts b/sdk/core/core-http/src/util/xml.browser.ts index d6d1f4109e5c..d7ac746c182d 100644 --- a/sdk/core/core-http/src/util/xml.browser.ts +++ b/sdk/core/core-http/src/util/xml.browser.ts @@ -1,20 +1,27 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { XML_ATTRKEY, XML_CHARKEY, SerializerOptions } from "./serializer.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: SerializerOptions = {}): 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); @@ -52,7 +59,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; @@ -67,15 +74,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 = ""; @@ -88,7 +95,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])) { @@ -105,9 +112,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: SerializerOptions = {}): 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) ); @@ -123,7 +134,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 || @@ -137,7 +148,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); } } @@ -145,14 +156,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-http/src/util/xml.ts b/sdk/core/core-http/src/util/xml.ts index e1d877793857..79cd51eb3319 100644 --- a/sdk/core/core-http/src/util/xml.ts +++ b/sdk/core/core-http/src/util/xml.ts @@ -2,23 +2,23 @@ // Licensed under the MIT license. import * as xml2js from "xml2js"; +import { XML_ATTRKEY, XML_CHARKEY, SerializerOptions } from "./serializer.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: " ", @@ -66,8 +66,9 @@ xml2jsBuilderSettings.renderOpts = { * @param opts Options that govern the parsing 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: SerializerOptions = {}): 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: SerializerOptions = {}): 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-http/src/webResource.ts b/sdk/core/core-http/src/webResource.ts index 5602469c9447..73fe9470edb7 100644 --- a/sdk/core/core-http/src/webResource.ts +++ b/sdk/core/core-http/src/webResource.ts @@ -10,6 +10,7 @@ import { OperationResponse } from "./operationResponse"; import { ProxySettings } from "./serviceClient"; import { AbortSignalLike } from "@azure/abort-controller"; import { SpanOptions } from "@azure/core-tracing"; +import { SerializerOptions } from "./util/serializer.common"; export type HttpMethods = | "GET" @@ -667,4 +668,9 @@ export interface RequestOptionsBase { spanOptions?: SpanOptions; [key: string]: any; + + /** + * Options to override XML parsing/building behavior. + */ + serializerOptions?: SerializerOptions; } diff --git a/sdk/core/core-http/test/policies/deserializationPolicyTests.ts b/sdk/core/core-http/test/policies/deserializationPolicyTests.ts index a5302f3eaaf1..2af8d3b8ad7b 100644 --- a/sdk/core/core-http/test/policies/deserializationPolicyTests.ts +++ b/sdk/core/core-http/test/policies/deserializationPolicyTests.ts @@ -27,11 +27,7 @@ describe("deserializationPolicy", function() { }; it(`should not modify a request that has no request body mapper`, async function() { - const deserializationPolicy = new DeserializationPolicy( - mockPolicy, - {}, - new RequestPolicyOptions() - ); + const deserializationPolicy = new DeserializationPolicy(mockPolicy, new RequestPolicyOptions()); const request = createRequest(); request.body = "hello there!"; @@ -40,6 +36,31 @@ describe("deserializationPolicy", function() { assert.strictEqual(request.body, "hello there!"); }); + it(`should deserialize underscore xml element with custom xml char key`, async function() { + const request = createRequest(); + const mockClient: HttpClient = { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: new HttpHeaders({ "Content-Type": "application/xml" }), + bodyAsText: "v<_>underscore" + }) + }; + const deserializationPolicy = new DeserializationPolicy( + mockClient, + new RequestPolicyOptions(), + {}, + { xmlCharKey: "#" } + ); + + const response = await deserializationPolicy.sendRequest(request); + assert.deepStrictEqual(response.parsedBody, { + h: "v", + _: "underscore" + }); + }); + it("should parse a JSON response body", async function() { const request: WebResource = createRequest(); const mockClient: HttpClient = { @@ -412,6 +433,66 @@ describe("deserializationPolicy", function() { assert.strictEqual(deserializedResponse.parsedHeaders, undefined); }); + it(`with custom xml char key`, async function() { + const response: HttpOperationResponse = { + request: createRequest(), + status: 200, + headers: new HttpHeaders({ + "content-type": "application/xml" + }), + bodyAsText: `3` + }; + + const deserializedResponse: HttpOperationResponse = await deserializeResponseBody( + [], + ["application/xml"], + response, + { + xmlCharKey: "#" + } + ); + + assert(deserializedResponse); + assert.deepEqual(deserializedResponse.parsedBody, { + apples: { + $: { + taste: "good" + }, + "#": "3" + } + }); + }); + + it(`with custom xml char key for underscore xml element`, async function() { + const response: HttpOperationResponse = { + request: createRequest(), + status: 200, + headers: new HttpHeaders({ + "content-type": "application/xml" + }), + bodyAsText: `v<_>underscore` + }; + + const deserializedResponse: HttpOperationResponse = await deserializeResponseBody( + [], + ["application/xml"], + response, + { + xmlCharKey: "#" + } + ); + + assert(deserializedResponse); + assert.strictEqual( + deserializedResponse.bodyAsText, + `v<_>underscore` + ); + assert.deepEqual(deserializedResponse.parsedBody, { + h: "v", + _: "underscore" + }); + }); + it(`with service bus response body, application/atom+xml content-type, and no operationSpec`, async function() { const response: HttpOperationResponse = { request: createRequest(), diff --git a/sdk/core/core-http/test/serviceClientTests.ts b/sdk/core/core-http/test/serviceClientTests.ts index 39218633cb25..7dd4471a78d1 100644 --- a/sdk/core/core-http/test/serviceClientTests.ts +++ b/sdk/core/core-http/test/serviceClientTests.ts @@ -1259,6 +1259,52 @@ describe("ServiceClient", function() { assert.strictEqual(httpRequest.body, "body value"); }); + it("should serialize an XML request body with custom xml char key", () => { + const httpRequest = new WebResource(); + serializeRequestBody( + new ServiceClient(), + httpRequest, + { + requestBody: { + "#": "pound value" + }, + options: { + serializerOptions: { + 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: new Serializer(undefined, true /** isXML */), + isXML: true + } + ); + assert.strictEqual( + httpRequest.body, + `pound value` + ); + }); + it("should serialize a string send to a text/plain endpoint as just a string", () => { const httpRequest = new WebResource(); serializeRequestBody( diff --git a/sdk/core/core-http/test/xmlTests.ts b/sdk/core/core-http/test/xmlTests.ts index 0d92c19b40ed..cffbb208a7b7 100644 --- a/sdk/core/core-http/test/xmlTests.ts +++ b/sdk/core/core-http/test/xmlTests.ts @@ -115,6 +115,15 @@ describe("XML serializer", function() { } }); }); + + it("with underscore element", async function() { + const str = "v<_>underscore"; + const parsed = await parseXML(str, { xmlCharKey: "#" }); + assert.deepStrictEqual(parsed, { + h: "v", + _: "underscore" + }); + }); }); describe("parseXML(string) with root", function() {