diff --git a/sdk/core/core-client/package.json b/sdk/core/core-client/package.json index 144f52398b01..8ac727f02217 100644 --- a/sdk/core/core-client/package.json +++ b/sdk/core/core-client/package.json @@ -6,6 +6,10 @@ "sdk-type": "client", "main": "dist/index.js", "module": "dist-esm/src/index.js", + "browser": { + "./dist-esm/src/base64.js": "./dist-esm/src/base64.browser.js", + "./dist-esm/src/url.js": "./dist-esm/src/url.browser.js" + }, "types": "types/latest/core-client.d.ts", "typesVersions": { "<3.6": { @@ -73,6 +77,10 @@ "sideEffects": false, "prettier": "@azure/eslint-plugin-azure-sdk/prettier.json", "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.1.3", + "@azure/core-https": "1.0.0-preview.1", + "@azure/core-tracing": "1.0.0-preview.8", "@opentelemetry/api": "^0.6.1", "tslib": "^2.0.0" }, @@ -86,6 +94,7 @@ "@types/chai": "^4.1.6", "@types/mocha": "^7.0.2", "@types/node": "^8.0.0", + "@types/sinon": "^9.0.4", "@typescript-eslint/eslint-plugin": "^2.0.0", "@typescript-eslint/parser": "^2.0.0", "@azure/eslint-plugin-azure-sdk": "^3.0.0", @@ -117,6 +126,7 @@ "rollup-plugin-sourcemaps": "^0.4.2", "rollup-plugin-terser": "^5.1.1", "rollup-plugin-visualizer": "^4.0.4", + "sinon": "^9.0.2", "typescript": "~3.9.3", "util": "^0.12.1" } diff --git a/sdk/core/core-client/review/core-client.api.md b/sdk/core/core-client/review/core-client.api.md index a8292bf6593c..1681e51a5672 100644 --- a/sdk/core/core-client/review/core-client.api.md +++ b/sdk/core/core-client/review/core-client.api.md @@ -4,8 +4,340 @@ ```ts +import { AbortSignalLike } from '@azure/abort-controller'; +import { HttpMethods } from '@azure/core-https'; +import { HttpsClient } from '@azure/core-https'; +import { OperationTracingOptions } from '@azure/core-tracing'; +import { Pipeline } from '@azure/core-https'; +import { PipelinePolicy } from '@azure/core-https'; +import { PipelineRequest } from '@azure/core-https'; +import { PipelineResponse } from '@azure/core-https'; +import { TokenCredential } from '@azure/core-auth'; +import { TransferProgressEvent } from '@azure/core-https'; + +// @public (undocumented) +export interface BaseMapper { + // (undocumented) + constraints?: MapperConstraints; + // (undocumented) + defaultValue?: any; + // (undocumented) + isConstant?: boolean; + // (undocumented) + nullable?: boolean; + // (undocumented) + readOnly?: boolean; + // (undocumented) + required?: boolean; + // (undocumented) + serializedName?: string; + // (undocumented) + type: MapperType; + // (undocumented) + xmlElementName?: string; + // (undocumented) + xmlIsAttribute?: boolean; + // (undocumented) + xmlIsWrapped?: boolean; + // (undocumented) + xmlName?: string; +} + +// @public (undocumented) +export interface CompositeMapper extends BaseMapper { + // (undocumented) + type: CompositeMapperType; +} + +// @public (undocumented) +export interface CompositeMapperType { + // (undocumented) + additionalProperties?: Mapper; + // (undocumented) + className?: string; + // (undocumented) + modelProperties?: { + [propertyName: string]: Mapper; + }; + // (undocumented) + name: "Composite"; + // (undocumented) + polymorphicDiscriminator?: PolymorphicDiscriminator; + // (undocumented) + uberParent?: string; +} + +// @public +export function createSerializer(modelMappers?: { + [key: string]: any; +}, isXML?: boolean): Serializer; + +// @public +export interface DeserializationContentTypes { + json?: string[]; + xml?: string[]; +} + +// @public +export function deserializationPolicy(options?: DeserializationPolicyOptions): PipelinePolicy; + +// @public +export const deserializationPolicyName = "deserializationPolicy"; + +// @public +export interface DeserializationPolicyOptions { + expectedContentTypes?: DeserializationContentTypes; + parseXML?: (str: string, opts?: { + includeRoot?: boolean; + }) => Promise; +} + +// @public (undocumented) +export interface DictionaryMapper extends BaseMapper { + // (undocumented) + headerCollectionPrefix?: string; + // (undocumented) + type: DictionaryMapperType; +} + +// @public (undocumented) +export interface DictionaryMapperType { + // (undocumented) + name: "Dictionary"; + // (undocumented) + value: Mapper; +} + +// @public (undocumented) +export interface EnumMapper extends BaseMapper { + // (undocumented) + type: EnumMapperType; +} + +// @public (undocumented) +export interface EnumMapperType { + // (undocumented) + allowedValues: any[]; + // (undocumented) + name: "Enum"; +} + +// @public +export interface FullOperationResponse extends PipelineResponse { + parsedBody?: any; + parsedHeaders?: { + [key: string]: unknown; + }; + request: OperationRequest; +} + +// @public (undocumented) +export type Mapper = BaseMapper | CompositeMapper | SequenceMapper | DictionaryMapper | EnumMapper; + +// @public (undocumented) +export interface MapperConstraints { + // (undocumented) + ExclusiveMaximum?: number; + // (undocumented) + ExclusiveMinimum?: number; + // (undocumented) + InclusiveMaximum?: number; + // (undocumented) + InclusiveMinimum?: number; + // (undocumented) + MaxItems?: number; + // (undocumented) + MaxLength?: number; + // (undocumented) + MinItems?: number; + // (undocumented) + MinLength?: number; + // (undocumented) + MultipleOf?: number; + // (undocumented) + Pattern?: RegExp; + // (undocumented) + UniqueItems?: true; +} + +// @public (undocumented) +export type MapperType = SimpleMapperType | CompositeMapperType | SequenceMapperType | DictionaryMapperType | EnumMapperType; + +// @public +export const MapperTypeNames: { + readonly Base64Url: "Base64Url"; + readonly Boolean: "Boolean"; + readonly ByteArray: "ByteArray"; + readonly Composite: "Composite"; + readonly Date: "Date"; + readonly DateTime: "DateTime"; + readonly DateTimeRfc1123: "DateTimeRfc1123"; + readonly Dictionary: "Dictionary"; + readonly Enum: "Enum"; + readonly Number: "Number"; + readonly Object: "Object"; + readonly Sequence: "Sequence"; + readonly String: "String"; + readonly Stream: "Stream"; + readonly TimeSpan: "TimeSpan"; + readonly UnixTime: "UnixTime"; +}; + +// @public +export interface OperationArguments { + [parameterName: string]: unknown; + options?: OperationOptions; +} + +// @public +export interface OperationOptions { + abortSignal?: AbortSignalLike; + requestOptions?: OperationRequestOptions; + tracingOptions?: OperationTracingOptions; +} + +// @public +export interface OperationParameter { + mapper: Mapper; + parameterPath: ParameterPath; +} + +// @public +export interface OperationQueryParameter extends OperationParameter { + collectionFormat?: QueryCollectionFormat; + skipEncoding?: boolean; +} + +// @public +export type OperationRequest = PipelineRequest; + +// @public +export interface OperationRequestInfo { + operationResponseGetter?: (operationSpec: OperationSpec, response: PipelineResponse) => undefined | OperationResponseMap; + operationSpec?: OperationSpec; + shouldDeserialize?: boolean | ((response: PipelineResponse) => boolean); +} + +// @public +export interface OperationRequestOptions { + customHeaders?: { + [key: string]: string; + }; + onDownloadProgress?: (progress: TransferProgressEvent) => void; + onUploadProgress?: (progress: TransferProgressEvent) => void; + shouldDeserialize?: boolean | ((response: PipelineResponse) => boolean); + timeout?: number; +} + +// @public +export interface OperationResponse { + // (undocumented) + [key: string]: any; + _response: FullOperationResponse; +} + +// @public +export interface OperationResponseMap { + bodyMapper?: Mapper; + headersMapper?: Mapper; +} + +// @public +export interface OperationSpec { + readonly baseUrl?: string; + readonly contentType?: string; + readonly formDataParameters?: ReadonlyArray; + readonly headerParameters?: ReadonlyArray; + readonly httpMethod: HttpMethods; + readonly isXML?: boolean; + readonly mediaType?: "json" | "xml" | "form" | "binary" | "multipart" | "text" | "unknown" | string; + readonly path?: string; + readonly queryParameters?: ReadonlyArray; + readonly requestBody?: OperationParameter; + readonly responses: { + [responseCode: string]: OperationResponseMap; + }; + readonly serializer: Serializer; + readonly urlParameters?: ReadonlyArray; +} + +// @public +export interface OperationURLParameter extends OperationParameter { + skipEncoding?: boolean; +} + +// @public +export type ParameterPath = string | string[] | { + [propertyName: string]: ParameterPath; +}; + +// @public (undocumented) +export interface PolymorphicDiscriminator { + // (undocumented) + [key: string]: string; + // (undocumented) + clientName: string; + // (undocumented) + serializedName: string; +} + +// @public +export type QueryCollectionFormat = "CSV" | "SSV" | "TSV" | "Pipes" | "Multi"; + +// @public (undocumented) +export interface SequenceMapper extends BaseMapper { + // (undocumented) + type: SequenceMapperType; +} + +// @public (undocumented) +export interface SequenceMapperType { + // (undocumented) + element: Mapper; + // (undocumented) + name: "Sequence"; +} + +// @public +export interface Serializer { + // (undocumented) + deserialize(mapper: Mapper, responseBody: any, objectName: string): any; + // (undocumented) + readonly isXML: boolean; + // (undocumented) + readonly modelMappers: { + [key: string]: any; + }; + // (undocumented) + serialize(mapper: Mapper, object: any, objectName?: string): any; + // (undocumented) + validateConstraints(mapper: Mapper, value: any, objectName: string): void; +} + +// @public +export class ServiceClient { + constructor(options?: ServiceClientOptions); + sendOperationRequest(operationArguments: OperationArguments, operationSpec: OperationSpec): Promise; + sendRequest(request: PipelineRequest): Promise; + } + +// @public +export interface ServiceClientOptions { + baseUri?: string; + credential?: TokenCredential; + httpsClient?: HttpsClient; + pipeline?: Pipeline; + requestContentType?: string; + stringifyXML?: (obj: any, opts?: { + rootName?: string; + }) => string; +} + // @public (undocumented) -export function helloWorld(): string; +export interface SimpleMapperType { + // (undocumented) + name: "Base64Url" | "Boolean" | "ByteArray" | "Date" | "DateTime" | "DateTimeRfc1123" | "Object" | "Stream" | "String" | "TimeSpan" | "UnixTime" | "Uuid" | "Number" | "any"; +} // (No @packageDocumentation comment for this package) diff --git a/sdk/core/core-client/rollup.base.config.js b/sdk/core/core-client/rollup.base.config.js index 434aa86d87d2..7f8dc473b25b 100644 --- a/sdk/core/core-client/rollup.base.config.js +++ b/sdk/core/core-client/rollup.base.config.js @@ -14,7 +14,7 @@ const input = "dist-esm/src/index.js"; const production = process.env.NODE_ENV === "production"; export function nodeConfig(test = false) { - const externalNodeBuiltins = []; + const externalNodeBuiltins = ["url"]; const baseConfig = { input: input, external: depNames.concat(externalNodeBuiltins), diff --git a/sdk/core/core-client/rollup.test.config.js b/sdk/core/core-client/rollup.test.config.js index 925a4421a53e..fc843df2b5a3 100644 --- a/sdk/core/core-client/rollup.test.config.js +++ b/sdk/core/core-client/rollup.test.config.js @@ -1,3 +1,13 @@ import * as base from "./rollup.base.config"; -export default [base.nodeConfig(true), base.browserConfig(true)]; +const inputs = []; + +if (!process.env.ONLY_BROWSER) { + inputs.push(base.nodeConfig(true)); +} + +if (!process.env.ONLY_NODE) { + inputs.push(base.browserConfig(true)); +} + +export default inputs; diff --git a/sdk/core/core-client/src/base64.browser.ts b/sdk/core/core-client/src/base64.browser.ts new file mode 100644 index 000000000000..66252c17764e --- /dev/null +++ b/sdk/core/core-client/src/base64.browser.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// eslint-disable-next-line @azure/azure-sdk/ts-no-namespaces +declare global { + // stub these out for the browser + function btoa(input: string): string; + function atob(input: string): string; +} + +/** + * Encodes a string in base64 format. + * @param value the string to encode + */ +export function encodeString(value: string): string { + return btoa(value); +} + +/** + * Encodes a byte array in base64 format. + * @param value the Uint8Aray to encode + */ +export function encodeByteArray(value: Uint8Array): string { + let str = ""; + for (let i = 0; i < value.length; i++) { + str += String.fromCharCode(value[i]); + } + return btoa(str); +} + +/** + * Decodes a base64 string into a byte array. + * @param value the base64 string to decode + */ +export function decodeString(value: string): Uint8Array { + const byteString = atob(value); + const arr = new Uint8Array(byteString.length); + for (let i = 0; i < byteString.length; i++) { + arr[i] = byteString.charCodeAt(i); + } + return arr; +} diff --git a/sdk/core/core-client/src/base64.ts b/sdk/core/core-client/src/base64.ts new file mode 100644 index 000000000000..5a58a1993079 --- /dev/null +++ b/sdk/core/core-client/src/base64.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Encodes a string in base64 format. + * @param value the string to encode + * @internal @ignore + */ +export function encodeString(value: string): string { + return Buffer.from(value).toString("base64"); +} + +/** + * Encodes a byte array in base64 format. + * @param value the Uint8Aray to encode + * @internal @ignore + */ +export function encodeByteArray(value: Uint8Array): string { + // Buffer.from accepts | -- the TypeScript definition is off here + // https://nodejs.org/api/buffer.html#buffer_class_method_buffer_from_arraybuffer_byteoffset_length + const bufferValue = value instanceof Buffer ? value : Buffer.from(value.buffer as ArrayBuffer); + return bufferValue.toString("base64"); +} + +/** + * Decodes a base64 string into a byte array. + * @param value the base64 string to decode + * @internal @ignore + */ +export function decodeString(value: string): Uint8Array { + return Buffer.from(value, "base64"); +} diff --git a/sdk/core/core-client/src/deserializationPolicy.ts b/sdk/core/core-client/src/deserializationPolicy.ts new file mode 100644 index 000000000000..3a3739daecab --- /dev/null +++ b/sdk/core/core-client/src/deserializationPolicy.ts @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + PipelineResponse, + PipelineRequest, + SendRequest, + PipelinePolicy, + RestError +} from "@azure/core-https"; +import { OperationRequest, OperationResponseMap, FullOperationResponse } from "./interfaces"; +import { MapperTypeNames } from "./serializer"; +import { isStreamOperation } from "./interfaceHelpers"; + +const defaultJsonContentTypes = ["application/json", "text/json"]; +const defaultXmlContentTypes = ["application/xml", "application/atom+xml"]; + +/** + * The programmatic identifier of the deserializationPolicy. + */ +export const deserializationPolicyName = "deserializationPolicy"; + +/** + * Options to configure API response deserialization. + */ +export interface DeserializationPolicyOptions { + /** + * Configures the expected content types for the deserialization of + * JSON and XML response bodies. + */ + expectedContentTypes?: DeserializationContentTypes; + + /** + * A function that is able to parse XML. Required for XML support. + */ + parseXML?: (str: string, opts?: { includeRoot?: boolean }) => Promise; +} + +/** + * The content-types that will indicate that an operation response should be deserialized in a + * particular way. + */ +export interface DeserializationContentTypes { + /** + * The content-types that indicate that an operation response should be deserialized as JSON. + * Defaults to [ "application/json", "text/json" ]. + */ + json?: string[]; + + /** + * The content-types that indicate that an operation response should be deserialized as XML. + * Defaults to [ "application/xml", "application/atom+xml" ]. + */ + xml?: string[]; +} + +/** + * This policy handles parsing out responses according to OperationSpecs on the request. + */ +export function deserializationPolicy(options: DeserializationPolicyOptions = {}): PipelinePolicy { + const jsonContentTypes = options.expectedContentTypes?.json ?? defaultJsonContentTypes; + const xmlContentTypes = options.expectedContentTypes?.xml ?? defaultXmlContentTypes; + const parseXML = options.parseXML; + + return { + name: deserializationPolicyName, + async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + const response = await next(request); + return deserializeResponseBody(jsonContentTypes, xmlContentTypes, response, parseXML); + } + }; +} + +function getOperationResponseMap( + parsedResponse: PipelineResponse +): undefined | OperationResponseMap { + let result: OperationResponseMap | undefined; + const request: OperationRequest = parsedResponse.request; + const operationSpec = request.additionalInfo?.operationSpec; + if (operationSpec) { + if (!request.additionalInfo?.operationResponseGetter) { + result = operationSpec.responses[parsedResponse.status]; + } else { + result = request.additionalInfo?.operationResponseGetter(operationSpec, parsedResponse); + } + } + return result; +} + +function shouldDeserializeResponse(parsedResponse: PipelineResponse): boolean { + const request: OperationRequest = parsedResponse.request; + const shouldDeserialize = request.additionalInfo?.shouldDeserialize; + let result: boolean; + if (shouldDeserialize === undefined) { + result = true; + } else if (typeof shouldDeserialize === "boolean") { + result = shouldDeserialize; + } else { + result = shouldDeserialize(parsedResponse); + } + return result; +} + +async function deserializeResponseBody( + jsonContentTypes: string[], + xmlContentTypes: string[], + response: PipelineResponse, + parseXML?: (str: string, opts?: { includeRoot?: boolean }) => Promise +): Promise { + const parsedResponse = await parse(jsonContentTypes, xmlContentTypes, response, parseXML); + if (!shouldDeserializeResponse(parsedResponse)) { + return parsedResponse; + } + + const operationSpec = parsedResponse.request.additionalInfo?.operationSpec; + if (!operationSpec || !operationSpec.responses) { + return parsedResponse; + } + + const responseSpec = getOperationResponseMap(parsedResponse); + const expectedStatusCodes = Object.keys(operationSpec.responses); + const hasNoExpectedStatusCodes = + expectedStatusCodes.length === 0 || + (expectedStatusCodes.length === 1 && expectedStatusCodes[0] === "default"); + const isExpectedStatusCode: boolean = hasNoExpectedStatusCodes + ? 200 <= parsedResponse.status && parsedResponse.status < 300 + : !!responseSpec; + + // There is no operation response spec for current status code. + // So, treat it as an error case and use the default response spec to deserialize the response. + if (!isExpectedStatusCode) { + const defaultResponseSpec = operationSpec.responses.default; + if (!defaultResponseSpec) { + return parsedResponse; + } + + const defaultBodyMapper = defaultResponseSpec.bodyMapper; + const defaultHeadersMapper = defaultResponseSpec.headersMapper; + + const initialErrorMessage = isStreamOperation(operationSpec) + ? `Unexpected status code: ${parsedResponse.status}` + : (parsedResponse.bodyAsText as string); + + const error = new RestError(initialErrorMessage, { + statusCode: parsedResponse.status, + request: parsedResponse.request, + response: parsedResponse + }); + + try { + // If error response has a body, try to extract error code & message from it + // Then try to deserialize it using default body mapper + if (parsedResponse.parsedBody) { + const parsedBody = parsedResponse.parsedBody; + const internalError: any = parsedBody.error || parsedBody; + error.code = internalError.code; + if (internalError.message) { + error.message = internalError.message; + } + + if (defaultBodyMapper) { + let valueToDeserialize: any = parsedBody; + if (operationSpec.isXML && defaultBodyMapper.type.name === MapperTypeNames.Sequence) { + valueToDeserialize = []; + const elementName = defaultBodyMapper.xmlElementName; + if (typeof parsedBody === "object" && elementName) { + valueToDeserialize = parsedBody[elementName]; + } + } + if (error.response) { + const errorResponse: FullOperationResponse = error.response; + errorResponse.parsedBody = operationSpec.serializer.deserialize( + defaultBodyMapper, + valueToDeserialize, + "error.response.parsedBody" + ); + } + } + } + + // If error response has headers, try to deserialize it using default header mapper + if (parsedResponse.headers && defaultHeadersMapper && error.response) { + const errorResponse: FullOperationResponse = error.response; + errorResponse.parsedHeaders = operationSpec.serializer.deserialize( + defaultHeadersMapper, + parsedResponse.headers.toJSON(), + "operationRes.parsedHeaders" + ); + } + } catch (defaultError) { + error.message = `Error "${defaultError.message}" occurred in deserializing the responseBody - "${parsedResponse.bodyAsText}" for the default response.`; + } + throw error; + } + + // 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 === MapperTypeNames.Sequence) { + valueToDeserialize = + typeof valueToDeserialize === "object" + ? valueToDeserialize[responseSpec.bodyMapper.xmlElementName!] + : []; + } + 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}`, + { + statusCode: parsedResponse.status, + request: parsedResponse.request, + response: 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; + } + + if (responseSpec.headersMapper) { + parsedResponse.parsedHeaders = operationSpec.serializer.deserialize( + responseSpec.headersMapper, + parsedResponse.headers.toJSON(), + "operationRes.parsedHeaders" + ); + } + } + + return parsedResponse; +} + +async function parse( + jsonContentTypes: string[], + xmlContentTypes: string[], + operationResponse: FullOperationResponse, + parseXML?: (str: string, opts?: { includeRoot?: boolean }) => Promise +): Promise { + if (!operationResponse.request.streamResponseBody && operationResponse.bodyAsText) { + const text = operationResponse.bodyAsText; + const contentType: string = operationResponse.headers.get("Content-Type") || ""; + const contentComponents: string[] = !contentType + ? [] + : contentType.split(";").map((component) => component.toLowerCase()); + + try { + if ( + contentComponents.length === 0 || + contentComponents.some((component) => jsonContentTypes.indexOf(component) !== -1) + ) { + operationResponse.parsedBody = JSON.parse(text); + return operationResponse; + } else if (contentComponents.some((component) => xmlContentTypes.indexOf(component) !== -1)) { + if (!parseXML) { + throw new Error("Parsing XML not supported."); + } + const body = await parseXML(text); + operationResponse.parsedBody = body; + return operationResponse; + } + } catch (err) { + const msg = `Error "${err}" occurred while parsing the response body - ${operationResponse.bodyAsText}.`; + const errCode = err.code || RestError.PARSE_ERROR; + const e = new RestError(msg, { + code: errCode, + statusCode: operationResponse.status, + request: operationResponse.request, + response: operationResponse + }); + throw e; + } + } + + return operationResponse; +} diff --git a/sdk/core/core-client/src/dom.d.ts b/sdk/core/core-client/src/dom.d.ts new file mode 100644 index 000000000000..88bcf1442b2f --- /dev/null +++ b/sdk/core/core-client/src/dom.d.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/// diff --git a/sdk/core/core-client/src/index.ts b/sdk/core/core-client/src/index.ts index 2656b82da865..84b920d308c0 100644 --- a/sdk/core/core-client/src/index.ts +++ b/sdk/core/core-client/src/index.ts @@ -1,6 +1,42 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export function helloWorld(): string { - return "Hello world!"; -} +export { createSerializer, MapperTypeNames } from "./serializer"; +export { ServiceClient, ServiceClientOptions } from "./serviceClient"; +export { + OperationSpec, + OperationArguments, + OperationOptions, + OperationResponseMap, + OperationParameter, + OperationQueryParameter, + OperationURLParameter, + Serializer, + BaseMapper, + Mapper, + MapperType, + SimpleMapperType, + EnumMapper, + EnumMapperType, + SequenceMapper, + SequenceMapperType, + DictionaryMapper, + DictionaryMapperType, + CompositeMapper, + CompositeMapperType, + MapperConstraints, + OperationRequest, + OperationRequestOptions, + OperationRequestInfo, + QueryCollectionFormat, + ParameterPath, + OperationResponse, + FullOperationResponse, + PolymorphicDiscriminator +} from "./interfaces"; +export { + deserializationPolicy, + deserializationPolicyName, + DeserializationPolicyOptions, + DeserializationContentTypes +} from "./deserializationPolicy"; diff --git a/sdk/core/core-client/src/interfaceHelpers.ts b/sdk/core/core-client/src/interfaceHelpers.ts new file mode 100644 index 000000000000..6e8647d87f43 --- /dev/null +++ b/sdk/core/core-client/src/interfaceHelpers.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { MapperTypeNames } from "./serializer"; +import { OperationSpec, OperationParameter } from "./interfaces"; + +/** + * @internal @ignore + */ +export function isStreamOperation(operationSpec: OperationSpec): boolean { + for (const statusCode in operationSpec.responses) { + const operationResponse = operationSpec.responses[statusCode]; + if ( + operationResponse.bodyMapper && + operationResponse.bodyMapper.type.name === MapperTypeNames.Stream + ) { + return true; + } + } + return false; +} + +/** + * Get the path to this parameter's value as a dotted string (a.b.c). + * @param parameter The parameter to get the path string for. + * @returns The path to this parameter's value as a dotted string. + * @internal @ignore + */ +export function getPathStringFromParameter(parameter: OperationParameter): string { + const { parameterPath, mapper } = parameter; + let result: string; + if (typeof parameterPath === "string") { + result = parameterPath; + } else if (Array.isArray(parameterPath)) { + result = parameterPath.join("."); + } else { + result = mapper.serializedName!; + } + return result; +} diff --git a/sdk/core/core-client/src/interfaces.ts b/sdk/core/core-client/src/interfaces.ts new file mode 100644 index 000000000000..dd38df47b315 --- /dev/null +++ b/sdk/core/core-client/src/interfaces.ts @@ -0,0 +1,420 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AbortSignalLike } from "@azure/abort-controller"; +import { OperationTracingOptions } from "@azure/core-tracing"; +import { + HttpMethods, + PipelineResponse, + TransferProgressEvent, + PipelineRequest +} from "@azure/core-https"; + +/** + * This interface extends a generic `PipelineRequest` to include + * additional metadata about the request. + */ +export type OperationRequest = PipelineRequest; + +/** + * Metadata that is used to properly parse a response. + */ +export interface OperationRequestInfo { + /** + * Used to parse the response. + */ + operationSpec?: OperationSpec; + + /** + * A function that returns the proper OperationResponseMap for the given OperationSpec and + * PipelineResponse combination. If this is undefined, then a simple status code lookup will + * be used. + */ + operationResponseGetter?: ( + operationSpec: OperationSpec, + response: PipelineResponse + ) => undefined | OperationResponseMap; + + /** + * Whether or not the PipelineResponse should be deserialized. Defaults to true. + */ + shouldDeserialize?: boolean | ((response: PipelineResponse) => boolean); +} + +/** + * The base options type for all operations. + */ +export interface OperationOptions { + /** + * The signal which can be used to abort requests. + */ + abortSignal?: AbortSignalLike; + /** + * Options used when creating and sending HTTP requests for this operation. + */ + requestOptions?: OperationRequestOptions; + /** + * Options used when tracing is enabled. + */ + tracingOptions?: OperationTracingOptions; +} + +/** + * Options used when creating and sending HTTP requests for this operation. + */ +export interface OperationRequestOptions { + /** + * @property {object} [customHeaders] User defined custom request headers that + * will be applied before the request is sent. + */ + customHeaders?: { [key: string]: string }; + + /** + * The number of milliseconds a request can take before automatically being terminated. + */ + timeout?: number; + + /** + * Callback which fires upon upload progress. + */ + onUploadProgress?: (progress: TransferProgressEvent) => void; + + /** + * Callback which fires upon download progress. + */ + onDownloadProgress?: (progress: TransferProgressEvent) => void; + /** + * Whether or not the HttpOperationResponse should be deserialized. If this is undefined, then the + * HttpOperationResponse should be deserialized. + */ + shouldDeserialize?: boolean | ((response: PipelineResponse) => boolean); +} + +/** + * A collection of properties that apply to a single invocation of an operation. + */ +export interface OperationArguments { + /** + * The parameters that were passed to the operation method. + */ + [parameterName: string]: unknown; + + /** + * The optional arugments that are provided to an operation. + */ + options?: OperationOptions; +} + +/** + * The format that will be used to join an array of values together for a query parameter value. + */ +export type QueryCollectionFormat = "CSV" | "SSV" | "TSV" | "Pipes" | "Multi"; + +/** + * Encodes how to reach a particular property on an object. + */ +export type ParameterPath = string | string[] | { [propertyName: string]: ParameterPath }; + +/** + * A common interface that all Operation parameter's extend. + */ +export interface OperationParameter { + /** + * The path to this parameter's value in OperationArguments or the object that contains paths for + * each property's value in OperationArguments. + */ + parameterPath: ParameterPath; + + /** + * The mapper that defines how to validate and serialize this parameter's value. + */ + mapper: Mapper; +} + +/** + * A parameter for an operation that will be substituted into the operation's request URL. + */ +export interface OperationURLParameter extends OperationParameter { + /** + * Whether or not to skip encoding the URL parameter's value before adding it to the URL. + */ + skipEncoding?: boolean; +} + +/** + * A parameter for an operation that will be added as a query parameter to the operation's HTTP + * request. + */ +export interface OperationQueryParameter extends OperationParameter { + /** + * Whether or not to skip encoding the query parameter's value before adding it to the URL. + */ + skipEncoding?: boolean; + + /** + * If this query parameter's value is a collection, what type of format should the value be + * converted to. + */ + collectionFormat?: QueryCollectionFormat; +} + +/** + * An OperationResponse that can be returned from an operation request for a single status code. + */ +export interface OperationResponseMap { + /** + * The mapper that will be used to deserialize the response headers. + */ + headersMapper?: Mapper; + + /** + * The mapper that will be used to deserialize the response body. + */ + bodyMapper?: Mapper; +} + +/** + * A specification that defines an operation. + */ +export interface OperationSpec { + /** + * The serializer to use in this operation. + */ + readonly serializer: Serializer; + + /** + * The HTTP method that should be used by requests for this operation. + */ + readonly httpMethod: HttpMethods; + + /** + * The URL that was provided in the service's specification. This will still have all of the URL + * template variables in it. If this is not provided when the OperationSpec is created, then it + * will be populated by a "baseUri" property on the ServiceClient. + */ + readonly baseUrl?: string; + + /** + * The fixed path for this operation's URL. This will still have all of the URL template variables + * in it. + */ + readonly path?: string; + + /** + * The content type of the request body. This value will be used as the "Content-Type" header if + * it is provided. + */ + readonly contentType?: string; + + /** + * The media type of the request body. + * This value can be used to aide in serialization if it is provided. + */ + readonly mediaType?: + | "json" + | "xml" + | "form" + | "binary" + | "multipart" + | "text" + | "unknown" + | string; + /** + * The parameter that will be used to construct the HTTP request's body. + */ + readonly requestBody?: OperationParameter; + + /** + * Whether or not this operation uses XML request and response bodies. + */ + readonly isXML?: boolean; + + /** + * The parameters to the operation method that will be substituted into the constructed URL. + */ + readonly urlParameters?: ReadonlyArray; + + /** + * The parameters to the operation method that will be added to the constructed URL's query. + */ + readonly queryParameters?: ReadonlyArray; + + /** + * The parameters to the operation method that will be converted to headers on the operation's + * HTTP request. + */ + readonly headerParameters?: ReadonlyArray; + + /** + * The parameters to the operation method that will be used to create a formdata body for the + * operation's HTTP request. + */ + readonly formDataParameters?: ReadonlyArray; + + /** + * The different types of responses that this operation can return based on what status code is + * returned. + */ + readonly responses: { [responseCode: string]: OperationResponseMap }; +} + +/** + * Wrapper object for http request and response. Deserialized object is stored in + * the `parsedBody` property when the response body is received in JSON or XML. + */ +export interface FullOperationResponse extends PipelineResponse { + /** + * The parsed HTTP response headers. + */ + parsedHeaders?: { [key: string]: unknown }; + + /** + * The response body as parsed JSON or XML. + */ + parsedBody?: any; + + /** + * The request that generated the response. + */ + request: OperationRequest; +} + +/** + * The processed and flattened response to an operation call. + * Contains merged properties of the parsed body and headers. + */ +export interface OperationResponse { + /** + * The underlying HTTP response containing both raw and deserialized response data. + */ + _response: FullOperationResponse; + + [key: string]: any; +} + +/** + * Used to map raw response objects to final shapes. + * Mostly useful for unpacking/packing Dates and other encoded types that + * are not intrinsic to JSON. + */ +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; +} + +export interface MapperConstraints { + InclusiveMaximum?: number; + ExclusiveMaximum?: number; + InclusiveMinimum?: number; + ExclusiveMinimum?: number; + MaxLength?: number; + MinLength?: number; + Pattern?: RegExp; + MaxItems?: number; + MinItems?: number; + UniqueItems?: true; + MultipleOf?: number; +} + +export type MapperType = + | SimpleMapperType + | CompositeMapperType + | SequenceMapperType + | DictionaryMapperType + | EnumMapperType; + +export interface SimpleMapperType { + name: + | "Base64Url" + | "Boolean" + | "ByteArray" + | "Date" + | "DateTime" + | "DateTimeRfc1123" + | "Object" + | "Stream" + | "String" + | "TimeSpan" + | "UnixTime" + | "Uuid" + | "Number" + | "any"; +} + +export interface CompositeMapperType { + name: "Composite"; + + // Only one of the two below properties should be present. + // Use className to reference another type definition, + // and use modelProperties/additionalProperties when the reference to the other type has been resolved. + className?: string; + + modelProperties?: { [propertyName: string]: Mapper }; + additionalProperties?: Mapper; + + uberParent?: string; + polymorphicDiscriminator?: PolymorphicDiscriminator; +} + +export interface SequenceMapperType { + name: "Sequence"; + element: Mapper; +} + +export interface DictionaryMapperType { + name: "Dictionary"; + value: Mapper; +} + +export interface EnumMapperType { + name: "Enum"; + allowedValues: any[]; +} + +export interface BaseMapper { + xmlName?: string; + xmlIsAttribute?: boolean; + xmlElementName?: string; + xmlIsWrapped?: boolean; + readOnly?: boolean; + isConstant?: boolean; + required?: boolean; + nullable?: boolean; + serializedName?: string; + type: MapperType; + defaultValue?: any; + constraints?: MapperConstraints; +} + +export type Mapper = BaseMapper | CompositeMapper | SequenceMapper | DictionaryMapper | EnumMapper; + +export interface PolymorphicDiscriminator { + serializedName: string; + clientName: string; + [key: string]: string; +} + +export interface CompositeMapper extends BaseMapper { + type: CompositeMapperType; +} + +export interface SequenceMapper extends BaseMapper { + type: SequenceMapperType; +} + +export interface DictionaryMapper extends BaseMapper { + type: DictionaryMapperType; + headerCollectionPrefix?: string; +} + +export interface EnumMapper extends BaseMapper { + type: EnumMapperType; +} + +export interface UrlParameterValue { + value: string; + skipUrlEncoding: boolean; +} diff --git a/sdk/core/core-client/src/operationHelpers.ts b/sdk/core/core-client/src/operationHelpers.ts new file mode 100644 index 000000000000..20ef062f5506 --- /dev/null +++ b/sdk/core/core-client/src/operationHelpers.ts @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + OperationArguments, + OperationParameter, + Mapper, + CompositeMapper, + ParameterPath +} from "./interfaces"; + +/** + * @internal @ignore + * Retrieves the value to use for a given operation argument + * @param operationArguments The arguments passed from the generated client + * @param parameter The parameter description + * @param fallbackObject If something isn't found in the arguments bag, look here. + * Generally used to look at the service client properties. + */ +export function getOperationArgumentValueFromParameter( + operationArguments: OperationArguments, + parameter: OperationParameter, + fallbackObject?: { [parameterName: string]: any } +): any { + let parameterPath = parameter.parameterPath; + const parameterMapper = parameter.mapper; + let value: any; + if (typeof parameterPath === "string") { + parameterPath = [parameterPath]; + } + if (Array.isArray(parameterPath)) { + if (parameterPath.length > 0) { + if (parameterMapper.isConstant) { + value = parameterMapper.defaultValue; + } else { + let propertySearchResult = getPropertyFromParameterPath(operationArguments, parameterPath); + + if (!propertySearchResult.propertyFound && fallbackObject) { + propertySearchResult = getPropertyFromParameterPath(fallbackObject, parameterPath); + } + + let useDefaultValue = false; + if (!propertySearchResult.propertyFound) { + useDefaultValue = + parameterMapper.required || + (parameterPath[0] === "options" && parameterPath.length === 2); + } + value = useDefaultValue ? parameterMapper.defaultValue : propertySearchResult.propertyValue; + } + } + } else { + if (parameterMapper.required) { + value = {}; + } + + for (const propertyName in parameterPath) { + const propertyMapper: Mapper = (parameterMapper as CompositeMapper).type.modelProperties![ + propertyName + ]; + const propertyPath: ParameterPath = parameterPath[propertyName]; + const propertyValue: any = getOperationArgumentValueFromParameter( + operationArguments, + { + parameterPath: propertyPath, + mapper: propertyMapper + }, + fallbackObject + ); + if (propertyValue !== undefined) { + if (!value) { + value = {}; + } + value[propertyName] = propertyValue; + } + } + } + return value; +} + +interface PropertySearchResult { + propertyValue?: any; + propertyFound: boolean; +} + +function getPropertyFromParameterPath( + parent: { [parameterName: string]: any }, + parameterPath: string[] +): PropertySearchResult { + const result: PropertySearchResult = { propertyFound: false }; + let i = 0; + for (; i < parameterPath.length; ++i) { + const parameterPathPart: string = parameterPath[i]; + // Make sure to check inherited properties too, so don't use hasOwnProperty(). + if (parent && parameterPathPart in parent) { + parent = parent[parameterPathPart]; + } else { + break; + } + } + if (i === parameterPath.length) { + result.propertyValue = parent; + result.propertyFound = true; + } + return result; +} diff --git a/sdk/core/core-client/src/serializer.ts b/sdk/core/core-client/src/serializer.ts new file mode 100644 index 000000000000..d63a83efe684 --- /dev/null +++ b/sdk/core/core-client/src/serializer.ts @@ -0,0 +1,938 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { isDuration, isValidUuid } from "./utils"; +import * as base64 from "./base64"; +import { + Serializer, + Mapper, + MapperConstraints, + DictionaryMapper, + SequenceMapper, + CompositeMapper, + PolymorphicDiscriminator, + EnumMapper +} from "./interfaces"; + +class SerializerImpl implements Serializer { + constructor( + public readonly modelMappers: { [key: string]: any } = {}, + public readonly isXML: boolean = false + ) {} + + validateConstraints(mapper: Mapper, value: any, objectName: string): void { + const failValidation = ( + constraintName: keyof MapperConstraints, + constraintValue: any + ): never => { + throw new Error( + `"${objectName}" with value "${value}" should satisfy the constraint "${constraintName}": ${constraintValue}.` + ); + }; + if (mapper.constraints && value !== undefined && value !== null) { + const { + ExclusiveMaximum, + ExclusiveMinimum, + InclusiveMaximum, + InclusiveMinimum, + MaxItems, + MaxLength, + MinItems, + MinLength, + MultipleOf, + Pattern, + UniqueItems + } = mapper.constraints; + if (ExclusiveMaximum !== undefined && value >= ExclusiveMaximum) { + failValidation("ExclusiveMaximum", ExclusiveMaximum); + } + if (ExclusiveMinimum !== undefined && value <= ExclusiveMinimum) { + failValidation("ExclusiveMinimum", ExclusiveMinimum); + } + if (InclusiveMaximum !== undefined && value > InclusiveMaximum) { + failValidation("InclusiveMaximum", InclusiveMaximum); + } + if (InclusiveMinimum !== undefined && value < InclusiveMinimum) { + failValidation("InclusiveMinimum", InclusiveMinimum); + } + if (MaxItems !== undefined && value.length > MaxItems) { + failValidation("MaxItems", MaxItems); + } + if (MaxLength !== undefined && value.length > MaxLength) { + failValidation("MaxLength", MaxLength); + } + if (MinItems !== undefined && value.length < MinItems) { + failValidation("MinItems", MinItems); + } + if (MinLength !== undefined && value.length < MinLength) { + failValidation("MinLength", MinLength); + } + if (MultipleOf !== undefined && value % MultipleOf !== 0) { + failValidation("MultipleOf", MultipleOf); + } + if (Pattern) { + const pattern: RegExp = typeof Pattern === "string" ? new RegExp(Pattern) : Pattern; + if (typeof value !== "string" || value.match(pattern) === null) { + failValidation("Pattern", Pattern); + } + } + if ( + UniqueItems && + value.some((item: any, i: number, ar: Array) => ar.indexOf(item) !== i) + ) { + failValidation("UniqueItems", UniqueItems); + } + } + } + + /** + * Serialize the given object based on its metadata defined in the mapper + * + * @param {Mapper} mapper The mapper which defines the metadata of the serializable object + * + * @param {object|string|Array|number|boolean|Date|stream} object A valid Javascript object to be serialized + * + * @param {string} objectName Name of the serialized object + * + * @returns {object|string|Array|number|boolean|Date|stream} A valid serialized Javascript object + */ + serialize(mapper: Mapper, object: any, objectName?: string): any { + let payload: any = {}; + const mapperType = mapper.type.name as string; + if (!objectName) { + objectName = mapper.serializedName!; + } + if (mapperType.match(/^Sequence$/i) !== null) { + payload = []; + } + + if (mapper.isConstant) { + object = mapper.defaultValue; + } + + // This table of allowed values should help explain + // the mapper.required and mapper.nullable properties. + // X means "neither undefined or null are allowed". + // || required + // || true | false + // nullable || ========================== + // true || null | undefined/null + // false || X | undefined + // undefined || X | undefined/null + + const { required, nullable } = mapper; + + if (required && nullable && object === undefined) { + throw new Error(`${objectName} cannot be undefined.`); + } + if (required && !nullable && (object === undefined || object === null)) { + throw new Error(`${objectName} cannot be null or undefined.`); + } + if (!required && nullable === false && object === null) { + throw new Error(`${objectName} cannot be null.`); + } + + if (object === undefined || object === null) { + payload = object; + } else { + // Validate Constraints if any + this.validateConstraints(mapper, object, objectName); + if (mapperType.match(/^any$/i) !== null) { + payload = object; + } else if (mapperType.match(/^(Number|String|Boolean|Object|Stream|Uuid)$/i) !== null) { + payload = serializeBasicTypes(mapperType, objectName, object); + } else if (mapperType.match(/^Enum$/i) !== null) { + const enumMapper = mapper as EnumMapper; + payload = serializeEnumType(objectName, enumMapper.type.allowedValues, object); + } else if ( + mapperType.match(/^(Date|DateTime|TimeSpan|DateTimeRfc1123|UnixTime)$/i) !== null + ) { + payload = serializeDateTypes(mapperType, object, objectName); + } else if (mapperType.match(/^ByteArray$/i) !== null) { + payload = serializeByteArrayType(objectName, object); + } else if (mapperType.match(/^Base64Url$/i) !== null) { + payload = serializeBase64UrlType(objectName, object); + } else if (mapperType.match(/^Sequence$/i) !== null) { + payload = serializeSequenceType(this, mapper as SequenceMapper, object, objectName); + } else if (mapperType.match(/^Dictionary$/i) !== null) { + payload = serializeDictionaryType(this, mapper as DictionaryMapper, object, objectName); + } else if (mapperType.match(/^Composite$/i) !== null) { + payload = serializeCompositeType(this, mapper as CompositeMapper, object, objectName); + } + } + return payload; + } + + /** + * Deserialize the given object based on its metadata defined in the mapper + * + * @param {object} mapper The mapper which defines the metadata of the serializable object + * + * @param {object|string|Array|number|boolean|Date|stream} responseBody A valid Javascript entity to be deserialized + * + * @param {string} objectName Name of the deserialized object + * + * @returns {object|string|Array|number|boolean|Date|stream} A valid deserialized Javascript object + */ + deserialize(mapper: Mapper, responseBody: any, objectName: string): any { + 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 + // between the list being empty versus being missing, + // so let's do the more user-friendly thing and return an empty list. + responseBody = []; + } + // specifically check for undefined as default value can be a falsey value `0, "", false, null` + if (mapper.defaultValue !== undefined) { + responseBody = mapper.defaultValue; + } + return responseBody; + } + + let payload: any; + const mapperType = mapper.type.name; + if (!objectName) { + objectName = mapper.serializedName!; + } + + if (mapperType.match(/^Composite$/i) !== null) { + payload = deserializeCompositeType(this, mapper as CompositeMapper, responseBody, objectName); + } else { + if (this.isXML) { + /** + * 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. + */ + if (responseBody["$"] !== undefined && responseBody["_"] !== undefined) { + responseBody = responseBody["_"]; + } + } + + if (mapperType.match(/^Number$/i) !== null) { + payload = parseFloat(responseBody); + if (isNaN(payload)) { + payload = responseBody; + } + } else if (mapperType.match(/^Boolean$/i) !== null) { + if (responseBody === "true") { + payload = true; + } else if (responseBody === "false") { + payload = false; + } else { + payload = responseBody; + } + } else if (mapperType.match(/^(String|Enum|Object|Stream|Uuid|TimeSpan|any)$/i) !== null) { + payload = responseBody; + } else if (mapperType.match(/^(Date|DateTime|DateTimeRfc1123)$/i) !== null) { + payload = new Date(responseBody); + } else if (mapperType.match(/^UnixTime$/i) !== null) { + payload = unixTimeToDate(responseBody); + } else if (mapperType.match(/^ByteArray$/i) !== null) { + payload = base64.decodeString(responseBody); + } 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); + } else if (mapperType.match(/^Dictionary$/i) !== null) { + payload = deserializeDictionaryType( + this, + mapper as DictionaryMapper, + responseBody, + objectName + ); + } + } + + if (mapper.isConstant) { + payload = mapper.defaultValue; + } + + return payload; + } +} + +/** + * Method that creates and returns a Serializer. + * @param modelMappers Known models to map + * @param isXML If XML shuold be supported + */ +export function createSerializer( + modelMappers: { [key: string]: any } = {}, + isXML: boolean = false +): Serializer { + return new SerializerImpl(modelMappers, isXML); +} + +function trimEnd(str: string, ch: string): string { + let len = str.length; + while (len - 1 >= 0 && str[len - 1] === ch) { + --len; + } + return str.substr(0, len); +} + +function bufferToBase64Url(buffer: Uint8Array): string | undefined { + if (!buffer) { + return undefined; + } + if (!(buffer instanceof Uint8Array)) { + throw new Error(`Please provide an input of type Uint8Array for converting to Base64Url.`); + } + // Uint8Array to Base64. + const str = base64.encodeByteArray(buffer); + // Base64 to Base64Url. + return trimEnd(str, "=") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function base64UrlToByteArray(str: string): Uint8Array | undefined { + if (!str) { + return undefined; + } + if (str && typeof str.valueOf() !== "string") { + throw new Error("Please provide an input of type string for converting to Uint8Array"); + } + // Base64Url to Base64. + str = str.replace(/-/g, "+").replace(/_/g, "/"); + // Base64 to Uint8Array. + return base64.decodeString(str); +} + +function splitSerializeName(prop: string | undefined): string[] { + const classes: string[] = []; + let partialclass = ""; + if (prop) { + const subwords = prop.split("."); + + for (const item of subwords) { + if (item.charAt(item.length - 1) === "\\") { + partialclass += item.substr(0, item.length - 1) + "."; + } else { + partialclass += item; + classes.push(partialclass); + partialclass = ""; + } + } + } + + return classes; +} + +function dateToUnixTime(d: string | Date): number | undefined { + if (!d) { + return undefined; + } + + if (typeof d.valueOf() === "string") { + d = new Date(d as string); + } + return Math.floor((d as Date).getTime() / 1000); +} + +function unixTimeToDate(n: number): Date | undefined { + if (!n) { + return undefined; + } + return new Date(n * 1000); +} + +function serializeBasicTypes(typeName: string, objectName: string, value: any): any { + if (value !== null && value !== undefined) { + if (typeName.match(/^Number$/i) !== null) { + if (typeof value !== "number") { + throw new Error(`${objectName} with value ${value} must be of type number.`); + } + } else if (typeName.match(/^String$/i) !== null) { + if (typeof value.valueOf() !== "string") { + throw new Error(`${objectName} with value "${value}" must be of type string.`); + } + } else if (typeName.match(/^Uuid$/i) !== null) { + if (!(typeof value.valueOf() === "string" && isValidUuid(value))) { + throw new Error( + `${objectName} with value "${value}" must be of type string and a valid uuid.` + ); + } + } else if (typeName.match(/^Boolean$/i) !== null) { + if (typeof value !== "boolean") { + throw new Error(`${objectName} with value ${value} must be of type boolean.`); + } + } else if (typeName.match(/^Stream$/i) !== null) { + const objectType = typeof value; + if ( + objectType !== "string" && + objectType !== "function" && + !(value instanceof ArrayBuffer) && + !ArrayBuffer.isView(value) && + !(value?.constructor?.name === "Blob") + ) { + throw new Error( + `${objectName} must be a string, Blob, ArrayBuffer, ArrayBufferView, or a function returning NodeJS.ReadableStream.` + ); + } + } + } + return value; +} + +function serializeEnumType(objectName: string, allowedValues: Array, value: any): any { + if (!allowedValues) { + throw new Error( + `Please provide a set of allowedValues to validate ${objectName} as an Enum Type.` + ); + } + const isPresent = allowedValues.some((item) => { + if (typeof item.valueOf() === "string") { + return item.toLowerCase() === value.toLowerCase(); + } + return item === value; + }); + if (!isPresent) { + throw new Error( + `${value} is not a valid value for ${objectName}. The valid values are: ${JSON.stringify( + allowedValues + )}.` + ); + } + return value; +} + +function serializeByteArrayType(objectName: string, value: any): any { + if (value !== undefined && value !== null) { + if (!(value instanceof Uint8Array)) { + throw new Error(`${objectName} must be of type Uint8Array.`); + } + value = base64.encodeByteArray(value); + } + return value; +} + +function serializeBase64UrlType(objectName: string, value: any): any { + if (value !== undefined && value !== null) { + if (!(value instanceof Uint8Array)) { + throw new Error(`${objectName} must be of type Uint8Array.`); + } + value = bufferToBase64Url(value); + } + return value; +} + +function serializeDateTypes(typeName: string, value: any, objectName: string): any { + if (value !== undefined && value !== null) { + if (typeName.match(/^Date$/i) !== null) { + if ( + !( + value instanceof Date || + (typeof value.valueOf() === "string" && !isNaN(Date.parse(value))) + ) + ) { + throw new Error(`${objectName} must be an instanceof Date or a string in ISO8601 format.`); + } + value = + value instanceof Date + ? value.toISOString().substring(0, 10) + : new Date(value).toISOString().substring(0, 10); + } else if (typeName.match(/^DateTime$/i) !== null) { + if ( + !( + value instanceof Date || + (typeof value.valueOf() === "string" && !isNaN(Date.parse(value))) + ) + ) { + throw new Error(`${objectName} must be an instanceof Date or a string in ISO8601 format.`); + } + value = value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + } else if (typeName.match(/^DateTimeRfc1123$/i) !== null) { + if ( + !( + value instanceof Date || + (typeof value.valueOf() === "string" && !isNaN(Date.parse(value))) + ) + ) { + throw new Error(`${objectName} must be an instanceof Date or a string in RFC-1123 format.`); + } + value = value instanceof Date ? value.toUTCString() : new Date(value).toUTCString(); + } else if (typeName.match(/^UnixTime$/i) !== null) { + if ( + !( + value instanceof Date || + (typeof value.valueOf() === "string" && !isNaN(Date.parse(value))) + ) + ) { + throw new Error( + `${objectName} must be an instanceof Date or a string in RFC-1123/ISO8601 format ` + + `for it to be serialized in UnixTime/Epoch format.` + ); + } + value = dateToUnixTime(value); + } else if (typeName.match(/^TimeSpan$/i) !== null) { + if (!isDuration(value)) { + throw new Error( + `${objectName} must be a string in ISO 8601 format. Instead was "${value}".` + ); + } + } + } + return value; +} + +function serializeSequenceType( + serializer: Serializer, + mapper: SequenceMapper, + object: any, + objectName: string +): any { + if (!Array.isArray(object)) { + throw new Error(`${objectName} must be of type Array.`); + } + const elementType = mapper.type.element; + if (!elementType || typeof elementType !== "object") { + throw new Error( + `element" metadata for an Array must be defined in the ` + + `mapper and it must of type "object" in ${objectName}.` + ); + } + const tempArray = []; + for (let i = 0; i < object.length; i++) { + tempArray[i] = serializer.serialize(elementType, object[i], objectName); + } + return tempArray; +} + +function serializeDictionaryType( + serializer: Serializer, + mapper: DictionaryMapper, + object: any, + objectName: string +): any { + if (typeof object !== "object") { + throw new Error(`${objectName} must be of type object.`); + } + const valueType = mapper.type.value; + if (!valueType || typeof valueType !== "object") { + throw new Error( + `"value" metadata for a Dictionary must be defined in the ` + + `mapper and it must of type "object" in ${objectName}.` + ); + } + const tempDictionary: { [key: string]: any } = {}; + for (const key of Object.keys(object)) { + tempDictionary[key] = serializer.serialize(valueType, object[key], objectName + "." + key); + } + return tempDictionary; +} + +/** + * Resolves a composite mapper's modelProperties. + * @param serializer the serializer containing the entire set of mappers + * @param mapper the composite mapper to resolve + */ +function resolveModelProperties( + serializer: Serializer, + mapper: CompositeMapper, + objectName: string +): { [propertyName: string]: Mapper } { + let modelProps = mapper.type.modelProperties; + if (!modelProps) { + const className = mapper.type.className; + if (!className) { + throw new Error( + `Class name for model "${objectName}" is not provided in the mapper "${JSON.stringify( + mapper, + undefined, + 2 + )}".` + ); + } + + const modelMapper = serializer.modelMappers[className]; + if (!modelMapper) { + throw new Error(`mapper() cannot be null or undefined for model "${className}".`); + } + modelProps = modelMapper.type.modelProperties; + if (!modelProps) { + throw new Error( + `modelProperties cannot be null or undefined in the ` + + `mapper "${JSON.stringify( + modelMapper + )}" of type "${className}" for object "${objectName}".` + ); + } + } + + return modelProps; +} + +function serializeCompositeType( + serializer: Serializer, + mapper: CompositeMapper, + object: any, + objectName: string +): any { + if (getPolymorphicDiscriminatorRecursively(serializer, mapper)) { + mapper = getPolymorphicMapper(serializer, mapper, object, "clientName"); + } + + if (object !== undefined && object !== null) { + const payload: any = {}; + const modelProps = resolveModelProperties(serializer, mapper, objectName); + for (const key of Object.keys(modelProps)) { + const propertyMapper = modelProps[key]; + if (propertyMapper.readOnly) { + continue; + } + + let propName: string | undefined; + let parentObject: any = payload; + if (serializer.isXML) { + if (propertyMapper.xmlIsWrapped) { + propName = propertyMapper.xmlName; + } else { + propName = propertyMapper.xmlElementName || propertyMapper.xmlName; + } + } else { + const paths = splitSerializeName(propertyMapper.serializedName!); + propName = paths.pop(); + + for (const pathName of paths) { + const childObject = parentObject[pathName]; + if ( + (childObject === undefined || childObject === null) && + ((object[key] !== undefined && object[key] !== null) || + propertyMapper.defaultValue !== undefined) + ) { + parentObject[pathName] = {}; + } + parentObject = parentObject[pathName]; + } + } + + if (parentObject !== undefined && parentObject !== null) { + const propertyObjectName = + propertyMapper.serializedName !== "" + ? objectName + "." + propertyMapper.serializedName + : objectName; + + let toSerialize = object[key]; + const polymorphicDiscriminator = getPolymorphicDiscriminatorRecursively(serializer, mapper); + if ( + polymorphicDiscriminator && + polymorphicDiscriminator.clientName === key && + (toSerialize === undefined || toSerialize === null) + ) { + toSerialize = mapper.serializedName; + } + + const serializedValue = serializer.serialize( + propertyMapper, + toSerialize, + propertyObjectName + ); + if (serializedValue !== undefined && propName !== undefined && propName !== null) { + if (propertyMapper.xmlIsAttribute) { + // $ 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; + } else if (propertyMapper.xmlIsWrapped) { + parentObject[propName] = { [propertyMapper.xmlElementName!]: serializedValue }; + } else { + parentObject[propName] = serializedValue; + } + } + } + } + + const additionalPropertiesMapper = mapper.type.additionalProperties; + if (additionalPropertiesMapper) { + const propNames = Object.keys(modelProps); + for (const clientPropName in object) { + const isAdditionalProperty = propNames.every((pn) => pn !== clientPropName); + if (isAdditionalProperty) { + payload[clientPropName] = serializer.serialize( + additionalPropertiesMapper, + object[clientPropName], + objectName + '["' + clientPropName + '"]' + ); + } + } + } + + return payload; + } + return object; +} + +function isSpecialXmlProperty(propertyName: string): boolean { + return ["$", "_"].includes(propertyName); +} + +function deserializeCompositeType( + serializer: Serializer, + mapper: CompositeMapper, + responseBody: any, + objectName: string +): any { + if (getPolymorphicDiscriminatorRecursively(serializer, mapper)) { + mapper = getPolymorphicMapper(serializer, mapper, responseBody, "serializedName"); + } + + const modelProps = resolveModelProperties(serializer, mapper, objectName); + let instance: { [key: string]: any } = {}; + const handledPropertyNames: string[] = []; + + for (const key of Object.keys(modelProps)) { + const propertyMapper = modelProps[key]; + const paths = splitSerializeName(modelProps[key].serializedName!); + handledPropertyNames.push(paths[0]); + const { serializedName, xmlName, xmlElementName } = propertyMapper; + let propertyObjectName = objectName; + if (serializedName !== "" && serializedName !== undefined) { + propertyObjectName = objectName + "." + serializedName; + } + + const headerCollectionPrefix = (propertyMapper as DictionaryMapper).headerCollectionPrefix; + if (headerCollectionPrefix) { + const dictionary: any = {}; + for (const headerKey of Object.keys(responseBody)) { + if (headerKey.startsWith(headerCollectionPrefix)) { + dictionary[headerKey.substring(headerCollectionPrefix.length)] = serializer.deserialize( + (propertyMapper as DictionaryMapper).type.value, + responseBody[headerKey], + propertyObjectName + ); + } + + handledPropertyNames.push(headerKey); + } + instance[key] = dictionary; + } else if (serializer.isXML) { + if (propertyMapper.xmlIsAttribute && responseBody.$) { + instance[key] = serializer.deserialize( + propertyMapper, + responseBody.$[xmlName!], + propertyObjectName + ); + } else { + const propertyName = xmlElementName || xmlName || serializedName; + let unwrappedProperty = responseBody[propertyName!]; + if (propertyMapper.xmlIsWrapped) { + unwrappedProperty = responseBody[xmlName!]; + unwrappedProperty = unwrappedProperty && unwrappedProperty[xmlElementName!]; + + const isEmptyWrappedList = unwrappedProperty === undefined; + if (isEmptyWrappedList) { + unwrappedProperty = []; + } + } + instance[key] = serializer.deserialize( + propertyMapper, + unwrappedProperty, + propertyObjectName + ); + } + } else { + // deserialize the property if it is present in the provided responseBody instance + let propertyInstance; + let res = responseBody; + // traversing the object step by step. + for (const item of paths) { + if (!res) break; + res = res[item]; + } + propertyInstance = res; + const polymorphicDiscriminator = mapper.type.polymorphicDiscriminator; + // checking that the model property name (key)(ex: "fishtype") and the + // clientName of the polymorphicDiscriminator {metadata} (ex: "fishtype") + // instead of the serializedName of the polymorphicDiscriminator (ex: "fish.type") + // is a better approach. The generator is not consistent with escaping '\.' in the + // serializedName of the property (ex: "fish\.type") that is marked as polymorphic discriminator + // and the serializedName of the metadata polymorphicDiscriminator (ex: "fish.type"). However, + // the clientName transformation of the polymorphicDiscriminator (ex: "fishtype") and + // the transformation of model property name (ex: "fishtype") is done consistently. + // Hence, it is a safer bet to rely on the clientName of the polymorphicDiscriminator. + if ( + polymorphicDiscriminator && + key === polymorphicDiscriminator.clientName && + (propertyInstance === undefined || propertyInstance === null) + ) { + propertyInstance = mapper.serializedName; + } + + let serializedValue; + // paging + if (Array.isArray(responseBody[key]) && modelProps[key].serializedName === "") { + propertyInstance = responseBody[key]; + instance = serializer.deserialize(propertyMapper, propertyInstance, propertyObjectName); + } else if (propertyInstance !== undefined || propertyMapper.defaultValue !== undefined) { + serializedValue = serializer.deserialize( + propertyMapper, + propertyInstance, + propertyObjectName + ); + instance[key] = serializedValue; + } + } + } + + const additionalPropertiesMapper = mapper.type.additionalProperties; + if (additionalPropertiesMapper) { + const isAdditionalProperty = (responsePropName: string): boolean => { + for (const clientPropName in modelProps) { + const paths = splitSerializeName(modelProps[clientPropName].serializedName); + if (paths[0] === responsePropName) { + return false; + } + } + return true; + }; + + for (const responsePropName in responseBody) { + if (isAdditionalProperty(responsePropName)) { + instance[responsePropName] = serializer.deserialize( + additionalPropertiesMapper, + responseBody[responsePropName], + objectName + '["' + responsePropName + '"]' + ); + } + } + } else if (responseBody) { + for (const key of Object.keys(responseBody)) { + if ( + instance[key] === undefined && + !handledPropertyNames.includes(key) && + !isSpecialXmlProperty(key) + ) { + instance[key] = responseBody[key]; + } + } + } + + return instance; +} + +function deserializeDictionaryType( + serializer: Serializer, + mapper: DictionaryMapper, + responseBody: any, + objectName: string +): any { + /* jshint validthis: true */ + const value = mapper.type.value; + if (!value || typeof value !== "object") { + throw new Error( + `"value" metadata for a Dictionary must be defined in the ` + + `mapper and it must of type "object" in ${objectName}` + ); + } + if (responseBody) { + const tempDictionary: { [key: string]: any } = {}; + for (const key of Object.keys(responseBody)) { + tempDictionary[key] = serializer.deserialize(value, responseBody[key], objectName); + } + return tempDictionary; + } + return responseBody; +} + +function deserializeSequenceType( + serializer: Serializer, + mapper: SequenceMapper, + responseBody: any, + objectName: string +): any { + /* jshint validthis: true */ + const element = mapper.type.element; + if (!element || typeof element !== "object") { + throw new Error( + `element" metadata for an Array must be defined in the ` + + `mapper and it must of type "object" in ${objectName}` + ); + } + if (responseBody) { + if (!Array.isArray(responseBody)) { + // xml2js will interpret a single element array as just the element, so force it to be an array + responseBody = [responseBody]; + } + + const tempArray = []; + for (let i = 0; i < responseBody.length; i++) { + tempArray[i] = serializer.deserialize(element, responseBody[i], `${objectName}[${i}]`); + } + return tempArray; + } + return responseBody; +} + +function getPolymorphicMapper( + serializer: Serializer, + mapper: CompositeMapper, + object: any, + polymorphicPropertyName: "clientName" | "serializedName" +): CompositeMapper { + const polymorphicDiscriminator = getPolymorphicDiscriminatorRecursively(serializer, mapper); + if (polymorphicDiscriminator) { + const discriminatorName = polymorphicDiscriminator[polymorphicPropertyName]; + if (discriminatorName) { + const discriminatorValue = object[discriminatorName]; + if (discriminatorValue !== undefined && discriminatorValue !== null) { + const typeName = mapper.type.uberParent || mapper.type.className; + const indexDiscriminator = + discriminatorValue === typeName + ? discriminatorValue + : typeName + "." + discriminatorValue; + const polymorphicMapper = serializer.modelMappers.discriminators[indexDiscriminator]; + if (polymorphicMapper) { + mapper = polymorphicMapper; + } + } + } + } + return mapper; +} + +function getPolymorphicDiscriminatorRecursively( + serializer: Serializer, + mapper: CompositeMapper +): PolymorphicDiscriminator | undefined { + return ( + mapper.type.polymorphicDiscriminator || + getPolymorphicDiscriminatorSafely(serializer, mapper.type.uberParent) || + getPolymorphicDiscriminatorSafely(serializer, mapper.type.className) + ); +} + +function getPolymorphicDiscriminatorSafely( + serializer: Serializer, + typeName?: string +): PolymorphicDiscriminator | undefined { + return ( + typeName && + serializer.modelMappers[typeName] && + serializer.modelMappers[typeName].type.polymorphicDiscriminator + ); +} + +/** + * Known types of Mappers + */ +export const MapperTypeNames = { + Base64Url: "Base64Url", + Boolean: "Boolean", + ByteArray: "ByteArray", + Composite: "Composite", + Date: "Date", + DateTime: "DateTime", + DateTimeRfc1123: "DateTimeRfc1123", + Dictionary: "Dictionary", + Enum: "Enum", + Number: "Number", + Object: "Object", + Sequence: "Sequence", + String: "String", + Stream: "Stream", + TimeSpan: "TimeSpan", + UnixTime: "UnixTime" +} as const; diff --git a/sdk/core/core-client/src/serviceClient.ts b/sdk/core/core-client/src/serviceClient.ts new file mode 100644 index 000000000000..a8d761d767e7 --- /dev/null +++ b/sdk/core/core-client/src/serviceClient.ts @@ -0,0 +1,422 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TokenCredential, isTokenCredential } from "@azure/core-auth"; +import { + DefaultHttpsClient, + HttpsClient, + PipelineRequest, + PipelineResponse, + Pipeline, + createPipelineRequest, + createPipelineFromOptions, + bearerTokenAuthenticationPolicy +} from "@azure/core-https"; +import { + OperationResponse, + OperationArguments, + OperationSpec, + OperationRequest, + OperationResponseMap, + FullOperationResponse, + DictionaryMapper, + CompositeMapper +} from "./interfaces"; +import { getPathStringFromParameter, isStreamOperation } from "./interfaceHelpers"; +import { MapperTypeNames } from "./serializer"; +import { getRequestUrl } from "./urlHelpers"; +import { isPrimitiveType } from "./utils"; +import { getOperationArgumentValueFromParameter } from "./operationHelpers"; +import { deserializationPolicy } from "./deserializationPolicy"; + +/** + * Options to be provided while creating the client. + */ +export interface ServiceClientOptions { + /** + * If specified, this is the base URI that requests will be made against for this ServiceClient. + * If it is not specified, then all OperationSpecs must contain a baseUrl property. + */ + baseUri?: string; + /** + * The default request content type for the service. + * Used if no requestContentType is present on an OperationSpec. + */ + requestContentType?: string; + /** + * Credential used to authenticate the request. + */ + credential?: TokenCredential; + /** + * A customized pipeline to use, otherwise a default one will be created. + */ + pipeline?: Pipeline; + /** + * The HttpClient that will be used to send HTTP requests. + */ + httpsClient?: HttpsClient; + + /** + * A method that is able to turn an XML object model into a string. + */ + stringifyXML?: (obj: any, opts?: { rootName?: string }) => string; +} + +/** + * Initializes a new instance of the ServiceClient. + */ +export class ServiceClient { + /** + * If specified, this is the base URI that requests will be made against for this ServiceClient. + * If it is not specified, then all OperationSpecs must contain a baseUrl property. + */ + private readonly _baseUri?: string; + + /** + * The default request content type for the service. + * Used if no requestContentType is present on an OperationSpec. + */ + private readonly _requestContentType?: string; + + /** + * Decoupled method for processing XML into a string. + */ + private readonly _stringifyXML?: (obj: any, opts?: { rootName?: string }) => string; + + /** + * The HTTP client that will be used to send requests. + */ + private readonly _httpsClient: HttpsClient; + + private readonly _pipeline: Pipeline; + + /** + * The ServiceClient constructor + * @constructor + * @param credential The credentials used for authentication with the service. + * @param options The service client options that govern the behavior of the client. + */ + constructor(options: ServiceClientOptions = {}) { + this._requestContentType = options.requestContentType; + this._baseUri = options.baseUri; + this._httpsClient = options.httpsClient || new DefaultHttpsClient(); + this._pipeline = + options.pipeline || + createDefaultPipeline({ baseUri: this._baseUri, credential: options.credential }); + this._stringifyXML = options.stringifyXML; + } + + /** + * Send the provided httpRequest. + */ + async sendRequest(request: PipelineRequest): Promise { + return this._pipeline.sendRequest(this._httpsClient, request); + } + + /** + * Send an HTTP request that is populated using the provided OperationSpec. + * @param {OperationArguments} operationArguments The arguments that the HTTP request's templated values will be populated from. + * @param {OperationSpec} operationSpec The OperationSpec to use to populate the httpRequest. + */ + async sendOperationRequest( + operationArguments: OperationArguments, + operationSpec: OperationSpec + ): Promise { + const baseUri: string | undefined = operationSpec.baseUrl || this._baseUri; + if (!baseUri) { + throw new Error( + "If operationSpec.baseUrl is not specified, then the ServiceClient must have a baseUri string property that contains the base URL to use." + ); + } + + // Templatized URLs sometimes reference properties on the ServiceClient child class, + // so we have to pass `this` below in order to search these properties if they're + // not part of OperationArguments + const url = getRequestUrl(baseUri, operationSpec, operationArguments, this); + + const request: OperationRequest = createPipelineRequest({ + url + }); + request.method = operationSpec.httpMethod; + request.additionalInfo = {}; + request.additionalInfo.operationSpec = operationSpec; + + const contentType = operationSpec.contentType || this._requestContentType; + if (contentType) { + request.headers.set("Content-Type", contentType); + } + + if (operationSpec.headerParameters) { + for (const headerParameter of operationSpec.headerParameters) { + let headerValue = getOperationArgumentValueFromParameter( + operationArguments, + headerParameter + ); + if (headerValue !== null && headerValue !== undefined) { + headerValue = operationSpec.serializer.serialize( + headerParameter.mapper, + headerValue, + getPathStringFromParameter(headerParameter) + ); + const headerCollectionPrefix = (headerParameter.mapper as DictionaryMapper) + .headerCollectionPrefix; + if (headerCollectionPrefix) { + for (const key of Object.keys(headerValue)) { + request.headers.set(headerCollectionPrefix + key, headerValue[key]); + } + } else { + request.headers.set( + headerParameter.mapper.serializedName || getPathStringFromParameter(headerParameter), + headerValue + ); + } + } + } + } + + const options = operationArguments.options; + if (options) { + const requestOptions = options.requestOptions; + + if (requestOptions) { + if (requestOptions.customHeaders) { + for (const customHeaderName of Object.keys(requestOptions.customHeaders)) { + request.headers.set(customHeaderName, requestOptions.customHeaders[customHeaderName]); + } + } + + if (requestOptions.timeout) { + request.timeout = requestOptions.timeout; + } + + if (requestOptions.onUploadProgress) { + request.onUploadProgress = requestOptions.onUploadProgress; + } + + if (requestOptions.onDownloadProgress) { + request.onDownloadProgress = requestOptions.onDownloadProgress; + } + + if (requestOptions.shouldDeserialize !== undefined) { + request.additionalInfo.shouldDeserialize = requestOptions.shouldDeserialize; + } + } + + if (options.abortSignal) { + request.abortSignal = options.abortSignal; + } + + if (options.tracingOptions?.spanOptions) { + request.spanOptions = options.tracingOptions.spanOptions; + } + } + + serializeRequestBody(request, operationArguments, operationSpec, this._stringifyXML); + + if (request.streamResponseBody === undefined) { + request.streamResponseBody = isStreamOperation(operationSpec); + } + + try { + const rawResponse = await this.sendRequest(request); + return flattenResponse(rawResponse, operationSpec.responses[rawResponse.status]); + } catch (error) { + if (error.response) { + error.details = flattenResponse( + error.response, + operationSpec.responses[error.statusCode] || operationSpec.responses["default"] + ); + } + throw error; + } + } +} + +/** + * @internal @ignore + */ +export function serializeRequestBody( + request: OperationRequest, + operationArguments: OperationArguments, + operationSpec: OperationSpec, + stringifyXML: (obj: any, opts?: { rootName?: string }) => string = function() { + throw new Error("XML serialization unsupported!"); + } +): void { + if (operationSpec.requestBody && operationSpec.requestBody.mapper) { + request.body = getOperationArgumentValueFromParameter( + operationArguments, + operationSpec.requestBody + ); + + const bodyMapper = operationSpec.requestBody.mapper; + const { required, serializedName, xmlName, xmlElementName } = bodyMapper; + const typeName = bodyMapper.type.name; + + try { + if (request.body || required) { + const requestBodyParameterPathString: string = getPathStringFromParameter( + operationSpec.requestBody + ); + request.body = operationSpec.serializer.serialize( + bodyMapper, + request.body, + requestBodyParameterPathString + ); + + const isStream = typeName === MapperTypeNames.Stream; + + if (operationSpec.isXML) { + if (typeName === MapperTypeNames.Sequence) { + request.body = stringifyXML( + prepareXMLRootList(request.body, xmlElementName || xmlName || serializedName!), + { rootName: xmlName || serializedName } + ); + } else if (!isStream) { + request.body = stringifyXML(request.body, { + rootName: xmlName || serializedName + }); + } + } else if ( + typeName === MapperTypeNames.String && + (operationSpec.contentType?.match("text/plain") || operationSpec.mediaType === "text") + ) { + // the String serializer has validated that request body is a string + // so just send the string. + return; + } else if (!isStream) { + request.body = JSON.stringify(request.body); + } + } + } catch (error) { + throw new Error( + `Error "${error.message}" occurred in serializing the payload - ${JSON.stringify( + serializedName, + undefined, + " " + )}.` + ); + } + } else if (operationSpec.formDataParameters && operationSpec.formDataParameters.length > 0) { + request.formData = {}; + for (const formDataParameter of operationSpec.formDataParameters) { + const formDataParameterValue = getOperationArgumentValueFromParameter( + operationArguments, + formDataParameter + ); + if (formDataParameterValue !== undefined && formDataParameterValue !== null) { + const formDataParameterPropertyName: string = + formDataParameter.mapper.serializedName || getPathStringFromParameter(formDataParameter); + request.formData[formDataParameterPropertyName] = operationSpec.serializer.serialize( + formDataParameter.mapper, + formDataParameterValue, + getPathStringFromParameter(formDataParameter) + ); + } + } + } +} + +function createDefaultPipeline( + options: { baseUri?: string; credential?: TokenCredential } = {} +): Pipeline { + const pipeline = createPipelineFromOptions({}); + + const credential = options.credential; + if (credential) { + if (isTokenCredential(credential)) { + pipeline.addPolicy( + bearerTokenAuthenticationPolicy({ credential, scopes: `${options.baseUri || ""}/.default` }) + ); + } else { + throw new Error("The credential argument must implement the TokenCredential interface"); + } + } + + pipeline.addPolicy(deserializationPolicy(), { phase: "Serialize" }); + + return pipeline; +} + +function prepareXMLRootList(obj: any, elementName: string): { [key: string]: any[] } { + if (!Array.isArray(obj)) { + obj = [obj]; + } + return { [elementName]: obj }; +} + +function flattenResponse( + fullResponse: FullOperationResponse, + responseSpec: OperationResponseMap | undefined +): OperationResponse { + const parsedHeaders = fullResponse.parsedHeaders; + const bodyMapper = responseSpec && responseSpec.bodyMapper; + + function addResponse( + obj: T + ): T & { readonly _response: FullOperationResponse } { + return Object.defineProperty(obj, "_response", { + configurable: false, + enumerable: false, + writable: false, + value: fullResponse + }); + } + + if (bodyMapper) { + const typeName = bodyMapper.type.name; + if (typeName === "Stream") { + return addResponse({ + ...parsedHeaders, + blobBody: fullResponse.blobBody, + readableStreamBody: fullResponse.readableStreamBody + }); + } + + const modelProperties = + (typeName === "Composite" && (bodyMapper as CompositeMapper).type.modelProperties) || {}; + const isPageableResponse = Object.keys(modelProperties).some( + (k) => modelProperties[k].serializedName === "" + ); + if (typeName === "Sequence" || isPageableResponse) { + const arrayResponse: { [key: string]: unknown } = + fullResponse.parsedBody ?? (([] as unknown) as { [key: string]: unknown }); + + for (const key of Object.keys(modelProperties)) { + if (modelProperties[key].serializedName) { + arrayResponse[key] = fullResponse.parsedBody?.[key]; + } + } + + if (parsedHeaders) { + for (const key of Object.keys(parsedHeaders)) { + arrayResponse[key] = parsedHeaders[key]; + } + } + return addResponse(arrayResponse); + } + + if (typeName === "Composite" || typeName === "Dictionary") { + return addResponse({ + ...parsedHeaders, + ...fullResponse.parsedBody + }); + } + } + + if ( + bodyMapper || + fullResponse.request.method === "HEAD" || + isPrimitiveType(fullResponse.parsedBody) + ) { + return addResponse({ + ...parsedHeaders, + body: fullResponse.parsedBody + }); + } + + return addResponse({ + ...parsedHeaders, + ...fullResponse.parsedBody + }); +} diff --git a/sdk/core/core-client/src/url.browser.ts b/sdk/core/core-client/src/url.browser.ts new file mode 100644 index 000000000000..085c11b25cf4 --- /dev/null +++ b/sdk/core/core-client/src/url.browser.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const url = URL; +const urlSearchParams = URLSearchParams; + +export { url as URL, urlSearchParams as URLSearchParams }; diff --git a/sdk/core/core-client/src/url.ts b/sdk/core/core-client/src/url.ts new file mode 100644 index 000000000000..993e69798f9e --- /dev/null +++ b/sdk/core/core-client/src/url.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { URL, URLSearchParams } from "url"; diff --git a/sdk/core/core-client/src/urlHelpers.ts b/sdk/core/core-client/src/urlHelpers.ts new file mode 100644 index 000000000000..e370e593fbab --- /dev/null +++ b/sdk/core/core-client/src/urlHelpers.ts @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { URL } from "./url"; +import { OperationSpec, OperationArguments, QueryCollectionFormat } from "./interfaces"; +import { getOperationArgumentValueFromParameter } from "./operationHelpers"; +import { getPathStringFromParameter } from "./interfaceHelpers"; + +const CollectionFormatToDelimiterMap: { [key in QueryCollectionFormat]: string } = { + CSV: ",", + SSV: " ", + Multi: "Multi", + TSV: "\t", + Pipes: "|" +}; + +export function getRequestUrl( + baseUri: string, + operationSpec: OperationSpec, + operationArguments: OperationArguments, + fallbackObject: { [parameterName: string]: any } +): string { + const urlReplacements = calculateUrlReplacements( + operationSpec, + operationArguments, + fallbackObject + ); + + let requestUrl = replaceAll(baseUri, urlReplacements); + if (operationSpec.path) { + const path = replaceAll(operationSpec.path, urlReplacements); + // QUIRK: sometimes we get a path component like {nextLink} + // which may be a fully formed URL. In that case, we should + // ignore the baseUri. + if (isAbsoluteUrl(path)) { + requestUrl = path; + } else { + requestUrl = appendPath(requestUrl, operationSpec.path); + } + } + + const queryParams = calculateQueryParameters(operationSpec, operationArguments, fallbackObject); + requestUrl = appendQueryParams(requestUrl, queryParams); + + return requestUrl; +} + +function replaceAll(input: string, replacements: Map): string { + let result = input; + for (const [searchValue, replaceValue] of replacements) { + result = result.split(searchValue).join(replaceValue); + } + return result; +} + +function calculateUrlReplacements( + operationSpec: OperationSpec, + operationArguments: OperationArguments, + fallbackObject: { [parameterName: string]: any } +): Map { + const result = new Map(); + if (operationSpec.urlParameters?.length) { + for (const urlParameter of operationSpec.urlParameters) { + let urlParameterValue: string = getOperationArgumentValueFromParameter( + operationArguments, + urlParameter, + fallbackObject + ); + const parameterPathString = getPathStringFromParameter(urlParameter); + urlParameterValue = operationSpec.serializer.serialize( + urlParameter.mapper, + urlParameterValue, + parameterPathString + ); + if (!urlParameter.skipEncoding) { + urlParameterValue = encodeURIComponent(urlParameterValue); + } + result.set( + `{${urlParameter.mapper.serializedName || parameterPathString}}`, + urlParameterValue + ); + } + } + return result; +} + +function isAbsoluteUrl(url: string): boolean { + return url.includes("://"); +} + +function appendPath(url: string, path?: string): string { + let result = url; + let toAppend = path; + if (toAppend) { + if (!result.endsWith("/")) { + result = `${result}/`; + } + + if (toAppend.startsWith("/")) { + toAppend = toAppend.substring(1); + } + + result = result + toAppend; + } + return result; +} + +function calculateQueryParameters( + operationSpec: OperationSpec, + operationArguments: OperationArguments, + fallbackObject: { [parameterName: string]: any } +): Map { + const result = new Map(); + if (operationSpec.queryParameters?.length) { + for (const queryParameter of operationSpec.queryParameters) { + let queryParameterValue: string | string[] = getOperationArgumentValueFromParameter( + operationArguments, + queryParameter, + fallbackObject + ); + if (queryParameterValue !== undefined && queryParameterValue !== null) { + queryParameterValue = operationSpec.serializer.serialize( + queryParameter.mapper, + queryParameterValue, + getPathStringFromParameter(queryParameter) + ); + + const delimiter = queryParameter.collectionFormat + ? CollectionFormatToDelimiterMap[queryParameter.collectionFormat] + : ""; + if (Array.isArray(queryParameterValue)) { + // replace null and undefined + queryParameterValue = queryParameterValue.map((item) => { + if (item === null || item === undefined) { + return ""; + } + + return item; + }); + } + if (queryParameter.collectionFormat === "Multi" && queryParameterValue.length === 0) { + queryParameterValue = ""; + } else if ( + Array.isArray(queryParameterValue) && + (queryParameter.collectionFormat === "SSV" || queryParameter.collectionFormat === "TSV") + ) { + queryParameterValue = queryParameterValue.join(delimiter); + } + if (!queryParameter.skipEncoding) { + if (Array.isArray(queryParameterValue)) { + queryParameterValue = queryParameterValue.map((item: string) => { + return encodeURIComponent(item); + }); + } else { + queryParameterValue = encodeURIComponent(queryParameterValue); + } + } + + // Join pipes and CSV *after* encoding, or the server will be upset. + if ( + Array.isArray(queryParameterValue) && + (queryParameter.collectionFormat === "CSV" || queryParameter.collectionFormat === "Pipes") + ) { + queryParameterValue = queryParameterValue.join(delimiter); + } + + // ignore empty values + if (queryParameterValue) { + result.set( + queryParameter.mapper.serializedName || getPathStringFromParameter(queryParameter), + queryParameterValue + ); + } + } + } + } + return result; +} + +function appendQueryParams(url: string, queryParams: Map): string { + const parsedUrl = new URL(url); + + const combinedParams = new Map(queryParams); + + for (const [name, value] of parsedUrl.searchParams) { + const existingValue = combinedParams.get(name); + if (Array.isArray(existingValue)) { + existingValue.push(value); + } else if (existingValue) { + combinedParams.set(name, [existingValue, value]); + } else { + combinedParams.set(name, value); + } + } + + const searchPieces: string[] = []; + for (const [name, value] of combinedParams) { + if (typeof value === "string") { + searchPieces.push(`${name}=${value}`); + } else { + // QUIRK: If we get an array of values, include multiple key/value pairs + for (const subValue of value) { + searchPieces.push(`${name}=${subValue}`); + } + } + } + + // QUIRK: we have to set search manually as searchParams will encode comma when it shouldn't. + parsedUrl.search = `?${searchPieces.join("&")}`; + + return parsedUrl.toString(); +} diff --git a/sdk/core/core-client/src/utils.ts b/sdk/core/core-client/src/utils.ts new file mode 100644 index 000000000000..743bf1e3b265 --- /dev/null +++ b/sdk/core/core-client/src/utils.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Returns true if the given value is a basic/primitive type + * (string, number, boolean, null, undefined). + * @param value Value to test + * @ignore @internal + */ +export function isPrimitiveType(value: any): boolean { + return (typeof value !== "object" && typeof value !== "function") || value === null; +} + +const validateISODuration = /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/; + +/** + * Returns true if the given string is in ISO 8601 format. + * @param value The value to be validated for ISO 8601 duration format. + * @ignore @internal + */ +export function isDuration(value: string): boolean { + return validateISODuration.test(value); +} + +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; + +/** + * Returns true if the provided uuid is valid. + * + * @param uuid The uuid that needs to be validated. + * + * @ignore @internal + */ +export function isValidUuid(uuid: string): boolean { + return validUuidRegex.test(uuid); +} diff --git a/sdk/core/core-client/test/deserializationPolicy.spec.ts b/sdk/core/core-client/test/deserializationPolicy.spec.ts new file mode 100644 index 000000000000..4992964907ac --- /dev/null +++ b/sdk/core/core-client/test/deserializationPolicy.spec.ts @@ -0,0 +1,465 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "chai"; +import * as sinon from "sinon"; +import { + deserializationPolicy, + OperationSpec, + OperationRequest, + createSerializer, + CompositeMapper, + FullOperationResponse +} from "../src"; +import { + createPipelineRequest, + PipelineResponse, + createHttpHeaders, + SendRequest, + RawHttpHeaders +} from "@azure/core-https"; + +describe("deserializationPolicy", function() { + it(`should not modify a request that has no request body mapper`, async function() { + const response = await getDeserializedResponse({ requestBody: "hello there!" }); + assert.strictEqual(response.request.body, "hello there!"); + }); + + it("should parse a JSON response body", async function() { + const response = await getDeserializedResponse({ + headers: { "Content-Type": "application/json" }, + bodyAsText: "[123, 456, 789]" + }); + assert.deepEqual(response.parsedBody, [123, 456, 789]); + }); + + it("should parse a JSON response body with a charset specified in Content-Type", async function() { + const response = await getDeserializedResponse({ + headers: { "Content-Type": "application/json;charset=UTF-8" }, + bodyAsText: "[123, 456, 789]" + }); + assert.deepEqual(response.parsedBody, [123, 456, 789]); + }); + + it("should parse a JSON response body with an uppercase Content-Type", async function() { + const response = await getDeserializedResponse({ + headers: { "Content-Type": "APPLICATION/JSON" }, + bodyAsText: "[123, 456, 789]" + }); + assert.deepEqual(response.parsedBody, [123, 456, 789]); + }); + + it("should parse a JSON response body with a missing Content-Type", async function() { + const response = await getDeserializedResponse({ + bodyAsText: "[123, 456, 789]" + }); + assert.deepEqual(response.parsedBody, [123, 456, 789]); + }); + + describe(`parse(HttpOperationResponse)`, () => { + it(`with no response headers or body`, async function() { + const response = await getDeserializedResponse(); + + assert.exists(response); + assert.isUndefined(response.readableStreamBody); + assert.isUndefined(response.blobBody); + assert.isUndefined(response.bodyAsText); + assert.isUndefined(response.parsedBody); + assert.isUndefined(response.parsedHeaders); + }); + + it.skip(`with xml response body, application/xml content-type, but no operation spec`, async function() { + const response = await getDeserializedResponse({ + headers: { "content-type": "application/xml" }, + bodyAsText: `3` + }); + assert.exists(response); + assert.isUndefined(response.readableStreamBody); + assert.isUndefined(response.blobBody); + assert.isUndefined(response.parsedBody); + assert.isUndefined(response.parsedHeaders); + assert.strictEqual(response.bodyAsText, `3`); + assert.deepEqual(response.parsedBody, { apples: "3" }); + }); + + it.skip(`with xml response body with child element with attributes and value, application/xml content-type, but no operation spec`, async function() { + const response = await getDeserializedResponse({ + headers: { "content-type": "application/xml" }, + bodyAsText: `3` + }); + + assert.exists(response); + assert.isUndefined(response.readableStreamBody); + assert.isUndefined(response.blobBody); + assert.strictEqual(response.bodyAsText, `3`); + assert.deepEqual(response.parsedBody, { + apples: { + $: { + tasty: "yes" + }, + _: "3" + } + }); + assert.isUndefined(response.parsedHeaders); + }); + + it.skip(`with xml response body, application/xml content-type, and operation spec for only String value`, async function() { + const operationSpec: OperationSpec = { + httpMethod: "GET", + serializer: createSerializer({}, true), + responses: { + 200: { + bodyMapper: { + xmlName: "fruit", + serializedName: "fruit", + type: { + name: "Composite", + className: "Fruit", + modelProperties: { + apples: { + xmlName: "apples", + serializedName: "apples", + type: { + name: "String" + } + } + } + } + } + } + } + }; + + const response = await getDeserializedResponse({ + operationSpec, + headers: { "content-type": "application/xml" }, + bodyAsText: `3` + }); + + assert.exists(response); + assert.isUndefined(response.readableStreamBody); + assert.isUndefined(response.blobBody); + assert.strictEqual(response.bodyAsText, `3`); + assert.deepEqual(response.parsedBody, { apples: "3" }); + assert.isUndefined(response.parsedHeaders); + }); + + it.skip(`with xml response body, application/xml content-type, and operation spec for only number value`, async function() { + const operationSpec: OperationSpec = { + httpMethod: "GET", + serializer: createSerializer({}, true), + responses: { + 200: { + bodyMapper: { + xmlName: "fruit", + serializedName: "fruit", + type: { + name: "Composite", + className: "Fruit", + modelProperties: { + apples: { + xmlName: "apples", + serializedName: "apples", + type: { + name: "Number" + } + } + } + } + } + } + } + }; + const response = await getDeserializedResponse({ + operationSpec, + headers: { "content-type": "application/xml" }, + bodyAsText: `3` + }); + + assert.exists(response); + assert.isUndefined(response.readableStreamBody); + assert.isUndefined(response.blobBody); + assert.strictEqual(response.bodyAsText, `3`); + assert.deepEqual(response.parsedBody, { apples: "3" }); + assert.isUndefined(response.parsedHeaders); + }); + + it.skip(`with xml response body, application/xml content-type, and operation spec for only headers`, async function() { + const operationSpec: OperationSpec = { + httpMethod: "GET", + serializer: createSerializer({}, true), + responses: { + 200: { + bodyMapper: { + xmlName: "fruit", + serializedName: "fruit", + type: { + name: "Composite", + className: "Fruit", + modelProperties: { + apples: { + xmlName: "apples", + serializedName: "apples", + type: { + name: "Composite", + className: "Apples", + modelProperties: { + tasty: { + xmlName: "tasty", + xmlIsAttribute: true, + serializedName: "tasty", + type: { + name: "String" + } + } + } + } + } + } + } + } + } + } + }; + const response = await getDeserializedResponse({ + operationSpec, + headers: { "content-type": "application/xml" }, + bodyAsText: `3` + }); + + 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: { tasty: "yes" } }); + }); + + it.skip(`with xml response body, application/atom+xml content-type, but no operation spec`, async function() { + const response = await getDeserializedResponse({ + headers: { "content-type": "application/xml" }, + bodyAsText: `3` + }); + + 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: "3" }); + }); + + it.skip(`with xml property with attribute and value, application/atom+xml content-type, but no operation spec`, async function() { + const response = await getDeserializedResponse({ + headers: { "content-type": "application/atom+xml" }, + bodyAsText: `3` + }); + + 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.skip(`with xml property with attribute and value, my/weird-xml content-type, but no operation spec`, async function() { + const response = await getDeserializedResponse({ + headers: { "content-type": "my/weird-xml" }, + bodyAsText: `3`, + xmlContentTypes: ["my/weird-xml"] + }); + + 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.skip(`with service bus response body, application/atom+xml content-type, and no operationSpec`, async function() { + const response = await getDeserializedResponse({ + headers: { "content-type": "application/atom+xml;type=entry;charset=utf-8" }, + bodyAsText: `https://daschulttest1.servicebus.windows.net/testQueuePath/?api-version=2017-04&enrich=FalsetestQueuePath2018-10-09T19:56:34Z2018-10-09T19:56:35Zdaschulttest1PT1M1024falsefalseP14DfalsePT10M10true00falseActive2018-10-09T19:56:34.903Z2018-10-09T19:56:35.013Z0001-01-01T00:00:00Ztrue00000P10675199DT2H48M5.4775807SfalseAvailablefalse` + }); + + assert.exists(response); + assert.isUndefined(response.readableStreamBody); + assert.isUndefined(response.blobBody); + assert.isUndefined(response.parsedHeaders); + assert.deepEqual(response.parsedBody, { + $: { + xmlns: "http://www.w3.org/2005/Atom" + }, + author: { + name: "daschulttest1" + }, + content: { + $: { + type: "application/xml" + }, + QueueDescription: { + $: { + xmlns: "http://schemas.microsoft.com/netservices/2010/10/servicebus/connect", + "xmlns:i": "http://www.w3.org/2001/XMLSchema-instance" + }, + AccessedAt: "0001-01-01T00:00:00Z", + AuthorizationRules: "", + AutoDeleteOnIdle: "P10675199DT2H48M5.4775807S", + CountDetails: { + $: { + "xmlns:d2p1": "http://schemas.microsoft.com/netservices/2011/06/servicebus" + }, + "d2p1:ActiveMessageCount": "0", + "d2p1:DeadLetterMessageCount": "0", + "d2p1:ScheduledMessageCount": "0", + "d2p1:TransferDeadLetterMessageCount": "0", + "d2p1:TransferMessageCount": "0" + }, + CreatedAt: "2018-10-09T19:56:34.903Z", + DeadLetteringOnMessageExpiration: "false", + DefaultMessageTimeToLive: "P14D", + DuplicateDetectionHistoryTimeWindow: "PT10M", + EnableBatchedOperations: "true", + EnableExpress: "false", + EnablePartitioning: "false", + EntityAvailabilityStatus: "Available", + IsAnonymousAccessible: "false", + LockDuration: "PT1M", + MaxDeliveryCount: "10", + MaxSizeInMegabytes: "1024", + MessageCount: "0", + RequiresDuplicateDetection: "false", + RequiresSession: "false", + SizeInBytes: "0", + Status: "Active", + SupportOrdering: "true", + UpdatedAt: "2018-10-09T19:56:35.013Z" + } + }, + id: + "https://daschulttest1.servicebus.windows.net/testQueuePath/?api-version=2017-04&enrich=False", + link: { + $: { + href: + "https://daschulttest1.servicebus.windows.net/testQueuePath/?api-version=2017-04&enrich=False", + rel: "self" + } + }, + published: "2018-10-09T19:56:34Z", + title: { + $: { + type: "text" + }, + _: "testQueuePath" + }, + updated: "2018-10-09T19:56:35Z" + }); + }); + + it(`with default response headers`, async function() { + const BodyMapper: CompositeMapper = { + serializedName: "getproperties-body", + type: { + name: "Composite", + className: "PropertiesBody", + modelProperties: { + message: { + type: { + name: "String" + } + } + } + } + }; + + const HeadersMapper: CompositeMapper = { + serializedName: "getproperties-headers", + type: { + name: "Composite", + className: "PropertiesHeaders", + modelProperties: { + errorCode: { + serializedName: "x-ms-error-code", + type: { + name: "String" + } + } + } + } + }; + + const serializer = createSerializer(HeadersMapper, true); + + const operationSpec: OperationSpec = { + httpMethod: "GET", + responses: { + default: { + headersMapper: HeadersMapper, + bodyMapper: BodyMapper + } + }, + serializer + }; + + try { + await getDeserializedResponse({ + operationSpec, + headers: { "x-ms-error-code": "InvalidResourceNameHeader" }, + bodyAsText: '{"message": "InvalidResourceNameBody"}', + status: 500 + }); + assert.fail(); + } catch (e) { + assert.exists(e); + assert.strictEqual(e.response.parsedHeaders.errorCode, "InvalidResourceNameHeader"); + assert.strictEqual(e.response.parsedBody.message, "InvalidResourceNameBody"); + } + }); + }); +}); + +async function getDeserializedResponse( + options: { + operationSpec?: OperationSpec; + requestBody?: any; + headers?: RawHttpHeaders; + status?: number; + bodyAsText?: string; + xmlContentTypes?: string[]; + } = {} +): Promise { + const policy = deserializationPolicy({ expectedContentTypes: { xml: options.xmlContentTypes } }); + const request: OperationRequest = createPipelineRequest({ url: "https://example.com" }); + request.additionalInfo = { + operationSpec: options.operationSpec + }; + request.body = options.requestBody; + + const res: PipelineResponse = { + headers: createHttpHeaders(options.headers), + request, + bodyAsText: options.bodyAsText, + status: options.status ?? 200 + }; + const next = sinon.stub, ReturnType>(); + next.resolves(res); + + const response = await policy.sendRequest(request, next); + return response; +} diff --git a/sdk/core/core-client/test/helloWorld.spec.ts b/sdk/core/core-client/test/helloWorld.spec.ts deleted file mode 100644 index 57a39c76b110..000000000000 --- a/sdk/core/core-client/test/helloWorld.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { assert } from "chai"; -import { helloWorld } from "../src"; - -describe("helloWorld", () => { - it("returns hello world", () => { - assert.strictEqual(helloWorld(), "Hello world!"); - }); -}); diff --git a/sdk/core/core-client/test/serializer.spec.ts b/sdk/core/core-client/test/serializer.spec.ts new file mode 100644 index 000000000000..db9c54abafe2 --- /dev/null +++ b/sdk/core/core-client/test/serializer.spec.ts @@ -0,0 +1,1715 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "chai"; +import { + createSerializer, + Mapper, + EnumMapper, + SequenceMapper, + DictionaryMapper, + CompositeMapper +} from "../src"; +import { Mappers } from "./testMappers"; + +const Serializer = createSerializer(Mappers); +const valid_uuid = "ceaafd1e-f936-429f-bbfc-82ee75dddc33"; + +function stringToByteArray(str: string): Uint8Array { + if (typeof Buffer === "function") { + return Buffer.from(str, "utf-8"); + } else { + return new TextEncoder().encode(str); + } +} + +describe("Serializer", function() { + describe("serialize", function() { + const invalid_uuid = "abcd-efgd90-90890jkh"; + + it("should correctly serialize flattened properties", function() { + const expected = { + id: 1, + name: "testProduct", + details: { + max_product_capacity: "Large", + max_product_display_name: "MaxDisplayName" + } + }; + + const serialized = Serializer.serialize( + Mappers.SimpleProduct, + { + id: 1, + name: "testProduct", + maxProductDisplayName: "MaxDisplayName" + }, + "SimpleProduct" + ); + + assert.deepEqual(serialized, expected); + }); + + it("should correctly serialize flattened properties when flattened constant is defined first", function() { + const expected = { + id: 1, + name: "testProduct", + details: { + max_product_capacity: "Large", + max_product_display_name: "MaxDisplayName" + } + }; + + const serialized = Serializer.serialize( + Mappers.SimpleProductConstFirst, + { + id: 1, + name: "testProduct", + maxProductDisplayName: "MaxDisplayName" + }, + "SimpleProduct" + ); + + assert.deepEqual(serialized, expected); + }); + + it("should correctly serialize a string if the type is 'any'", function() { + const mapper: Mapper = { + type: { name: "any" }, + required: false, + serializedName: "any" + }; + const serializedObject = Serializer.serialize(mapper, "foo", "anyBody"); + assert.equal(serializedObject, "foo"); + }); + + it("should correctly serialize an array if the type is 'any'", function() { + const mapper: Mapper = { + type: { name: "any" }, + required: false, + serializedName: "any" + }; + const serializedObject = Serializer.serialize(mapper, [1, 2], "anyBody"); + assert.deepEqual(serializedObject, [1, 2]); + }); + + it("should correctly serialize a string", function() { + const mapper: Mapper = { + type: { name: "String" }, + required: false, + serializedName: "string" + }; + const serializedObject = Serializer.serialize(mapper, "foo", "stringBody"); + assert.equal(serializedObject, "foo"); + }); + + it("should correctly serialize a uuid", function() { + const mapper: Mapper = { + type: { name: "Uuid" }, + required: false, + serializedName: "Uuid" + }; + const serializedObject = Serializer.serialize(mapper, valid_uuid, "uuidBody"); + assert.equal(serializedObject, valid_uuid); + }); + + it("should throw an error if the value is not a valid Uuid", function() { + const mapper: Mapper = { + type: { name: "Uuid" }, + required: false, + serializedName: "Uuid" + }; + try { + Serializer.serialize(mapper, invalid_uuid, "uuidBody"); + } catch (error) { + assert.match(error.message, /.*with value.*must be of type string and a valid uuid/gi); + } + }); + + it("should correctly serialize a number", function() { + const mapper: Mapper = { + type: { name: "Number" }, + required: false, + serializedName: "Number" + }; + const serializedObject = Serializer.serialize(mapper, 1.506, "stringBody"); + assert.equal(serializedObject, 1.506); + }); + + it("should correctly serialize a boolean", function() { + const mapper: Mapper = { + type: { name: "Boolean" }, + required: false, + serializedName: "Boolean" + }; + const serializedObject = Serializer.serialize(mapper, false, "stringBody"); + assert.equal(serializedObject, false); + }); + + it("should correctly serialize an Enum", function() { + const mapper: EnumMapper = { + type: { name: "Enum", allowedValues: [1, 2, 3, 4] }, + required: false, + serializedName: "Enum" + }; + const serializedObject = Serializer.serialize(mapper, 1, "enumBody"); + assert.equal(serializedObject, 1); + }); + + it("should throw an error if the value is not valid for an Enum", function() { + const mapper: EnumMapper = { + type: { name: "Enum", allowedValues: [1, 2, 3, 4] }, + required: false, + serializedName: "Enum" + }; + try { + Serializer.serialize(mapper, 6, "enumBody"); + } catch (error) { + assert.match( + error.message, + /6 is not a valid value for enumBody\. The valid values are: \[1,2,3,4\]/gi + ); + } + }); + + it("should correctly serialize a ByteArray Object", function() { + const mapper: Mapper = { + type: { name: "ByteArray" }, + required: false, + serializedName: "ByteArray" + }; + const byteArray = stringToByteArray("Javascript"); + const base64str = "SmF2YXNjcmlwdA=="; + const serializedObject = Serializer.serialize(mapper, byteArray, "stringBody"); + assert.equal(serializedObject, base64str); + }); + + it("should correctly serialize a Date Object", function() { + const dateObj = new Date("2015-01-01"); + const dateISO = "2015-01-01"; + const mapper: Mapper = { + type: { name: "Date" }, + required: false, + serializedName: "Date" + }; + assert.equal(Serializer.serialize(mapper, dateObj, "dateObj"), dateISO); + }); + + it("should correctly serialize a Date object with max value", function() { + const mapper: Mapper = { + type: { name: "DateTime" }, + required: false, + serializedName: "DateTime" + }; + const serializedDateString = Serializer.serialize( + mapper, + new Date("9999-12-31T23:59:59-12:00"), + "dateTimeObj" + ); + assert.equal(serializedDateString, "+010000-01-01T11:59:59.000Z"); + }); + + it("should correctly serialize a Date object with max value and format UnixTime", function() { + const mapper: Mapper = { + type: { name: "UnixTime" }, + required: false, + serializedName: "UnixTime" + }; + const serializedDate = Serializer.serialize( + mapper, + new Date("9999-12-31T23:59:59-12:00"), + "dateTimeObj" + ); + assert.equal(serializedDate, 253402343999); + }); + + it("should correctly serialize a string in DateTimeRfc1123", function() { + const mapper: Mapper = { + type: { name: "DateTimeRfc1123" }, + required: false, + serializedName: "DateTimeRfc1123" + }; + const rfc = new Date("Wed, 01 Jan 2020 00:00:00 GMT"); + const serializedDateString = Serializer.serialize(mapper, rfc, "dateTimeObj"); + assert.equal(serializedDateString, "Wed, 01 Jan 2020 00:00:00 GMT"); + }); + + it("should correctly serialize an ISO 8601 duration", function() { + const mapper: Mapper = { + type: { name: "TimeSpan" }, + required: false, + serializedName: "TimeSpan" + }; + const duration = "P123DT22H14M12.011S"; + const serializedDateString = Serializer.serialize(mapper, duration, "dateTimeObj"); + assert.equal(serializedDateString, duration); + }); + + it("should throw an error when given an invalid ISO 8601 duration", function() { + const mapper: Mapper = { + type: { name: "TimeSpan" }, + required: false, + serializedName: "TimeSpan" + }; + const duration = "P123Z42DT22H14M12.011S"; + assert.throws(() => { + Serializer.serialize(mapper, duration, "dateTimeObj"); + }, /must be a string in ISO 8601 format/); + }); + + it("should correctly serialize an array of primitives", function() { + const mapper: SequenceMapper = { + required: false, + serializedName: "Sequence", + type: { + name: "Sequence", + element: { + type: { name: "String" }, + required: true, + serializedName: "sequenceElement" + } + } + }; + const array = ["One", "Two", "three"]; + const serializedArray = Serializer.serialize(mapper, array, "arrayObj"); + assert.deepEqual(array, serializedArray); + }); + + it("should correctly serialize an array of array of primitives", function() { + const mapper: SequenceMapper = { + required: false, + serializedName: "Sequence", + type: { + name: "Sequence", + element: { + required: true, + serializedName: "sequenceElement", + type: { + name: "Sequence", + element: { + required: true, + serializedName: "sequenceElement", + type: { + name: "Number" + } + } + } + } + } + }; + const array = [[1], [2], [1, 2, 3]]; + const serializedArray = Serializer.serialize(mapper, array, "arrayObj"); + assert.deepEqual(array, serializedArray); + }); + + it("should correctly serialize an array of array of object types", function() { + const mapper: SequenceMapper = { + serializedName: "arrayObj", + required: true, + type: { + name: "Sequence", + element: { + type: { + name: "Sequence", + element: { + type: { + name: "Object" + } + } + } + } + } + }; + const array = [[1], ["2"], [1, "2", {}, true, []]]; + const serializedArray = Serializer.serialize(mapper, array, mapper.serializedName); + assert.deepEqual(array, serializedArray); + }); + + it('should fail while serializing an array of array of "object" types when a null value is provided', function() { + const mapper: Mapper = { + serializedName: "arrayObj", + required: true, + type: { + name: "Sequence", + element: { + type: { + name: "Sequence", + element: { + required: true, + type: { + name: "Object" + } + } + } + } + } + }; + const array = [[1], ["2"], [undefined], [1, "2", {}, true, []]]; + try { + Serializer.serialize(mapper, array, mapper.serializedName); + } catch (err) { + assert.equal(err.message, "arrayObj cannot be null or undefined."); + } + }); + + it("should correctly serialize an array of dictionary of primitives", function() { + const mapper: SequenceMapper = { + required: false, + serializedName: "Sequence", + type: { + name: "Sequence", + element: { + required: true, + serializedName: "sequenceElement", + type: { + name: "Dictionary", + value: { + required: true, + serializedName: "valueElement", + type: { + name: "Boolean" + } + } + } + } + } + }; + const array = [{ 1: true }, { 2: false }, { 1: true, 2: false, 3: true }]; + const serializedArray = Serializer.serialize(mapper, array, "arrayObj"); + assert.deepEqual(array, serializedArray); + }); + + it("should correctly serialize a dictionary of primitives", function() { + const mapper: DictionaryMapper = { + required: false, + serializedName: "Dictionary", + type: { + name: "Dictionary", + value: { + required: true, + serializedName: "valueElement", + type: { + name: "String" + } + } + } + }; + const dict = { 1: "One", 2: "Two", 3: "three" }; + const serializedDictionary = Serializer.serialize(mapper, dict, "dictObj"); + assert.deepEqual(dict, serializedDictionary); + }); + + it("should correctly serialize a dictionary of array of primitives", function() { + const mapper: DictionaryMapper = { + required: false, + serializedName: "Dictionary", + type: { + name: "Dictionary", + value: { + required: true, + serializedName: "valueElement", + type: { + name: "Sequence", + element: { + required: true, + serializedName: "sequenceElement", + type: { + name: "Number" + } + } + } + } + } + }; + const dict = { One: [1], Two: [1, 2], three: [1, 2, 3] }; + const serializedDictionary = Serializer.serialize(mapper, dict, "dictObj"); + assert.deepEqual(dict, serializedDictionary); + }); + + it("should correctly serialize a dictionary of dictionary of primitives", function() { + const mapper: DictionaryMapper = { + required: false, + serializedName: "Dictionary", + type: { + name: "Dictionary", + value: { + required: true, + serializedName: "valueElement", + type: { + name: "Dictionary", + value: { + required: true, + serializedName: "valueElement", + type: { + name: "Boolean" + } + } + } + } + } + }; + const dict = { 1: { One: true }, 2: { Two: false }, 3: { three: true } }; + const serializedDictionary = Serializer.serialize(mapper, dict, "dictObj"); + assert.deepEqual(dict, serializedDictionary); + }); + + it("should correctly serialize a composite type", function() { + const mapper = Mappers.Product; + const serializer = createSerializer(Mappers); + const productObj = { + id: 101, + name: "TestProduct", + provisioningState: "Succeeded", + tags: { + tag1: "value1", + tag2: "value2" + }, + dispatchTime: new Date("2015-01-01T12:35:36.009Z"), + invoiceInfo: { + invId: 1002, + invDate: "2015-12-25", + invProducts: [ + { + Product1: { + id: 101, + name: "TestProduct" + } + }, + { + Product2: { + id: 104, + name: "TestProduct1" + } + } + ] + }, + subProducts: [ + { + subId: 102, + subName: "SubProduct1", + makeTime: new Date("2015-12-21T01:01:01"), + invoiceInfo: { + invId: 1002, + invDate: "2015-12-25" + } + }, + { + subId: 103, + subName: "SubProduct2", + makeTime: new Date("2015-12-21T01:01:01"), + invoiceInfo: { + invId: 1003, + invDate: "2015-12-25" + } + } + ] + }; + const serializedProduct = serializer.serialize(mapper, productObj, "productObject"); + for (const prop in serializedProduct) { + if (prop === "properties") { + assert.equal(serializedProduct[prop].provisioningState, productObj.provisioningState); + } else if (prop === "id") { + assert.equal(serializedProduct[prop], productObj.id); + } else if (prop === "name") { + assert.equal(serializedProduct[prop], productObj.name); + } else if (prop === "tags") { + assert.equal(JSON.stringify(serializedProduct[prop]), JSON.stringify(productObj.tags)); + } else if (prop === "dispatchTime") { + assert.equal( + JSON.stringify(serializedProduct[prop]), + JSON.stringify(productObj.dispatchTime) + ); + } else if (prop === "invoiceInfo") { + assert.equal( + JSON.stringify(serializedProduct[prop]).length - + JSON.stringify(productObj.invoiceInfo).length, + 4 + ); + } else if (prop === "subProducts") { + assert.equal( + JSON.stringify(serializedProduct[prop]).length - + JSON.stringify(productObj.subProducts).length, + 8 + ); + } + } + }); + + it("should correctly serialize object version of polymorphic discriminator", function() { + const serializer = createSerializer(Mappers); + const mapper = Mappers.SawShark; + const sawshark = { + fishtype: "sawshark", + age: 22, + birthday: new Date("2012-01-05T01:00:00Z"), + species: "king", + length: 1.0, + picture: new Uint8Array([255, 255, 255, 255, 254]), + siblings: [ + { + fishtype: "shark", + age: 6, + birthday: new Date("2012-01-05T01:00:00Z"), + length: 20.0, + species: "predator" + }, + { + fishtype: "sawshark", + age: 105, + birthday: new Date("1900-01-05T01:00:00Z"), + length: 10.0, + picture: new Uint8Array([255, 255, 255, 255, 254]), + species: "dangerous" + } + ] + }; + const serializedSawshark = serializer.serialize(mapper, sawshark, "result"); + assert.equal(serializedSawshark.age, 22); + assert.equal(serializedSawshark["fish.type"], "sawshark"); + assert.equal(serializedSawshark.siblings.length, 2); + assert.equal(serializedSawshark.siblings[0]["fish.type"], "shark"); + assert.equal(serializedSawshark.siblings[0].age, 6); + assert.equal( + serializedSawshark.siblings[0].birthday, + new Date("2012-01-05T01:00:00Z").toISOString() + ); + assert.equal(serializedSawshark.siblings[1]["fish.type"], "sawshark"); + assert.equal(serializedSawshark.siblings[1].age, 105); + assert.equal( + serializedSawshark.siblings[1].birthday, + new Date("1900-01-05T01:00:00Z").toISOString() + ); + assert.equal(serializedSawshark.siblings[1].picture, "//////4="); + assert.equal(serializedSawshark.picture, "//////4="); + }); + + it("should correctly serialize additionalProperties when the mapper knows that additional properties are allowed", function() { + const bodyParameter = { + id: 5, + name: "Funny", + odatalocation: "westus", + additionalProperties1: { + height: 5.61, + weight: 599, + footsize: 11.5 + }, + color: "red", + city: "Seattle", + food: "tikka masala", + birthdate: "2017-12-13T02:29:51.000Z" + }; + const serializer = createSerializer(Mappers); + const mapper = Mappers.PetAP; + const result = serializer.serialize(mapper, bodyParameter, "bodyParameter"); + assert.equal(result.id, 5); + assert.equal(result.eyeColor, "brown"); + assert.isUndefined(result.favoriteFood); + assert.equal(result["@odata.location"], "westus"); + assert.equal(result.color, "red"); + assert.equal(result.city, "Seattle"); + assert.equal(result.food, "tikka masala"); + assert.equal(result.additionalProperties.height, 5.61); + assert.equal(result.additionalProperties.weight, 599); + assert.equal(result.additionalProperties.footsize, 11.5); + assert.equal(result.name, "Funny"); + assert.equal(result.birthdate, "2017-12-13T02:29:51.000Z"); + }); + + it("should allow null when required: true and nullable: true", function() { + const mapper: Mapper = { + required: false, + serializedName: "testmodel", + type: { + name: "Composite", + className: "testmodel", + modelProperties: { + length: { + required: true, + nullable: true, + serializedName: "length", + type: { + name: "Number" + } + } + } + } + }; + + const result = Serializer.serialize(mapper, { length: null }, "testobj"); + assert.exists(result); + }); + + it("should not allow undefined when required: true and nullable: true", function() { + const mapper: Mapper = { + required: false, + serializedName: "testmodel", + type: { + name: "Composite", + className: "testmodel", + modelProperties: { + length: { + required: true, + nullable: true, + serializedName: "length", + type: { + name: "Number" + } + } + } + } + }; + + assert.throws(() => { + Serializer.serialize(mapper, { length: undefined }, "testobj"); + }, "testobj.length cannot be undefined."); + }); + + it("should not allow null when required: true and nullable: false", function() { + const mapper: Mapper = { + required: false, + serializedName: "testmodel", + type: { + name: "Composite", + className: "testmodel", + modelProperties: { + length: { + required: true, + nullable: false, + serializedName: "length", + type: { + name: "Number" + } + } + } + } + }; + + assert.throws(() => { + Serializer.serialize(mapper, { length: undefined }, "testobj"); + }, "testobj.length cannot be null or undefined."); + }); + + it("should not allow undefined when required: true and nullable: false", function() { + const mapper: Mapper = { + required: false, + serializedName: "testmodel", + type: { + name: "Composite", + className: "testmodel", + modelProperties: { + length: { + required: true, + nullable: false, + serializedName: "length", + type: { + name: "Number" + } + } + } + } + }; + + assert.throws(() => { + Serializer.serialize(mapper, { length: undefined }, "testobj"); + }, "testobj.length cannot be null or undefined."); + }); + + it("should not allow null when required: true and nullable is undefined", function() { + const mapper: Mapper = { + serializedName: "foo", + required: true, + type: { + name: "String" + } + }; + assert.throws(() => { + Serializer.serialize(mapper, undefined, "testobj"); + }, "testobj cannot be null or undefined."); + }); + + it("should not allow undefined when required: true and nullable is undefined", function() { + const mapper: Mapper = { + serializedName: "foo", + required: true, + type: { + name: "String" + } + }; + assert.throws(() => { + Serializer.serialize(mapper, undefined, "testobj"); + }, "testobj cannot be null or undefined."); + }); + + it("should allow null when required: false and nullable: true", function() { + const mapper: Mapper = { + serializedName: "foo", + required: false, + nullable: true, + type: { + name: "String" + } + }; + + Serializer.serialize(mapper, undefined, "testobj"); + }); + + it("should not allow null when required: false and nullable: false", function() { + const mapper: Mapper = { + serializedName: "foo", + required: false, + nullable: false, + type: { + name: "String" + } + }; + + assert.throws(() => { + Serializer.serialize(mapper, null, "testobj"); + }, "testobj cannot be null."); + }); + + it("should allow null when required: false and nullable is undefined", function() { + const mapper: Mapper = { + serializedName: "foo", + required: false, + type: { + name: "String" + } + }; + + Serializer.serialize(mapper, undefined, "testobj"); + }); + + it("should allow undefined when required: false and nullable: true", function() { + const mapper: Mapper = { + serializedName: "foo", + required: false, + nullable: true, + type: { + name: "String" + } + }; + + Serializer.serialize(mapper, undefined, "testobj"); + }); + + it("should allow undefined when required: false and nullable: false", function() { + const mapper: Mapper = { + serializedName: "fooType", + type: { + name: "Composite", + className: "fooType", + modelProperties: { + length: { + serializedName: "length", + required: false, + nullable: false, + type: { + name: "String" + } + } + } + } + }; + + Serializer.serialize(mapper, { length: undefined }, "testobj"); + }); + + it("should allow undefined when required: false and nullable is undefined", function() { + const mapper: Mapper = { + serializedName: "foo", + required: false, + type: { + name: "String" + } + }; + + Serializer.serialize(mapper, undefined, "testobj"); + }); + }); + + describe("deserialize", function() { + it("should correctly deserialize a Date if the type is 'any'", function() { + const mapper: Mapper = { + type: { name: "any" }, + required: false, + serializedName: "any" + }; + const d = new Date(); + const deserializedObject = Serializer.deserialize(mapper, d, "anyResponseBody"); + assert.equal(deserializedObject, d); + }); + it("should correctly deserialize an array if the type is 'any'", function() { + const mapper: Mapper = { + type: { name: "any" }, + required: false, + serializedName: "any" + }; + const buf = [1, 2, 3]; + const deserializedObject = Serializer.deserialize(mapper, buf, "anyBody"); + assert.equal(deserializedObject, buf); + }); + it("should correctly deserialize a uuid", function() { + const mapper: Mapper = { + type: { name: "Uuid" }, + required: false, + serializedName: "Uuid" + }; + const serializedObject = Serializer.deserialize(mapper, valid_uuid, "uuidBody"); + assert.equal(serializedObject, valid_uuid); + }); + it("should correctly deserialize a composite type", function() { + const serializer = createSerializer(Mappers); + const mapper = Mappers.Product; + const responseBody = { + id: 101, + name: "TestProduct", + properties: { + provisioningState: "Succeeded" + }, + tags: { + tag1: "value1", + tag2: "value2" + }, + dispatchTime: new Date("2015-01-01T12:35:36.009Z"), + invoiceInfo: { + invoiceId: 1002, + invDate: "2015-12-25", + invProducts: [ + { + Product1: { + id: 101, + name: "TestProduct" + } + }, + { + Product2: { + id: 104, + name: "TestProduct1" + } + } + ] + }, + subProducts: [ + { + subId: 102, + subName: "SubProduct1", + makeTime: new Date("2015-12-21T01:01:01"), + invoiceInfo: { + invoiceId: 1002, + invDate: "2015-12-25" + } + }, + { + subId: 103, + subName: "SubProduct2", + makeTime: new Date("2015-12-21T01:01:01"), + invoiceInfo: { + invoiceId: 1003, + invDate: "2015-12-25" + } + } + ] + }; + const deserializedProduct = serializer.deserialize(mapper, responseBody, "responseBody"); + for (const prop in deserializedProduct) { + if (prop === "provisioningState") { + assert.equal( + deserializedProduct.provisioningState, + responseBody.properties.provisioningState + ); + } else if (prop === "id") { + assert.equal(deserializedProduct[prop], responseBody.id); + } else if (prop === "name") { + assert.equal(deserializedProduct[prop], responseBody.name); + } else if (prop === "tags") { + assert.equal( + JSON.stringify(deserializedProduct[prop]), + JSON.stringify(responseBody.tags) + ); + } else if (prop === "dispatchTime") { + assert.equal( + JSON.stringify(deserializedProduct[prop]), + JSON.stringify(responseBody.dispatchTime) + ); + } else if (prop === "invoiceInfo") { + assert.equal( + JSON.stringify(deserializedProduct[prop]).length - + JSON.stringify(responseBody.invoiceInfo).length, + 10 + ); + } else if (prop === "subProducts") { + assert.equal( + JSON.stringify(deserializedProduct[prop]).length - + JSON.stringify(responseBody.subProducts).length, + 20 + ); + } + } + }); + + it("should correctly deserialize a pageable type without nextLink", function() { + const serializer = createSerializer(Mappers); + const mapper = Mappers.ProductListResult; + const responseBody = { + value: [ + { + id: 101, + name: "TestProduct", + properties: { + provisioningState: "Succeeded" + } + }, + { + id: 104, + name: "TestProduct1", + properties: { + provisioningState: "Failed" + } + } + ] + }; + const deserializedProduct = serializer.deserialize(mapper, responseBody, "responseBody"); + assert.isTrue(Array.isArray(deserializedProduct)); + assert.equal(deserializedProduct.length, 2); + for (let i = 0; i < deserializedProduct.length; i++) { + if (i === 0) { + assert.equal(deserializedProduct[i].id, 101); + assert.equal(deserializedProduct[i].name, "TestProduct"); + assert.equal(deserializedProduct[i].provisioningState, "Succeeded"); + } else if (i === 1) { + assert.equal(deserializedProduct[i].id, 104); + assert.equal(deserializedProduct[i].name, "TestProduct1"); + assert.equal(deserializedProduct[i].provisioningState, "Failed"); + } + } + }); + + it("should correctly deserialize a pageable type with nextLink", function() { + const serializer = createSerializer(Mappers); + const mapper = Mappers.ProductListResultNextLink; + const responseBody = { + value: [ + { + id: 101, + name: "TestProduct", + properties: { + provisioningState: "Succeeded" + } + }, + { + id: 104, + name: "TestProduct1", + properties: { + provisioningState: "Failed" + } + } + ], + nextLink: "https://helloworld.com" + }; + const deserializedProduct = serializer.deserialize(mapper, responseBody, "responseBody"); + assert.isTrue(Array.isArray(deserializedProduct)); + assert.equal(deserializedProduct.length, 2); + assert.equal(deserializedProduct.nextLink, "https://helloworld.com"); + for (let i = 0; i < deserializedProduct.length; i++) { + if (i === 0) { + assert.equal(deserializedProduct[i].id, 101); + assert.equal(deserializedProduct[i].name, "TestProduct"); + assert.equal(deserializedProduct[i].provisioningState, "Succeeded"); + } else if (i === 1) { + assert.equal(deserializedProduct[i].id, 104); + assert.equal(deserializedProduct[i].name, "TestProduct1"); + assert.equal(deserializedProduct[i].provisioningState, "Failed"); + } + } + }); + + it("should correctly deserialize object version of polymorphic discriminator", function() { + const serializer = createSerializer(Mappers); + const mapper = Mappers.Fish; + const responseBody = { + "fish.type": "sawshark", + age: 22, + birthday: new Date("2012-01-05T01:00:00Z").toISOString(), + species: "king", + length: 1.0, + picture: "/////g==", + siblings: [ + { + "fish.type": "shark", + age: 6, + birthday: new Date("2012-01-05T01:00:00Z"), + length: 20.0, + species: "predator" + }, + { + "fish.type": "sawshark", + age: 105, + birthday: new Date("1900-01-05T01:00:00Z").toISOString(), + length: 10.0, + picture: "/////g==", + species: "dangerous" + } + ] + }; + const deserializedSawshark = serializer.deserialize(mapper, responseBody, "responseBody"); + assert.equal(deserializedSawshark.age, 22); + assert.equal(deserializedSawshark.fishtype, "sawshark"); + + assert.instanceOf(deserializedSawshark.picture, Uint8Array); + assert.equal(deserializedSawshark.picture.length, 4); + assert.equal(deserializedSawshark.picture[0], 255); + assert.equal(deserializedSawshark.picture[1], 255); + assert.equal(deserializedSawshark.picture[2], 255); + assert.equal(deserializedSawshark.picture[3], 254); + + assert.equal(deserializedSawshark.siblings.length, 2); + assert.equal(deserializedSawshark.siblings[0].fishtype, "shark"); + assert.equal(deserializedSawshark.siblings[0].age, 6); + assert.equal( + deserializedSawshark.siblings[0].birthday.toISOString(), + "2012-01-05T01:00:00.000Z" + ); + assert.equal(deserializedSawshark.siblings[1].fishtype, "sawshark"); + assert.equal(deserializedSawshark.siblings[1].age, 105); + assert.equal( + deserializedSawshark.siblings[1].birthday.toISOString(), + "1900-01-05T01:00:00.000Z" + ); + }); + + it("should correctly deserialize an array of array of object types", function() { + const mapper: Mapper = { + serializedName: "arrayObj", + required: true, + type: { + name: "Sequence", + element: { + serializedName: "ObjectElementType", + type: { + name: "Sequence", + element: { + serializedName: "ObjectElementType", + type: { + name: "Object" + } + } + } + } + } + }; + const array = [[1], ["2"], [1, "2", {}, true, []]]; + const deserializedArray = Serializer.deserialize(mapper, array, mapper.serializedName!); + assert.deepEqual(array, deserializedArray); + }); + + it("should correctly deserialize without failing when encountering unrecognized discriminator", function() { + const serializer = createSerializer(Mappers); + const mapper = Mappers.Fish; + const responseBody = { + "fish.type": "sawshark", + age: 22, + birthday: new Date("2012-01-05T01:00:00Z").toISOString(), + species: "king", + length: 1.0, + picture: "/////g==", + siblings: [ + { + "fish.type": "mutatedshark", + age: 105, + birthday: new Date("1900-01-05T01:00:00Z").toISOString(), + length: 10.0, + picture: "/////g==", + species: "dangerous", + siblings: [ + { + "fish.type": "mutatedshark", + age: 6, + length: 20.0, + species: "predator" + } + ] + } + ] + }; + const deserializedSawshark = serializer.deserialize(mapper, responseBody, "responseBody"); + assert.equal(deserializedSawshark.siblings.length, 1); + assert.equal(deserializedSawshark.siblings[0].fishtype, "mutatedshark"); + assert.equal(deserializedSawshark.siblings[0].species, "dangerous"); + assert.equal(deserializedSawshark.siblings[0].birthday, "1900-01-05T01:00:00.000Z"); + assert.equal(deserializedSawshark.siblings[0].age, 105); + assert.equal(deserializedSawshark.siblings[0].siblings[0].fishtype, "mutatedshark"); + assert.equal(deserializedSawshark.siblings[0].siblings[0].species, "predator"); + assert.notProperty(deserializedSawshark.siblings[0].siblings[0], "birthday"); + assert.equal(deserializedSawshark.siblings[0].siblings[0].age, 6); + }); + + it("should correctly deserialize additionalProperties when the mapper knows that additional properties are allowed", function() { + const responseBody = { + id: 5, + name: "Funny", + status: true, + "@odata.location": "westus", + additionalProperties: { + height: 5.61, + weight: 599, + footsize: 11.5 + }, + color: "red", + city: "Seattle", + food: "tikka masala", + birthdate: "2017-12-13T02:29:51Z" + }; + const serializer = createSerializer(Mappers); + const mapper = Mappers.PetAP; + const result = serializer.deserialize(mapper, responseBody, "responseBody"); + assert.equal(result.id, 5); + assert.equal(result.status, true); + assert.equal(result.eyeColor, "brown"); + assert.equal(result.favoriteFood, "bones"); + assert.equal(result.odatalocation, "westus"); + assert.equal(result.color, "red"); + assert.equal(result.city, "Seattle"); + assert.equal(result.food, "tikka masala"); + assert.equal(result.birthdate, "2017-12-13T02:29:51Z"); + assert.equal(result.additionalProperties1.height, 5.61); + assert.equal(result.additionalProperties1.weight, 599); + assert.equal(result.additionalProperties1.footsize, 11.5); + assert.equal(result.name, "Funny"); + }); + + it("should deserialize headerCollectionPrefix", function() { + const mapper: CompositeMapper = { + serializedName: "something", + type: { + name: "Composite", + className: "CustomHeadersType", + modelProperties: { + metadata: { + serializedName: "metadata", + type: { + name: "Dictionary", + value: { + serializedName: "element", + type: { + name: "String" + } + } + }, + headerCollectionPrefix: "foo-bar-" + }, + unrelated: { + serializedName: "unrelated", + type: { + name: "Number" + } + } + } + } + }; + + const rawHeaders = { + "foo-bar-alpha": "hello", + "foo-bar-beta": "world", + unrelated: "42" + }; + + const expected = { + metadata: { + alpha: "hello", + beta: "world" + }, + unrelated: 42 + }; + const actual = Serializer.deserialize(mapper, rawHeaders, "headers"); + assert.deepEqual(actual, expected); + }); + + describe("composite type", () => { + it("should be deserialized properly when polymorphicDiscriminator specified", function() { + const fish: CompositeMapper = { + serializedName: "Fish", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fishtype", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Fish", + modelProperties: { + fishtype: { + required: true, + serializedName: "fishtype", + type: { + name: "String" + } + } + } + } + }; + + const shark: CompositeMapper = { + serializedName: "shark", + type: { + name: "Composite", + polymorphicDiscriminator: fish.type.polymorphicDiscriminator, + uberParent: "Fish", + className: "Shark", + modelProperties: { + ...fish.type.modelProperties, + age: { + serializedName: "age", + type: { + name: "Number" + } + } + } + } + }; + + const mappers = { + Fish: fish, + Shark: shark, + discriminators: { + Fish: fish, + "Fish.shark": shark + } + }; + const serializer = createSerializer(mappers); + const result = serializer.deserialize( + fish, + { + fishtype: "shark", + age: 10 + }, + "" + ); + + assert.strictEqual("shark", result.fishtype); + assert.strictEqual(10, result.age); + }); + + it("should be deserialized properly when polymorphicDiscriminator specified in nested property", function() { + const fish: CompositeMapper = { + serializedName: "Fish", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fishtype", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Fish", + modelProperties: { + sibling: { + required: false, + serializedName: "sibling", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fishtype", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Fish" + } + }, + fishtype: { + required: true, + serializedName: "fishtype", + type: { + name: "String" + } + } + } + } + }; + + const shark: CompositeMapper = { + serializedName: "shark", + type: { + name: "Composite", + polymorphicDiscriminator: fish.type.polymorphicDiscriminator, + uberParent: "Fish", + className: "Shark", + modelProperties: { + ...fish.type.modelProperties, + age: { + serializedName: "age", + type: { + name: "Number" + } + } + } + } + }; + + const mappers = { + Fish: fish, + Shark: shark, + discriminators: { + Fish: fish, + "Fish.shark": shark + } + }; + const serializer = createSerializer(mappers); + const result = serializer.deserialize( + fish, + { + fishtype: "shark", + age: 10, + sibling: { fishtype: "shark", age: 15 } + }, + "" + ); + + assert.strictEqual("shark", result.fishtype); + assert.strictEqual(10, result.age); + assert.strictEqual("shark", result.sibling.fishtype); + assert.strictEqual(15, result.sibling.age); + }); + + it("should be deserialized properly when polymorphicDiscriminator specified in the parent", function() { + const fish: CompositeMapper = { + serializedName: "Fish", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fishtype", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Fish", + modelProperties: { + sibling: { + required: false, + serializedName: "sibling", + type: { + name: "Composite", + uberParent: "Fish", + className: "Fish" + } + }, + fishtype: { + required: true, + serializedName: "fishtype", + type: { + name: "String" + } + } + } + } + }; + + const shark: CompositeMapper = { + serializedName: "shark", + type: { + name: "Composite", + polymorphicDiscriminator: fish.type.polymorphicDiscriminator, + uberParent: "Fish", + className: "Shark", + modelProperties: { + ...fish.type.modelProperties, + age: { + serializedName: "age", + type: { + name: "Number" + } + } + } + } + }; + + const mappers = { + Fish: fish, + Shark: shark, + discriminators: { + Fish: fish, + "Fish.shark": shark + } + }; + const serializer = createSerializer(mappers); + const result = serializer.deserialize( + fish, + { + fishtype: "shark", + age: 10, + sibling: { fishtype: "shark", age: 15 } + }, + "" + ); + + assert.strictEqual("shark", result.fishtype); + assert.strictEqual(10, result.age); + assert.strictEqual("shark", result.sibling.fishtype); + assert.strictEqual(15, result.sibling.age); + }); + + it("should be deserialized properly when responseBody is an empty string", function() { + const fish: CompositeMapper = { + serializedName: "Fish", + type: { + name: "Composite", + className: "Fish", + modelProperties: {} + } + }; + + const mappers = { + Fish: fish + }; + const serializer = createSerializer(mappers); + const result: any = serializer.deserialize(fish, "", "mockFishProperty"); + + assert.deepEqual(result, {}); + }); + }); + + describe("polymorphic composite type array", () => { + const Fish: CompositeMapper = { + serializedName: "Fish", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fishtype", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Fish", + modelProperties: { + species: { + serializedName: "species", + type: { + name: "String" + } + }, + length: { + required: true, + serializedName: "length", + type: { + name: "Number" + } + }, + siblings: { + serializedName: "siblings", + type: { + name: "Sequence", + element: { + type: { + name: "Composite", + className: "Fish" + } + } + } + }, + fishtype: { + required: true, + serializedName: "fishtype", + type: { + name: "String" + } + } + } + } + }; + + const Salmon: CompositeMapper = { + serializedName: "salmon", + type: { + name: "Composite", + polymorphicDiscriminator: Fish.type.polymorphicDiscriminator, + uberParent: "Fish", + className: "Salmon", + modelProperties: { + ...Fish.type.modelProperties, + location: { + serializedName: "location", + type: { + name: "String" + } + }, + iswild: { + serializedName: "iswild", + type: { + name: "Boolean" + } + } + } + } + }; + + const Shark: CompositeMapper = { + serializedName: "shark", + type: { + name: "Composite", + polymorphicDiscriminator: Fish.type.polymorphicDiscriminator, + uberParent: "Fish", + className: "Shark", + modelProperties: { + ...Fish.type.modelProperties, + age: { + serializedName: "age", + type: { + name: "Number" + } + }, + birthday: { + required: true, + serializedName: "birthday", + type: { + name: "DateTime" + } + } + } + } + }; + + const Sawshark: CompositeMapper = { + serializedName: "sawshark", + type: { + name: "Composite", + polymorphicDiscriminator: Fish.type.polymorphicDiscriminator, + uberParent: "Fish", + className: "Sawshark", + modelProperties: { + ...Shark.type.modelProperties, + picture: { + serializedName: "picture", + type: { + name: "ByteArray" + } + } + } + } + }; + + const Goblinshark: CompositeMapper = { + serializedName: "goblin", + type: { + name: "Composite", + polymorphicDiscriminator: Fish.type.polymorphicDiscriminator, + uberParent: "Fish", + className: "Goblinshark", + modelProperties: { + ...Shark.type.modelProperties, + jawsize: { + serializedName: "jawsize", + type: { + name: "Number" + } + }, + color: { + serializedName: "color", + defaultValue: "gray", + type: { + name: "String" + } + } + } + } + }; + + const mappers = { + discriminators: { + Fish: Fish, + "Fish.salmon": Salmon, + "Fish.shark": Shark, + "Fish.sawshark": Sawshark, + "Fish.goblin": Goblinshark + }, + Fish, + Salmon, + Shark, + Sawshark, + Goblinshark + }; + + it("should be deserialized with child properties", function() { + const body = { + fishtype: "salmon", + location: "alaska", + iswild: true, + species: "king", + length: 1, + siblings: [ + { + fishtype: "shark", + age: 6, + birthday: "2012-01-05T01:00:00Z", + length: 20, + species: "predator" + }, + { + fishtype: "sawshark", + age: 105, + birthday: "1900-01-05T01:00:00Z", + length: 10, + picture: "//////4=", + species: "dangerous" + }, + { + fishtype: "goblin", + age: 1, + birthday: "2015-08-08T00:00:00Z", + length: 30, + species: "scary", + jawsize: 5, + color: "pinkish-gray" + } + ] + }; + + const serializer = createSerializer(mappers); + const result = serializer.deserialize(Fish, body, ""); + + assert.equal(result.siblings.length, 3); + assert(result.siblings[1].picture); + assert.equal(result.siblings[2].jawsize, 5); + }); + + it("should be serialized with child properties", function() { + const body = { + fishtype: "salmon", + location: "alaska", + iswild: true, + species: "king", + length: 1.0, + siblings: [ + { + fishtype: "shark", + age: 6, + birthday: new Date("2012-01-05T01:00:00Z"), + length: 20.0, + species: "predator" + }, + { + fishtype: "sawshark", + age: 105, + birthday: new Date("1900-01-05T01:00:00Z"), + length: 10.0, + picture: new Uint8Array([255, 255, 255, 255, 254]), + species: "dangerous" + }, + { + fishtype: "goblin", + color: "pinkish-gray", + age: 1, + length: 30, + species: "scary", + birthday: new Date("2015-08-08T00:00:00Z"), + jawsize: 5 + } + ] + }; + + const serializer = createSerializer(mappers); + const result = serializer.serialize(Fish, body, ""); + + assert.equal(result.siblings.length, 3); + assert(result.siblings[1].picture); + assert.equal(result.siblings[2].jawsize, 5); + }); + }); + }); +}); diff --git a/sdk/core/core-client/test/serviceClient.spec.ts b/sdk/core/core-client/test/serviceClient.spec.ts new file mode 100644 index 000000000000..306deb5a917d --- /dev/null +++ b/sdk/core/core-client/test/serviceClient.spec.ts @@ -0,0 +1,930 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "chai"; +import { + ServiceClient, + OperationRequest, + createSerializer, + DictionaryMapper, + QueryCollectionFormat, + ParameterPath, + MapperTypeNames, + OperationArguments, + Mapper, + CompositeMapper, + OperationSpec +} from "../src"; +import { + createHttpHeaders, + createEmptyPipeline, + HttpsClient, + createPipelineRequest +} from "@azure/core-https"; +import { serializeRequestBody } from "../src/serviceClient"; +import { getOperationArgumentValueFromParameter } from "../src/operationHelpers"; +import { deserializationPolicy } from "../src/deserializationPolicy"; + +describe("ServiceClient", function() { + it("should serialize headerCollectionPrefix", async function() { + const expected = { + "foo-bar-alpha": "hello", + "foo-bar-beta": "world", + unrelated: "42" + }; + + let request: OperationRequest; + const client = new ServiceClient({ + httpsClient: { + sendRequest: (req) => { + request = req; + return Promise.resolve({ request, status: 200, headers: createHttpHeaders() }); + } + }, + pipeline: createEmptyPipeline() + }); + + await client.sendOperationRequest( + { + metadata: { + alpha: "hello", + beta: "world" + }, + unrelated: 42 + }, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + headerParameters: [ + { + parameterPath: "metadata", + mapper: { + serializedName: "metadata", + type: { + name: "Dictionary", + value: { + type: { + name: "String" + } + } + }, + headerCollectionPrefix: "foo-bar-" + } as DictionaryMapper + }, + { + parameterPath: "unrelated", + mapper: { + serializedName: "unrelated", + type: { + name: "Number" + } + } + } + ], + responses: { + 200: {} + } + } + ); + + assert(request!); + assert.deepEqual(request!.headers.toJSON(), expected); + }); + + it("responses should not show the _response property when serializing", async function() { + let request: OperationRequest; + const client = new ServiceClient({ + httpsClient: { + sendRequest: (req) => { + request = req; + return Promise.resolve({ request, status: 200, headers: createHttpHeaders() }); + } + }, + pipeline: createEmptyPipeline() + }); + + const response = await client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + headerParameters: [], + responses: { + 200: {} + } + } + ); + + assert(request!); + // _response should be not enumerable + assert.strictEqual(JSON.stringify(response), "{}"); + }); + + it("should serialize collection:csv query parameters", async function() { + await testSendOperationRequest(["1", "2", "3"], "CSV", false, "?q=1,2,3"); + }); + + it("should serialize collection:csv query parameters with commas & skipEncoding true", async function() { + await testSendOperationRequest(["1,2", "3,4", "5"], "CSV", true, "?q=1,2,3,4,5"); + }); + + it("should serialize collection:csv query parameters with commas", async function() { + await testSendOperationRequest(["1,2", "3,4", "5"], "CSV", false, "?q=1%2C2,3%2C4,5"); + }); + + it("should serialize collection:csv query parameters with undefined and null", async function() { + await testSendOperationRequest(["1,2", undefined, "5"], "CSV", false, "?q=1%2C2,,5"); + await testSendOperationRequest(["1,2", null, "5"], "CSV", false, "?q=1%2C2,,5"); + }); + + it("should serialize collection:tsv query parameters with undefined and null", async function() { + await testSendOperationRequest(["1,2", undefined, "5"], "TSV", false, "?q=1%2C2%09%095"); + await testSendOperationRequest(["1,2", null, "5"], "TSV", false, "?q=1%2C2%09%095"); + await testSendOperationRequest(["1,2", "3", "5"], "TSV", false, "?q=1%2C2%093%095"); + }); + + it("should serialize collection:ssv query parameters with undefined and null", async function() { + await testSendOperationRequest(["1,2", undefined, "5"], "SSV", false, "?q=1%2C2%20%205"); + await testSendOperationRequest(["1,2", null, "5"], "SSV", false, "?q=1%2C2%20%205"); + await testSendOperationRequest(["1,2", "3", "5"], "SSV", false, "?q=1%2C2%203%205"); + }); + + it("should serialize collection:multi query parameters", async function() { + await testSendOperationRequest(["1", "2", "3"], "Multi", false, "?q=1&q=2&q=3"); + await testSendOperationRequest(["1,2", "3,4", "5"], "Multi", false, "?q=1%2C2&q=3%2C4&q=5"); + await testSendOperationRequest(["1,2", "3,4", "5"], "Multi", true, "?q=1,2&q=3,4&q=5"); + }); + + it("should deserialize response bodies", async function() { + let request: OperationRequest; + const httpsClient: HttpsClient = { + sendRequest: (req) => { + request = req; + return Promise.resolve({ + request, + status: 200, + headers: createHttpHeaders(), + bodyAsText: "[1,2,3]" + }); + } + }; + + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy()); + const client1 = new ServiceClient({ + httpsClient, + pipeline + }); + + const res = await client1.sendOperationRequest( + {}, + { + serializer: createSerializer(), + httpMethod: "GET", + baseUrl: "https://example.com", + responses: { + 200: { + bodyMapper: { + type: { + name: "Sequence", + element: { + type: { + name: "Number" + } + } + } + } + } + } + } + ); + + assert.strictEqual(res._response.status, 200); + assert.deepStrictEqual(res.slice(), [1, 2, 3]); + }); + + describe("serializeRequestBody()", () => { + it("should serialize a JSON String request body", () => { + const httpRequest = createPipelineRequest({ url: "https://example.com" }); + serializeRequestBody( + httpRequest, + { + bodyArg: "body value" + }, + { + httpMethod: "POST", + requestBody: { + parameterPath: "bodyArg", + mapper: { + required: true, + serializedName: "bodyArg", + type: { + name: MapperTypeNames.String + } + } + }, + responses: { 200: {} }, + serializer: createSerializer() + } + ); + assert.strictEqual(httpRequest.body, `"body value"`); + }); + + it("should serialize a JSON ByteArray request body", () => { + const httpRequest = createPipelineRequest({ url: "https://example.com" }); + serializeRequestBody( + httpRequest, + { + bodyArg: stringToByteArray("Javascript") + }, + { + httpMethod: "POST", + requestBody: { + parameterPath: "bodyArg", + mapper: { + required: true, + serializedName: "bodyArg", + type: { + name: MapperTypeNames.ByteArray + } + } + }, + responses: { 200: {} }, + serializer: createSerializer() + } + ); + assert.strictEqual(httpRequest.body, `"SmF2YXNjcmlwdA=="`); + }); + + it("should serialize a JSON Stream request body", () => { + const httpRequest = createPipelineRequest({ url: "https://example.com" }); + serializeRequestBody( + httpRequest, + { + bodyArg: "body value" + }, + { + httpMethod: "POST", + requestBody: { + parameterPath: "bodyArg", + mapper: { + required: true, + serializedName: "bodyArg", + type: { + name: MapperTypeNames.Stream + } + } + }, + responses: { 200: {} }, + serializer: createSerializer() + } + ); + assert.strictEqual(httpRequest.body, "body value"); + }); + + it.skip("should serialize an XML String request body", () => { + const httpRequest = createPipelineRequest({ url: "https://example.com" }); + serializeRequestBody( + httpRequest, + { + bodyArg: "body value" + }, + { + httpMethod: "POST", + requestBody: { + parameterPath: "bodyArg", + mapper: { + required: true, + serializedName: "bodyArg", + type: { + name: MapperTypeNames.String + } + } + }, + responses: { 200: {} }, + serializer: createSerializer(), + isXML: true + } + ); + assert.strictEqual( + httpRequest.body, + `body value` + ); + }); + + it.skip("should serialize an XML ByteArray request body", () => { + const httpRequest = createPipelineRequest({ url: "https://example.com" }); + serializeRequestBody( + httpRequest, + { + bodyArg: stringToByteArray("Javascript") + }, + { + httpMethod: "POST", + requestBody: { + parameterPath: "bodyArg", + mapper: { + required: true, + serializedName: "bodyArg", + type: { + name: MapperTypeNames.ByteArray + } + } + }, + responses: { 200: {} }, + serializer: createSerializer(), + isXML: true + } + ); + assert.strictEqual( + httpRequest.body, + `SmF2YXNjcmlwdA==` + ); + }); + + it.skip("should serialize an XML Stream request body", () => { + const httpRequest = createPipelineRequest({ url: "https://example.com" }); + serializeRequestBody( + httpRequest, + { + bodyArg: "body value" + }, + { + httpMethod: "POST", + requestBody: { + parameterPath: "bodyArg", + mapper: { + required: true, + serializedName: "bodyArg", + type: { + name: MapperTypeNames.Stream + } + } + }, + responses: { 200: {} }, + serializer: createSerializer(), + isXML: true + } + ); + assert.strictEqual(httpRequest.body, "body value"); + }); + + it("should serialize a string send to a text/plain endpoint as just a string", () => { + const httpRequest = createPipelineRequest({ url: "https://example.com" }); + serializeRequestBody( + httpRequest, + { + bodyArg: "body value" + }, + { + httpMethod: "POST", + contentType: "text/plain; charset=UTF-8", + requestBody: { + parameterPath: "bodyArg", + mapper: { + required: true, + serializedName: "bodyArg", + type: { + name: MapperTypeNames.String + } + } + }, + responses: { 200: {} }, + serializer: createSerializer() + } + ); + assert.strictEqual(httpRequest.body, "body value"); + }); + + it("should serialize a string send with the mediaType 'text' as just a string", () => { + const httpRequest = createPipelineRequest({ url: "https://example.com" }); + serializeRequestBody( + httpRequest, + { + bodyArg: "body value" + }, + { + httpMethod: "POST", + mediaType: "text", + requestBody: { + parameterPath: "bodyArg", + mapper: { + required: true, + serializedName: "bodyArg", + type: { + name: MapperTypeNames.String + } + } + }, + responses: { 200: {} }, + serializer: createSerializer() + } + ); + assert.strictEqual(httpRequest.body, "body value"); + }); + }); + + describe("getOperationArgumentValueFromParameter()", () => { + it("should return undefined when the parameter path isn't found in the operation arguments or service client", () => { + const operationArguments: OperationArguments = {}; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter(operationArguments, { + parameterPath, + mapper: parameterMapper + }); + assert.strictEqual(parameterValue, undefined); + }); + + it("should return undefined when the parameter path is found in the operation arguments but is undefined and doesn't have a default value", () => { + const operationArguments: OperationArguments = { + myParameter: undefined + }; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter(operationArguments, { + parameterPath, + mapper: parameterMapper + }); + assert.strictEqual(parameterValue, undefined); + }); + + it("should return null when the parameter path is null in the operation arguments but is undefined and doesn't have a default value", () => { + const operationArguments: OperationArguments = { + myParameter: null + }; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter(operationArguments, { + parameterPath, + mapper: parameterMapper + }); + + assert.strictEqual(parameterValue, null); + }); + + it("should return the operation argument value when the parameter path is found in the operation arguments", () => { + const operationArguments: OperationArguments = { + myParameter: 20 + }; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter(operationArguments, { + mapper: parameterMapper, + parameterPath + }); + assert.strictEqual(parameterValue, 20); + }); + + // TODO: confirm if we need this support from RequestOptionsBase + // it("should return the options operation argument value when the parameter path is found in the optional operation arguments", () => { + // const operationArguments: OperationArguments = { + // options: { + // myParameter: 1 + // } + // }; + // const parameterPath: ParameterPath = ["options", "myParameter"]; + // const parameterMapper: Mapper = { + // serializedName: "my-parameter", + // type: { + // name: MapperTypeNames.Number + // } + // }; + // const parameterValue: any = getOperationArgumentValueFromParameter( + // operationArguments, + // { + // parameterPath, + // mapper: parameterMapper + // } + // ); + // assert.strictEqual(parameterValue, 1); + // }); + + it("should return the service client value when the parameter path is found in the service client", () => { + const serviceClient = new ServiceClient(); + (serviceClient as any)["myParameter"] = 21; + const operationArguments: OperationArguments = {}; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter( + operationArguments, + { + parameterPath, + mapper: parameterMapper + }, + + serviceClient + ); + assert.strictEqual(parameterValue, 21); + }); + + it("should return the operation argument value when the parameter path is found in both the operation arguments and the service client", () => { + const serviceClient = new ServiceClient(); + (serviceClient as any)["myParameter"] = 21; + const operationArguments: OperationArguments = { + myParameter: 22 + }; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter( + operationArguments, + { + parameterPath, + mapper: parameterMapper + }, + + serviceClient + ); + assert.strictEqual(parameterValue, 22); + }); + + it("should return the default value when it is a constant and the parameter path doesn't exist in other places", () => { + const operationArguments: OperationArguments = {}; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + isConstant: true, + defaultValue: 1, + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter(operationArguments, { + parameterPath, + mapper: parameterMapper + }); + assert.strictEqual(parameterValue, 1); + }); + + it("should return the default value when it is a constant and the parameter path exists in other places", () => { + const serviceClient = new ServiceClient(); + (serviceClient as any)["myParameter"] = 1; + const operationArguments: OperationArguments = { + myParameter: 2 + // TODO: confirm if we need this support from RequestOptionsBase + // options: { + // myParameter: 3 + // } + }; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + isConstant: true, + defaultValue: 4, + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter( + operationArguments, + { + parameterPath, + mapper: parameterMapper + }, + serviceClient + ); + assert.strictEqual(parameterValue, 4); + }); + + it("should return undefined when the parameter path isn't found in the operation arguments or the service client, the parameter is optional, and it has a default value", () => { + const serviceClient = new ServiceClient(); + const operationArguments: OperationArguments = {}; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + defaultValue: 21, + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter( + operationArguments, + { + parameterPath, + mapper: parameterMapper + }, + serviceClient + ); + assert.strictEqual(parameterValue, undefined); + }); + + it("should return the default value when the parameter path isn't found in the operation arguments or the service client, the parameter is required, and it has a default value", () => { + const serviceClient = new ServiceClient(); + const operationArguments: OperationArguments = {}; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + defaultValue: 21, + required: true, + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter( + operationArguments, + { + parameterPath, + mapper: parameterMapper + }, + serviceClient + ); + assert.strictEqual(parameterValue, 21); + }); + + it("should return the default value when the parameter path is partially found in the operation arguments, the parameter is required, and it has a default value", () => { + const operationArguments: OperationArguments = { + myParameter: { + differentProperty: "hello" + } + }; + const parameterPath: ParameterPath = ["myParameter", "myProperty"]; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + defaultValue: 21, + required: true, + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter(operationArguments, { + parameterPath, + mapper: parameterMapper + }); + assert.strictEqual(parameterValue, 21); + }); + + it("should return undefined when the parameter path is partially found in the operation arguments, the parameter is optional, and it has a default value", () => { + const operationArguments: OperationArguments = { + myParameter: { + differentProperty: "hello" + } + }; + const parameterPath: ParameterPath = ["myParameter", "myProperty"]; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + defaultValue: 21, + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter(operationArguments, { + parameterPath, + mapper: parameterMapper + }); + assert.strictEqual(parameterValue, undefined); + }); + + it("should return the default value when the parameter path is not found in the options operation arguments and it has a default value", () => { + const operationArguments: OperationArguments = {}; + const parameterPath: ParameterPath = ["options", "myParameter"]; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + defaultValue: 21, + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter(operationArguments, { + parameterPath, + mapper: parameterMapper + }); + assert.strictEqual(parameterValue, 21); + }); + + it("should return undefined when the parameter path is not found as a property on a parameter within the options operation arguments and it has a default value", () => { + const operationArguments: OperationArguments = {}; + const parameterPath: ParameterPath = ["options", "myParameter", "myProperty"]; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + defaultValue: 21, + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter(operationArguments, { + parameterPath, + mapper: parameterMapper + }); + assert.strictEqual(parameterValue, undefined); + }); + + it("should return null when the parameter path is null in the operation arguments but is undefined and it has a default value", () => { + const operationArguments: OperationArguments = { + myParameter: null + }; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + defaultValue: 2, + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter(operationArguments, { + parameterPath, + mapper: parameterMapper + }); + + assert.strictEqual(parameterValue, null); + }); + + it("should return the service client value when the parameter path is found in the service client and it has a default value", () => { + const serviceClient = new ServiceClient(); + (serviceClient as any)["myParameter"] = 5; + const operationArguments: OperationArguments = {}; + const parameterPath: ParameterPath = "myParameter"; + const parameterMapper: Mapper = { + serializedName: "my-parameter", + defaultValue: 2, + type: { + name: MapperTypeNames.Number + } + }; + const parameterValue: any = getOperationArgumentValueFromParameter( + operationArguments, + { + parameterPath, + mapper: parameterMapper + }, + serviceClient + ); + + assert.strictEqual(parameterValue, 5); + }); + }); + + it("should deserialize error response headers", async function() { + const BodyMapper: CompositeMapper = { + serializedName: "getproperties-body", + type: { + name: "Composite", + className: "PropertiesBody", + modelProperties: { + message: { + type: { + name: "String" + } + } + } + } + }; + + const HeadersMapper: CompositeMapper = { + serializedName: "getproperties-headers", + type: { + name: "Composite", + className: "PropertiesHeaders", + modelProperties: { + errorCode: { + serializedName: "x-ms-error-code", + type: { + name: "String" + } + } + } + } + }; + + const serializer = createSerializer(HeadersMapper, true); + + const operationSpec: OperationSpec = { + httpMethod: "GET", + responses: { + default: { + headersMapper: HeadersMapper, + bodyMapper: BodyMapper + } + }, + baseUrl: "https://example.com", + serializer + }; + + let request: OperationRequest = createPipelineRequest({ url: "https://example.com" }); + request.additionalInfo = { + operationSpec + }; + + const httpsClient: HttpsClient = { + sendRequest: (req) => { + request = req; + return Promise.resolve({ + request, + status: 500, + headers: createHttpHeaders({ + "x-ms-error-code": "InvalidResourceNameHeader" + }), + bodyAsText: '{"message": "InvalidResourceNameBody"}' + }); + } + }; + + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy()); + const client = new ServiceClient({ + httpsClient, + pipeline + }); + + try { + await client.sendOperationRequest({}, operationSpec); + assert.fail(); + } catch (ex) { + assert.strictEqual(ex.details.errorCode, "InvalidResourceNameHeader"); + assert.strictEqual(ex.details.message, "InvalidResourceNameBody"); + } + }); +}); + +function stringToByteArray(str: string): Uint8Array { + if (typeof Buffer === "function") { + return Buffer.from(str, "utf-8"); + } else { + return new TextEncoder().encode(str); + } +} + +async function testSendOperationRequest( + queryValue: any, + queryCollectionFormat: QueryCollectionFormat, + skipEncodingParameter: boolean, + expected: string +): Promise { + let request: OperationRequest; + const client = new ServiceClient({ + httpsClient: { + sendRequest: (req) => { + request = req; + return Promise.resolve({ request, status: 200, headers: createHttpHeaders() }); + } + }, + pipeline: createEmptyPipeline() + }); + + await client.sendOperationRequest( + { + q: queryValue + }, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + queryParameters: [ + { + collectionFormat: queryCollectionFormat, + skipEncoding: skipEncodingParameter, + parameterPath: "q", + mapper: { + serializedName: "q", + type: { + name: "Sequence", + element: { + type: { + name: "String" + }, + serializedName: "q" + } + } + } + } + ], + responses: { + 200: {} + } + } + ); + + assert(request!); + assert(request!.url.endsWith(expected), `"${request!.url}" does not end with "${expected}"`); +} diff --git a/sdk/core/core-client/test/testMappers.ts b/sdk/core/core-client/test/testMappers.ts new file mode 100644 index 000000000000..1db4895d63a9 --- /dev/null +++ b/sdk/core/core-client/test/testMappers.ts @@ -0,0 +1,753 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +const internalMappers: any = {}; + +internalMappers.SimpleProduct = { + type: { + name: "Composite", + className: "SimpleProduct", + modelProperties: { + id: { + serializedName: "id", + constraints: {}, + required: true, + type: { + name: "Number" + } + }, + name: { + serializedName: "name", + required: true, + type: { + name: "String" + } + }, + maxProductDisplayName: { + serializedName: "details.max_product_display_name", + type: { + name: "String" + } + }, + capacity: { + defaultValue: "Large", + isConstant: true, + serializedName: "details.max_product_capacity", + type: { + name: "String" + } + } + } + } +}; + +internalMappers.SimpleProductConstFirst = { + type: { + name: "Composite", + className: "SimpleProduct", + modelProperties: { + id: { + serializedName: "id", + constraints: {}, + required: true, + type: { + name: "Number" + } + }, + name: { + serializedName: "name", + required: true, + type: { + name: "String" + } + }, + capacity: { + defaultValue: "Large", + isConstant: true, + serializedName: "details.max_product_capacity", + type: { + name: "String" + } + }, + maxProductDisplayName: { + serializedName: "details.max_product_display_name", + type: { + name: "String" + } + } + } + } +}; + +internalMappers.Cat = { + required: false, + serializedName: "cat", + type: { + name: "Composite", + className: "Cat", + modelProperties: { + id: { + required: false, + serializedName: "id", + type: { + name: "Number" + } + }, + name: { + required: false, + serializedName: "name", + type: { + name: "String" + } + }, + pettype: { + required: true, + serializedName: "pet\\.type", + type: { + name: "String" + } + }, + color: { + required: false, + serializedName: "color", + type: { + name: "String" + } + }, + hates: { + required: false, + serializedName: "hates", + type: { + name: "Sequence", + element: { + required: false, + serializedName: "DogElementType", + type: { + name: "Composite", + className: "Dog" + } + } + } + } + } + } +}; +internalMappers.Dog = { + required: false, + serializedName: "dog", + type: { + name: "Composite", + className: "Dog", + modelProperties: { + id: { + required: false, + serializedName: "id", + type: { + name: "Number" + } + }, + name: { + required: false, + serializedName: "name", + type: { + name: "String" + } + }, + pettype: { + required: true, + serializedName: "pet\\.type", + type: { + name: "String" + } + }, + food: { + required: false, + serializedName: "food", + type: { + name: "String" + } + } + } + } +}; +internalMappers.Fish = { + required: false, + serializedName: "Fish", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fish.type", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Fish", + modelProperties: { + species: { + required: false, + serializedName: "species", + type: { + name: "String" + } + }, + length: { + required: true, + serializedName: "length", + type: { + name: "Number" + } + }, + siblings: { + required: false, + serializedName: "siblings", + type: { + name: "Sequence", + element: { + required: false, + serializedName: "FishElementType", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fish.type", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Fish" + } + } + } + }, + fishtype: { + required: true, + serializedName: "fish\\.type", + type: { + name: "String" + } + } + } + } +}; +internalMappers.Invoice = { + required: false, + serializedName: "Invoice", + type: { + name: "Composite", + className: "Invoice", + modelProperties: { + invId: { + serializedName: "invoiceId", + required: true, + type: { + name: "Number" + } + }, + invDate: { + serializedName: "invDate", + required: false, + type: { + name: "Date" + } + }, + invProducts: { + serializedName: "invProducts", + required: false, + type: { + name: "Sequence", + element: { + type: { + name: "Dictionary", + value: { + type: { + name: "Composite", + className: "Product" + } + } + } + } + } + } + } + } +}; +internalMappers.Pet = { + required: false, + serializedName: "pet", + type: { + name: "Composite", + className: "Pet", + polymorphicDiscriminator: "pet.type", + modelProperties: { + id: { + required: false, + serializedName: "id", + type: { + name: "Number" + } + }, + name: { + required: false, + serializedName: "name", + type: { + name: "String" + } + }, + pettype: { + required: true, + serializedName: "pet\\.type", + type: { + name: "String" + } + } + } + } +}; +internalMappers.PetAP = { + required: false, + serializedName: "PetAP", + type: { + name: "Composite", + additionalProperties: { + type: { + name: "String" + } + }, + className: "PetAP", + modelProperties: { + id: { + required: true, + serializedName: "id", + type: { + name: "Number" + } + }, + name: { + required: false, + serializedName: "name", + type: { + name: "String" + } + }, + eyeColor: { + required: true, + serializedName: "eyeColor", + isConstant: true, + defaultValue: "brown", + type: { + name: "String" + } + }, + favoriteFood: { + required: false, + serializedName: "favoriteFood", + defaultValue: "bones", + type: { + name: "String" + } + }, + status: { + required: false, + readOnly: true, + serializedName: "status", + type: { + name: "Boolean" + } + }, + odatalocation: { + required: true, + serializedName: "@odata\\.location", + type: { + name: "String" + } + }, + additionalProperties1: { + required: false, + serializedName: "additionalProperties", + type: { + name: "Dictionary", + value: { + required: false, + serializedName: "NumberElementType", + type: { + name: "Number" + } + } + } + } + } + } +}; +internalMappers.PetGallery = { + required: false, + serializedName: "PetGallery", + type: { + name: "Composite", + className: "PetGallery", + modelProperties: { + id: { + required: false, + serializedName: "id", + type: { + name: "Number" + } + }, + name: { + required: false, + serializedName: "name", + type: { + name: "String" + } + }, + pets: { + required: false, + serializedName: "pets", + type: { + name: "Sequence", + element: { + required: false, + serializedName: "petElementType", + type: { + name: "Composite", + polymorphicDiscriminator: "pet.type", + uberParent: "Pet", + className: "Pet" + } + } + } + } + } + } +}; +internalMappers.Product = { + required: false, + serializedName: "Product", + type: { + name: "Composite", + className: "Product", + modelProperties: { + id: { + serializedName: "id", + constraints: {}, + required: true, + type: { + name: "Number" + } + }, + name: { + serializedName: "name", + required: true, + type: { + name: "String" + }, + constraints: { + MaxLength: 256, + MinLength: 1, + Pattern: /^[A-Za-z0-9-._]+$/ + } + }, + provisioningState: { + serializedName: "properties.provisioningState", + required: false, + type: { + name: "Enum", + allowedValues: ["Creating", "Failed", "Succeeded"] + } + }, + tags: { + serializedName: "tags", + required: false, + type: { + name: "Dictionary", + value: { + type: { + name: "String" + } + } + } + }, + dispatchTime: { + serializedName: "dispatchTime", + required: false, + type: { + name: "DateTime" + } + }, + invoiceInfo: { + serializedName: "invoiceInfo", + required: false, + type: { + name: "Composite", + className: "Invoice" + } + }, + subProducts: { + serializedName: "subProducts", + required: false, + type: { + name: "Sequence", + element: { + type: { + name: "Composite", + className: "SubProduct" + } + } + } + } + } + } +}; +internalMappers.ProductListResult = { + required: false, + serializedName: "ProductListResult", + type: { + name: "Composite", + className: "ProductListResult", + modelProperties: { + value: { + serializedName: "", + required: false, + type: { + name: "Sequence", + element: { + type: { + name: "Composite", + className: "Product" + } + } + } + } + } + } +}; +internalMappers.ProductListResultNextLink = { + required: false, + serializedName: "ProductListResultNextLink", + type: { + name: "Composite", + className: "ProductListResultNextLink", + modelProperties: { + value: { + serializedName: "", + required: false, + type: { + name: "Sequence", + element: { + type: { + name: "Composite", + className: "Product" + } + } + } + }, + nextLink: { + serializedName: "nextLink", + required: false, + type: { + name: "String" + } + } + } + } +}; +internalMappers.SawShark = { + required: false, + serializedName: "sawshark", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fish.type", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Sawshark", + modelProperties: { + species: { + required: false, + serializedName: "species", + type: { + name: "String" + } + }, + length: { + required: true, + serializedName: "length", + type: { + name: "Number" + } + }, + siblings: { + required: false, + serializedName: "siblings", + type: { + name: "Sequence", + element: { + required: false, + serializedName: "FishElementType", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fish.type", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Fish" + } + } + } + }, + fishtype: { + required: true, + serializedName: "fish\\.type", + type: { + name: "String" + } + }, + age: { + required: false, + serializedName: "age", + type: { + name: "Number" + } + }, + birthday: { + required: true, + serializedName: "birthday", + type: { + name: "DateTime" + } + }, + picture: { + required: false, + serializedName: "picture", + type: { + name: "ByteArray" + } + } + } + } +}; +internalMappers.Shark = { + required: false, + serializedName: "shark", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fish.type", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Shark", + modelProperties: { + species: { + required: false, + serializedName: "species", + type: { + name: "String" + } + }, + length: { + required: true, + serializedName: "length", + type: { + name: "Number" + } + }, + siblings: { + required: false, + serializedName: "siblings", + type: { + name: "Sequence", + element: { + required: false, + serializedName: "FishElementType", + type: { + name: "Composite", + polymorphicDiscriminator: { + serializedName: "fish.type", + clientName: "fishtype" + }, + uberParent: "Fish", + className: "Fish" + } + } + } + }, + fishtype: { + required: true, + serializedName: "fish\\.type", + type: { + name: "String" + } + }, + age: { + required: false, + serializedName: "age", + type: { + name: "Number" + } + }, + birthday: { + required: true, + serializedName: "birthday", + type: { + name: "DateTime" + } + } + } + } +}; +internalMappers.SubProduct = { + required: false, + serializedName: "SubProduct", + type: { + name: "Composite", + className: "SubProduct", + modelProperties: { + subId: { + serializedName: "subId", + required: true, + type: { + name: "Number" + } + }, + subName: { + serializedName: "subName", + required: true, + type: { + name: "String" + } + }, + provisioningState: { + serializedName: "provisioningState", + required: false, + type: { + name: "Enum", + allowedValues: ["Creating", "Failed", "Succeeded"] + } + }, + makeTime: { + serializedName: "makeTime", + required: false, + type: { + name: "DateTime" + } + }, + invoiceInfo: { + serializedName: "invoiceInfo", + required: false, + type: { + name: "Composite", + className: "Invoice" + } + } + } + } +}; + +internalMappers.discriminators = { + Fish: internalMappers.Fish, + "Fish.shark": internalMappers.Shark, + "Fish.sawshark": internalMappers.SawShark, + Pet: internalMappers.Pet, + "Pet.Cat": internalMappers.Cat, + "Pet.Dog": internalMappers.Dog +}; + +export const Mappers = internalMappers; diff --git a/sdk/core/core-https/package.json b/sdk/core/core-https/package.json index 77c485e50df5..ef9a1be01cea 100644 --- a/sdk/core/core-https/package.json +++ b/sdk/core/core-https/package.json @@ -90,6 +90,7 @@ }, "dependencies": { "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.1.3", "@azure/core-tracing": "1.0.0-preview.8", "@azure/logger": "^1.0.0", "@opentelemetry/api": "^0.6.1", diff --git a/sdk/core/core-https/review/core-https.api.md b/sdk/core/core-https/review/core-https.api.md index c3afb248a7e4..15952cf14592 100644 --- a/sdk/core/core-https/review/core-https.api.md +++ b/sdk/core/core-https/review/core-https.api.md @@ -7,6 +7,7 @@ import { AbortSignalLike } from '@azure/abort-controller'; import { Debugger } from '@azure/logger'; import { SpanOptions } from '@azure/core-tracing'; +import { TokenCredential } from '@azure/core-auth'; // @public export interface AddPipelineOptions { @@ -16,6 +17,18 @@ export interface AddPipelineOptions { phase?: PipelinePhase; } +// @public +export function bearerTokenAuthenticationPolicy(options: BearerTokenAuthenticationPolicyOptions): PipelinePolicy; + +// @public +export const bearerTokenAuthenticationPolicyName = "bearerTokenAuthenticationPolicy"; + +// @public +export interface BearerTokenAuthenticationPolicyOptions { + credential: TokenCredential; + scopes: string | string[]; +} + // @public export function createEmptyPipeline(): Pipeline; @@ -154,8 +167,9 @@ export interface PipelineRedirectOptions extends RedirectPolicyOptions { } // @public -export interface PipelineRequest { +export interface PipelineRequest { abortSignal?: AbortSignalLike; + additionalInfo?: AdditionalInfo; body?: RequestBodyType; clone(): PipelineRequest; formData?: FormDataMap; @@ -175,8 +189,9 @@ export interface PipelineRequest { } // @public -export interface PipelineRequestOptions { +export interface PipelineRequestOptions { abortSignal?: AbortSignalLike; + additionalInfo?: AdditionalInfo; body?: RequestBodyType; formData?: FormDataMap; headers?: HttpHeaders; diff --git a/sdk/core/core-https/src/accessTokenCache.ts b/sdk/core/core-https/src/accessTokenCache.ts new file mode 100644 index 000000000000..1b7bf6e7204e --- /dev/null +++ b/sdk/core/core-https/src/accessTokenCache.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AccessToken } from "@azure/core-auth"; + +/** + * Defines the default token refresh buffer duration. + */ +export const DefaultTokenRefreshBufferMs = 2 * 60 * 1000; // 2 Minutes + +/** + * Provides a cache for an AccessToken that was that + * was returned from a TokenCredential. + */ +export interface AccessTokenCache { + /** + * Sets the cached token. + * + * @param accessToken The AccessToken to be cached or null to + * clear the cached token. + */ + setCachedToken(accessToken: AccessToken | undefined): void; + + /** + * Returns the cached AccessToken or undefined if nothing is cached. + */ + getCachedToken(): AccessToken | undefined; +} + +/** + * Provides an AccessTokenCache implementation which clears + * the cached AccessToken's after the expiresOnTimestamp has + * passed. + */ +export class ExpiringAccessTokenCache implements AccessTokenCache { + private tokenRefreshBufferMs: number; + private cachedToken?: AccessToken; + + /** + * Constructs an instance of ExpiringAccessTokenCache with + * an optional expiration buffer time. + */ + constructor(tokenRefreshBufferMs: number = DefaultTokenRefreshBufferMs) { + this.tokenRefreshBufferMs = tokenRefreshBufferMs; + } + + setCachedToken(accessToken: AccessToken | undefined): void { + this.cachedToken = accessToken; + } + + getCachedToken(): AccessToken | undefined { + if ( + this.cachedToken && + Date.now() + this.tokenRefreshBufferMs >= this.cachedToken.expiresOnTimestamp + ) { + this.cachedToken = undefined; + } + + return this.cachedToken; + } +} diff --git a/sdk/core/core-https/src/index.ts b/sdk/core/core-https/src/index.ts index be4f2520d08b..649c6122f06f 100644 --- a/sdk/core/core-https/src/index.ts +++ b/sdk/core/core-https/src/index.ts @@ -68,3 +68,8 @@ export { UserAgentPolicyOptions } from "./policies/userAgentPolicy"; export { formDataPolicy, formDataPolicyName } from "./policies/formDataPolicy"; +export { + bearerTokenAuthenticationPolicy, + BearerTokenAuthenticationPolicyOptions, + bearerTokenAuthenticationPolicyName +} from "./policies/bearerTokenAuthenticationPolicy"; diff --git a/sdk/core/core-https/src/interfaces.ts b/sdk/core/core-https/src/interfaces.ts index f114b8aabdee..8384f485a180 100644 --- a/sdk/core/core-https/src/interfaces.ts +++ b/sdk/core/core-https/src/interfaces.ts @@ -88,7 +88,7 @@ export type RequestBodyType = /** * Metadata about a request being made by the pipeline. */ -export interface PipelineRequest { +export interface PipelineRequest { /** * The URL to make the request to. */ @@ -122,6 +122,12 @@ export interface PipelineRequest { */ requestId: string; + /** + * Any additional information on the request that + * is policy or client specific. + */ + additionalInfo?: AdditionalInfo; + /** * The HTTP body content (if any) */ diff --git a/sdk/core/core-https/src/pipelineRequest.ts b/sdk/core/core-https/src/pipelineRequest.ts index f89fd3d27b62..bd8ddb2d3af3 100644 --- a/sdk/core/core-https/src/pipelineRequest.ts +++ b/sdk/core/core-https/src/pipelineRequest.ts @@ -19,12 +19,18 @@ import { SpanOptions } from "@azure/core-tracing"; * Settings to initialize a request. * Almost equivalent to Partial, but url is mandatory. */ -export interface PipelineRequestOptions { +export interface PipelineRequestOptions { /** * The URL to make the request to. */ url: string; + /** + * Any additional information on the request that + * is policy or client specific. + */ + additionalInfo?: AdditionalInfo; + /** * The HTTP method to use when making the request. */ @@ -102,7 +108,7 @@ export interface PipelineRequestOptions { onDownloadProgress?: (progress: TransferProgressEvent) => void; } -class PipelineRequestImpl implements PipelineRequest { +class PipelineRequestImpl implements PipelineRequest { public url: string; public method: HttpMethods; public headers: HttpHeaders; @@ -119,6 +125,7 @@ class PipelineRequestImpl implements PipelineRequest { public spanOptions?: SpanOptions; public onUploadProgress?: (progress: TransferProgressEvent) => void; public onDownloadProgress?: (progress: TransferProgressEvent) => void; + public additionalInfo?: AdditionalInfo; constructor(options: PipelineRequestOptions) { this.url = options.url; @@ -137,6 +144,7 @@ class PipelineRequestImpl implements PipelineRequest { this.onUploadProgress = options.onUploadProgress; this.onDownloadProgress = options.onDownloadProgress; this.requestId = options.requestId || generateUuid(); + this.additionalInfo = options.additionalInfo; } public clone(): PipelineRequest { @@ -156,7 +164,8 @@ class PipelineRequestImpl implements PipelineRequest { timeout: this.timeout, withCredentials: this.withCredentials, spanOptions: this.spanOptions, - requestId: this.requestId + requestId: this.requestId, + additionalInfo: this.additionalInfo }); } } diff --git a/sdk/core/core-https/src/policies/bearerTokenAuthenticationPolicy.ts b/sdk/core/core-https/src/policies/bearerTokenAuthenticationPolicy.ts new file mode 100644 index 000000000000..769eafcf7552 --- /dev/null +++ b/sdk/core/core-https/src/policies/bearerTokenAuthenticationPolicy.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PipelineResponse, PipelineRequest, SendRequest } from "../interfaces"; +import { PipelinePolicy } from "../pipeline"; +import { TokenCredential, GetTokenOptions } from "@azure/core-auth"; +import { AccessTokenCache, ExpiringAccessTokenCache } from "../accessTokenCache"; + +/** + * The programmatic identifier of the bearerTokenAuthenticationPolicy. + */ +export const bearerTokenAuthenticationPolicyName = "bearerTokenAuthenticationPolicy"; + +/** + * Options to configure the bearerTokenAuthenticationPolicy + */ +export interface BearerTokenAuthenticationPolicyOptions { + /** + * The TokenCredential implementation that can supply the bearer token. + */ + credential: TokenCredential; + /** + * The scopes for which the bearer token applies. + */ + scopes: string | string[]; +} + +/** + * A policy that can request a token from a TokenCredential implementation and + * then apply it to the Authorization header of a request as a Bearer token. + */ +export function bearerTokenAuthenticationPolicy( + options: BearerTokenAuthenticationPolicyOptions +): PipelinePolicy { + const { credential, scopes } = options; + const tokenCache: AccessTokenCache = new ExpiringAccessTokenCache(); + async function getToken(options: GetTokenOptions): Promise { + let accessToken = tokenCache.getCachedToken(); + if (accessToken === undefined) { + accessToken = (await credential.getToken(scopes, options)) || undefined; + tokenCache.setCachedToken(accessToken); + } + + return accessToken ? accessToken.token : undefined; + } + return { + name: bearerTokenAuthenticationPolicyName, + async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + const token = await getToken({ + abortSignal: request.abortSignal, + tracingOptions: { + spanOptions: request.spanOptions + } + }); + request.headers.set("Authorization", `Bearer ${token}`); + return next(request); + } + }; +} diff --git a/sdk/core/core-https/test/bearerTokenAuthenticationPolicy.spec.ts b/sdk/core/core-https/test/bearerTokenAuthenticationPolicy.spec.ts new file mode 100644 index 000000000000..9120a5d35505 --- /dev/null +++ b/sdk/core/core-https/test/bearerTokenAuthenticationPolicy.spec.ts @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "chai"; +import * as sinon from "sinon"; +import { TokenCredential, AccessToken } from "@azure/core-auth"; +import {} from "../src/policies/bearerTokenAuthenticationPolicy"; +import { DefaultTokenRefreshBufferMs } from "../src/accessTokenCache"; +import { + PipelinePolicy, + createPipelineRequest, + createHttpHeaders, + PipelineResponse, + bearerTokenAuthenticationPolicy, + SendRequest +} from "../src"; + +describe("BearerTokenAuthenticationPolicy", function() { + it("correctly adds an Authentication header with the Bearer token", async function() { + const mockToken = "token"; + const tokenScopes = ["scope1", "scope2"]; + const fakeGetToken = sinon.fake.returns( + Promise.resolve({ token: mockToken, expiresOn: new Date() }) + ); + const mockCredential: TokenCredential = { + getToken: fakeGetToken + }; + + const request = createPipelineRequest({ url: "https://example.com" }); + const successResponse: PipelineResponse = { + headers: createHttpHeaders(), + request, + status: 200 + }; + const next = sinon.stub, ReturnType>(); + next.resolves(successResponse); + + const bearerTokenAuthPolicy = createBearerTokenPolicy(tokenScopes, mockCredential); + await bearerTokenAuthPolicy.sendRequest(request, next); + + assert( + fakeGetToken.calledWith(tokenScopes, { + abortSignal: undefined, + tracingOptions: { spanOptions: undefined } + }), + "fakeGetToken called incorrectly." + ); + assert.strictEqual(request.headers.get("Authorization"), `Bearer ${mockToken}`); + }); + + it("refreshes access tokens when they expire", async () => { + const now = Date.now(); + const refreshCred1 = new MockRefreshAzureCredential(now); + const refreshCred2 = new MockRefreshAzureCredential(now + DefaultTokenRefreshBufferMs); + const notRefreshCred1 = new MockRefreshAzureCredential( + now + DefaultTokenRefreshBufferMs + 5000 + ); + + const credentialsToTest: [MockRefreshAzureCredential, number][] = [ + [refreshCred1, 2], + [refreshCred2, 2], + [notRefreshCred1, 1] + ]; + + const request = createPipelineRequest({ url: "https://example.com" }); + const successResponse: PipelineResponse = { + headers: createHttpHeaders(), + request, + status: 200 + }; + const next = sinon.stub, ReturnType>(); + next.resolves(successResponse); + + for (const [credentialToTest, expectedCalls] of credentialsToTest) { + const policy = createBearerTokenPolicy("testscope", credentialToTest); + await policy.sendRequest(request, next); + await policy.sendRequest(request, next); + assert.strictEqual(credentialToTest.authCount, expectedCalls); + } + }); + + function createBearerTokenPolicy( + scopes: string | string[], + credential: TokenCredential + ): PipelinePolicy { + return bearerTokenAuthenticationPolicy({ + scopes, + credential + }); + } +}); + +class MockRefreshAzureCredential implements TokenCredential { + private _expiresOnTimestamp: number; + public authCount = 0; + + constructor(expiresOnTimestamp: number) { + this._expiresOnTimestamp = expiresOnTimestamp; + } + + public async getToken(): Promise { + this.authCount++; + return { token: "mocktoken", expiresOnTimestamp: this._expiresOnTimestamp }; + } +}