diff --git a/dataplane.code-workspace b/dataplane.code-workspace index 99796bb0b127..a8ffe2b5b007 100644 --- a/dataplane.code-workspace +++ b/dataplane.code-workspace @@ -274,6 +274,9 @@ }, { "path": "sdk/instrumentation/opentelemetry-instrumentation-azure-sdk" + }, + { + "path": "sdk/core/core-http-compat" } ], "settings": { diff --git a/sdk/core/core-http-compat/review/core-http-compat.api.md b/sdk/core/core-http-compat/review/core-http-compat.api.md index 3c3d2e2e4291..b517c854ed7a 100644 --- a/sdk/core/core-http-compat/review/core-http-compat.api.md +++ b/sdk/core/core-http-compat/review/core-http-compat.api.md @@ -10,6 +10,7 @@ import { FullOperationResponse } from '@azure/core-client'; import { HttpMethods } from '@azure/core-rest-pipeline'; import { OperationArguments } from '@azure/core-client'; import { OperationSpec } from '@azure/core-client'; +import { PipelinePolicy } from '@azure/core-rest-pipeline'; import { ProxySettings } from '@azure/core-rest-pipeline'; import { ServiceClient } from '@azure/core-client'; import { ServiceClientOptions } from '@azure/core-client'; @@ -20,6 +21,9 @@ export interface CompatResponse extends Omit; +} + +// @public +export interface RequestPolicyFactory { + // (undocumented) + create(nextPolicy: RequestPolicy, options: RequestPolicyOptionsLike): RequestPolicy; +} + +// @public +export const requestPolicyFactoryPolicyName = "RequestPolicyFactoryPolicy"; + +// @public +export interface RequestPolicyOptionsLike { + // (undocumented) + log(logLevel: HttpPipelineLogLevel, message: string): void; + // (undocumented) + shouldLog(logLevel: HttpPipelineLogLevel): boolean; +} + // @public export type TransferProgressEvent = { loadedBytes: number; diff --git a/sdk/core/core-http-compat/src/extendedClient.ts b/sdk/core/core-http-compat/src/extendedClient.ts index d5c862e9229f..4945f3fd8ced 100644 --- a/sdk/core/core-http-compat/src/extendedClient.ts +++ b/sdk/core/core-http-compat/src/extendedClient.ts @@ -6,15 +6,15 @@ import { createDisableKeepAlivePolicy } from "./policies/disableKeepAlivePolicy" import { RedirectOptions } from "./policies/redirectOptions"; import { redirectPolicyName } from "@azure/core-rest-pipeline"; import { - ServiceClient, - ServiceClientOptions, CommonClientOptions, + FullOperationResponse, OperationArguments, OperationSpec, - FullOperationResponse, RawResponseCallback, + ServiceClient, + ServiceClientOptions, } from "@azure/core-client"; -import { toWebResourceLike, toHttpHeaderLike, WebResourceLike, HttpHeadersLike } from "./util"; +import { toCompatResponse } from "./response"; /** * Options specific to Shim Clients. @@ -94,28 +94,10 @@ export class ExtendedServiceClient extends ServiceClient { if (lastResponse) { Object.defineProperty(result, "_response", { - value: { - ...lastResponse, - request: toWebResourceLike(lastResponse.request), - headers: toHttpHeaderLike(lastResponse.headers), - }, + value: toCompatResponse(lastResponse), }); } return result; } } - -/** - * Http Response that is compatible with the core-v1(core-http). - */ -export interface CompatResponse extends Omit { - /** - * A description of a HTTP request to be made to a remote server. - */ - request: WebResourceLike; - /** - * A collection of HTTP header key/value pairs. - */ - headers: HttpHeadersLike; -} diff --git a/sdk/core/core-http-compat/src/index.ts b/sdk/core/core-http-compat/src/index.ts index a011346a1386..34efa3361e45 100644 --- a/sdk/core/core-http-compat/src/index.ts +++ b/sdk/core/core-http-compat/src/index.ts @@ -11,8 +11,16 @@ export { ExtendedServiceClientOptions, ExtendedCommonClientOptions, ExtendedClientOptions, - CompatResponse, } from "./extendedClient"; +export { CompatResponse } from "./response"; +export { + requestPolicyFactoryPolicyName, + createRequestPolicyFactoryPolicy, + RequestPolicyFactory, + RequestPolicy, + RequestPolicyOptionsLike, + HttpPipelineLogLevel, +} from "./policies/requestPolicyFactoryPolicy"; export { KeepAliveOptions } from "./policies/keepAliveOptions"; export { RedirectOptions } from "./policies/redirectOptions"; export { disbaleKeepAlivePolicyName } from "./policies/disableKeepAlivePolicy"; diff --git a/sdk/core/core-http-compat/src/policies/disableKeepAlivePolicy.ts b/sdk/core/core-http-compat/src/policies/disableKeepAlivePolicy.ts index dbe31c31785e..1bb0c4052576 100644 --- a/sdk/core/core-http-compat/src/policies/disableKeepAlivePolicy.ts +++ b/sdk/core/core-http-compat/src/policies/disableKeepAlivePolicy.ts @@ -4,8 +4,8 @@ import { PipelinePolicy, PipelineRequest, - SendRequest, PipelineResponse, + SendRequest, } from "@azure/core-rest-pipeline"; export const disbaleKeepAlivePolicyName = "DisableKeepAlivePolicy"; diff --git a/sdk/core/core-http-compat/src/policies/requestPolicyFactoryPolicy.ts b/sdk/core/core-http-compat/src/policies/requestPolicyFactoryPolicy.ts new file mode 100644 index 000000000000..73090c0b44bd --- /dev/null +++ b/sdk/core/core-http-compat/src/policies/requestPolicyFactoryPolicy.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + PipelinePolicy, + PipelineRequest, + PipelineResponse, + SendRequest, +} from "@azure/core-rest-pipeline"; +import { WebResourceLike, toPipelineRequest, toWebResourceLike } from "../util"; +import { CompatResponse, toCompatResponse, toPipelineResponse } from "../response"; + +/** + * A compatible interface for core-http request policies + */ +export interface RequestPolicy { + sendRequest(httpRequest: WebResourceLike): Promise; +} + +/** + * An enum for compatibility with RequestPolicy + */ +export enum HttpPipelineLogLevel { + ERROR = 1, + INFO = 3, + OFF = 0, + WARNING = 2, +} + +/** + * An interface for compatibility with RequestPolicy + */ +export interface RequestPolicyOptionsLike { + log(logLevel: HttpPipelineLogLevel, message: string): void; + shouldLog(logLevel: HttpPipelineLogLevel): boolean; +} + +const mockRequestPolicyOptions: RequestPolicyOptionsLike = { + log(_logLevel: HttpPipelineLogLevel, _message: string): void { + /* do nothing */ + }, + shouldLog(_logLevel: HttpPipelineLogLevel): boolean { + return false; + }, +}; + +/** + * An interface for compatibility with core-http's RequestPolicyFactory + */ +export interface RequestPolicyFactory { + create(nextPolicy: RequestPolicy, options: RequestPolicyOptionsLike): RequestPolicy; +} + +/** + * The name of the RequestPolicyFactoryPolicy + */ +export const requestPolicyFactoryPolicyName = "RequestPolicyFactoryPolicy"; + +/** + * A policy that wraps policies written for core-http. + * @param factories - An array of `RequestPolicyFactory` objects from a core-http pipeline + */ +export function createRequestPolicyFactoryPolicy( + factories: RequestPolicyFactory[] +): PipelinePolicy { + const orderedFactories = factories.slice().reverse(); + + return { + name: requestPolicyFactoryPolicyName, + async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + let httpPipeline: RequestPolicy = { + async sendRequest(httpRequest) { + const response = await next(toPipelineRequest(httpRequest)); + return toCompatResponse(response, { createProxy: true }); + }, + }; + for (const factory of orderedFactories) { + httpPipeline = factory.create(httpPipeline, mockRequestPolicyOptions); + } + + const webResourceLike = toWebResourceLike(request, { createProxy: true }); + const response = await httpPipeline.sendRequest(webResourceLike); + return toPipelineResponse(response); + }, + }; +} diff --git a/sdk/core/core-http-compat/src/response.ts b/sdk/core/core-http-compat/src/response.ts new file mode 100644 index 000000000000..420b11f40e94 --- /dev/null +++ b/sdk/core/core-http-compat/src/response.ts @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { FullOperationResponse } from "@azure/core-client"; +import { PipelineResponse, createHttpHeaders } from "@azure/core-rest-pipeline"; +import { + HttpHeadersLike, + WebResourceLike, + toHttpHeaderLike, + toPipelineRequest, + toWebResourceLike, +} from "./util"; +/** + * Http Response that is compatible with the core-v1(core-http). + */ +export interface CompatResponse extends Omit { + /** + * A description of a HTTP request to be made to a remote server. + */ + request: WebResourceLike; + /** + * A collection of HTTP header key/value pairs. + */ + headers: HttpHeadersLike; +} + +const originalResponse = Symbol("Original FullOperationResponse"); +type ExtendedCompatResponse = CompatResponse & { [originalResponse]?: FullOperationResponse }; + +/** + * A helper to convert response objects from the new pipeline back to the old one. + * @param response - A response object from core-client. + * @returns A response compatible with `HttpOperationResponse` from core-http. + */ +export function toCompatResponse( + response: FullOperationResponse, + options?: { createProxy?: boolean } +): CompatResponse { + let request = toWebResourceLike(response.request); + let headers = toHttpHeaderLike(response.headers); + if (options?.createProxy) { + return new Proxy(response, { + get(target, prop, receiver) { + if (prop === "headers") { + return headers; + } else if (prop === "request") { + return request; + } + return Reflect.get(target, prop, receiver); + }, + set(target, prop, value, receiver) { + if (prop === "headers") { + headers = value; + } else if (prop === "request") { + request = value; + } + return Reflect.set(target, prop, value, receiver); + }, + }) as unknown as CompatResponse; + } else { + return { + ...response, + request, + headers, + }; + } +} + +/** + * A helper to convert back to a PipelineResponse + * @param compatResponse - A response compatible with `HttpOperationResponse` from core-http. + */ +export function toPipelineResponse(compatResponse: CompatResponse): PipelineResponse { + const extendedCompatResponse = compatResponse as ExtendedCompatResponse; + const response = extendedCompatResponse[originalResponse]; + const headers = createHttpHeaders(compatResponse.headers.toJson({ preserveCase: true })); + if (response) { + response.headers = headers; + return response; + } else { + return { + ...compatResponse, + headers, + request: toPipelineRequest(compatResponse.request), + }; + } +} diff --git a/sdk/core/core-http-compat/src/util.ts b/sdk/core/core-http-compat/src/util.ts index 38f41f4fb8e8..6552ca1ddafd 100644 --- a/sdk/core/core-http-compat/src/util.ts +++ b/sdk/core/core-http-compat/src/util.ts @@ -1,19 +1,76 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { HttpMethods, ProxySettings } from "@azure/core-rest-pipeline"; +import { + HttpMethods, + ProxySettings, + createHttpHeaders, + createPipelineRequest, +} from "@azure/core-rest-pipeline"; import { AbortSignalLike } from "@azure/abort-controller"; import { HttpHeaders as HttpHeadersV2, PipelineRequest } from "@azure/core-rest-pipeline"; -export function toWebResourceLike(request: PipelineRequest): WebResourceLike { - return { +const originalRequest = Symbol("Original PipelineRequest"); +type CompatWebResourceLike = WebResourceLike & { [originalRequest]?: PipelineRequest }; + +export function toPipelineRequest(webResource: WebResourceLike): PipelineRequest { + const compatWebResource = webResource as CompatWebResourceLike; + const request = compatWebResource[originalRequest]; + const headers = createHttpHeaders(webResource.headers.toJson({ preserveCase: true })); + if (request) { + request.headers = headers; + return request; + } else { + return createPipelineRequest({ + url: webResource.url, + method: webResource.method, + headers, + withCredentials: webResource.withCredentials, + timeout: webResource.timeout, + requestId: webResource.requestId, + }); + } +} + +export function toWebResourceLike( + request: PipelineRequest, + options?: { createProxy?: boolean } +): WebResourceLike { + const webResource: WebResourceLike = { url: request.url, method: request.method, headers: toHttpHeaderLike(request.headers), withCredentials: request.withCredentials, timeout: request.timeout, - requestId: request.headers.get("x-ms-client-request-id") || "", + requestId: request.headers.get("x-ms-client-request-id") || request.requestId, }; + + if (options?.createProxy) { + return new Proxy(webResource, { + get(target, prop, receiver) { + if (prop === originalRequest) { + return request; + } + return Reflect.get(target, prop, receiver); + }, + set(target: any, prop, value, receiver) { + if (prop === "url") { + request.url = value; + } else if (prop === "method") { + request.method = value; + } else if (prop === "withCredentials") { + request.withCredentials = value; + } else if (prop === "timeout") { + request.timeout = value; + } else if (prop === "requestId") { + request.requestId = value; + } + return Reflect.set(target, prop, value, receiver); + }, + }); + } else { + return webResource; + } } export function toHttpHeaderLike(headers: HttpHeadersV2): HttpHeadersLike { diff --git a/sdk/core/core-http-compat/test/extendedClient.spec.ts b/sdk/core/core-http-compat/test/extendedClient.spec.ts index d118beae97c0..4b568011041a 100644 --- a/sdk/core/core-http-compat/test/extendedClient.spec.ts +++ b/sdk/core/core-http-compat/test/extendedClient.spec.ts @@ -2,14 +2,14 @@ // Licensed under the MIT license. import { assert } from "@azure/test-utils"; -import { PipelinePolicy, createHttpHeaders, createEmptyPipeline } from "@azure/core-rest-pipeline"; +import { PipelinePolicy, createEmptyPipeline, createHttpHeaders } from "@azure/core-rest-pipeline"; import { - serializationPolicy, - OperationRequest, - createSerializer, DictionaryMapper, OperationArguments, + OperationRequest, OperationSpec, + createSerializer, + serializationPolicy, } from "@azure/core-client"; import { ExtendedServiceClient, disbaleKeepAlivePolicyName } from "../src/index"; diff --git a/sdk/core/core-http-compat/test/requestPolicyFactoryPolicy.spec.ts b/sdk/core/core-http-compat/test/requestPolicyFactoryPolicy.spec.ts new file mode 100644 index 000000000000..9aad52f603da --- /dev/null +++ b/sdk/core/core-http-compat/test/requestPolicyFactoryPolicy.spec.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "@azure/test-utils"; +import { + HttpClient, + createEmptyPipeline, + createHttpHeaders, + createPipelineRequest, +} from "@azure/core-rest-pipeline"; +import { createRequestPolicyFactoryPolicy } from "../src/policies/requestPolicyFactoryPolicy"; +import { mutateRequestPolicy, mutateResponsePolicy } from "./testPolicies"; + +describe("requestPolicyFactoryPolicy", function () { + const testHttpClient: HttpClient = { + sendRequest: async (request) => { + return { + request, + headers: createHttpHeaders({ "server-header": "some-value" }), + status: 200, + }; + }, + }; + it("should preserve changes to headers in the request", async function () { + const policy = createRequestPolicyFactoryPolicy([ + mutateRequestPolicy({ headersToSet: { "x-ms-test": "testValue" } }), + ]); + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(policy); + const result = await pipeline.sendRequest( + testHttpClient, + createPipelineRequest({ url: "test" }) + ); + assert.strictEqual(result.headers.get("server-header"), "some-value"); + assert.strictEqual(result.request.headers.get("x-ms-test"), "testValue"); + }); + + it("should preserve changes to headers in the response", async function () { + const policy = createRequestPolicyFactoryPolicy([ + mutateResponsePolicy({ headersToSet: { "x-ms-test": "testValue" } }), + ]); + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(policy); + const result = await pipeline.sendRequest( + testHttpClient, + createPipelineRequest({ url: "test" }) + ); + assert.strictEqual(result.headers.get("server-header"), "some-value"); + assert.strictEqual(result.headers.get("x-ms-test"), "testValue"); + }); + + it("should preserve changes made to both request and response", async function () { + const policy = createRequestPolicyFactoryPolicy([ + mutateRequestPolicy({ + headersToSet: { "x-ms-test": "request" }, + url: "test2", + timeout: 9000, + }), + mutateResponsePolicy({ headersToSet: { "x-ms-test": "response" } }), + ]); + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(policy); + const result = await pipeline.sendRequest( + testHttpClient, + createPipelineRequest({ url: "test" }) + ); + assert.strictEqual(result.request.url, "test2"); + assert.strictEqual(result.request.timeout, 9000); + assert.strictEqual(result.request.headers.get("x-ms-test"), "request"); + assert.strictEqual(result.headers.get("server-header"), "some-value"); + assert.strictEqual(result.headers.get("x-ms-test"), "response"); + }); +}); diff --git a/sdk/core/core-http-compat/test/testPolicies.ts b/sdk/core/core-http-compat/test/testPolicies.ts new file mode 100644 index 000000000000..ea87d998c3a3 --- /dev/null +++ b/sdk/core/core-http-compat/test/testPolicies.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + CompatResponse, + RequestPolicy, + RequestPolicyFactory, + RequestPolicyOptionsLike, + WebResourceLike, +} from "../src/index"; + +export interface MutateOptions { + headersToSet?: { [name: string]: string }; +} + +export interface RequestMutateOptions extends MutateOptions { + url?: string; + timeout?: number; +} + +export function mutateRequestPolicy(mutateOptions: RequestMutateOptions): RequestPolicyFactory { + return { + create: (nextPolicy: RequestPolicy, options: RequestPolicyOptionsLike) => { + return new MutateRequestPolicy(nextPolicy, options, mutateOptions); + }, + }; +} + +export class MutateRequestPolicy { + constructor( + private _nextPolicy: RequestPolicy, + _options: RequestPolicyOptionsLike, + private readonly _mutateOptions: RequestMutateOptions + ) { + /** Nothing much to do here */ + } + + public async sendRequest(request: WebResourceLike): Promise { + const headersToSet = this._mutateOptions.headersToSet; + for (const [name, value] of Object.entries(headersToSet ?? {})) { + request.headers.set(name, value); + } + if (this._mutateOptions.url) { + request.url = this._mutateOptions.url; + } + if (typeof this._mutateOptions.timeout === "number") { + request.timeout = this._mutateOptions.timeout; + } + return this._nextPolicy.sendRequest(request); + } +} + +export function mutateResponsePolicy(mutateOptions: MutateOptions): RequestPolicyFactory { + return { + create: (nextPolicy: RequestPolicy, options: RequestPolicyOptionsLike) => { + return new MutateResponsePolicy(nextPolicy, options, mutateOptions); + }, + }; +} + +export class MutateResponsePolicy { + constructor( + private _nextPolicy: RequestPolicy, + _options: RequestPolicyOptionsLike, + private readonly _mutateOptions: MutateOptions + ) { + /** Nothing much to do here */ + } + + public async sendRequest(request: WebResourceLike): Promise { + const response = await this._nextPolicy.sendRequest(request); + const headersToSet = this._mutateOptions.headersToSet; + for (const [name, value] of Object.entries(headersToSet ?? {})) { + response.headers.set(name, value); + } + return response; + } +}