diff --git a/sdk/core/core-http/package.json b/sdk/core/core-http/package.json index b3d0391dc907..e605b3255691 100644 --- a/sdk/core/core-http/package.json +++ b/sdk/core/core-http/package.json @@ -53,7 +53,6 @@ "./dist-esm/src/util/base64.js": "./dist-esm/src/util/base64.browser.js", "./dist-esm/src/util/xml.js": "./dist-esm/src/util/xml.browser.js", "./dist-esm/src/defaultHttpClient.js": "./dist-esm/src/defaultHttpClient.browser.js", - "./dist-esm/src/fetchHttpClient.js": "./dist-esm/src/fetchHttpClient.browser.js", "./dist-esm/src/util/inspect.js": "./dist-esm/src/util/inspect.browser.js", "./dist-esm/src/util/url.js": "./dist-esm/src/util/url.browser.js" }, diff --git a/sdk/core/core-http/review/core-http.api.md b/sdk/core/core-http/review/core-http.api.md index 55e6a4f76d14..3c58a4b3c93f 100644 --- a/sdk/core/core-http/review/core-http.api.md +++ b/sdk/core/core-http/review/core-http.api.md @@ -174,10 +174,11 @@ export function createSpanFunction(args: SpanConfig): ; prepareRequest(httpRequest: WebResourceLike): Promise>; processRequest(operationResponse: HttpOperationResponse): Promise; + sendRequest(httpRequest: WebResourceLike): Promise; } // @public @@ -245,14 +246,6 @@ export class ExpiringAccessTokenCache implements AccessTokenCache { // @public export function exponentialRetryPolicy(retryCount?: number, retryInterval?: number, maxRetryInterval?: number): RequestPolicyFactory; -// @public -export abstract class FetchHttpClient implements HttpClient { - abstract fetch(input: CommonRequestInfo, init?: CommonRequestInit): Promise; - abstract prepareRequest(httpRequest: WebResourceLike): Promise>; - abstract processRequest(operationResponse: HttpOperationResponse): Promise; - sendRequest(httpRequest: WebResourceLike): Promise; -} - // @public export function flattenResponse(_response: HttpOperationResponse, responseSpec: OperationResponse | undefined): RestResponse; diff --git a/sdk/core/core-http/src/coreHttp.ts b/sdk/core/core-http/src/coreHttp.ts index 4cd4e6a51113..251c2c1ec114 100644 --- a/sdk/core/core-http/src/coreHttp.ts +++ b/sdk/core/core-http/src/coreHttp.ts @@ -14,12 +14,7 @@ export { RequestOptionsBase, TransferProgressEvent } from "./webResource"; -export { - CommonResponse, - CommonRequestInit, - CommonRequestInfo, - FetchHttpClient -} from "./fetchHttpClient"; +export { CommonResponse, CommonRequestInit, CommonRequestInfo } from "./nodeFetchHttpClient"; export { DefaultHttpClient } from "./defaultHttpClient"; export { HttpClient } from "./httpClient"; export { HttpHeader, HttpHeaders, HttpHeadersLike, RawHttpHeaders } from "./httpHeaders"; diff --git a/sdk/core/core-http/src/fetchHttpClient.browser.ts b/sdk/core/core-http/src/fetchHttpClient.browser.ts deleted file mode 100644 index 007efa6451df..000000000000 --- a/sdk/core/core-http/src/fetchHttpClient.browser.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { HttpClient } from "./httpClient"; -import { HttpOperationResponse } from "./httpOperationResponse"; -import { WebResourceLike } from "./webResource"; - -/** - * String URLs used when calling to `fetch()`. - */ -export type CommonRequestInfo = string; - -/** - * An object containing information about the outgoing HTTP request. - */ -export type CommonRequestInit = Omit & { - body?: any; - headers?: any; - signal?: any; -}; - -/** - * An object containing information about the incoming HTTP response. - */ -export type CommonResponse = Omit & { - body: any; - trailer: any; - formData: any; -}; - -/** - * An abstract HTTP client that allows custom methods to prepare and send HTTP requests, as well as a custom method to parse the HTTP response. - * It implements a simple `sendRequest` method that provides minimum viable error handling and the logic that executes the abstract methods. - * It's intended to be used as the base class for HTTP clients that may use `window.fetch` or an isomorphic alternative. - * - * Only supported in Node.js - */ -export abstract class FetchHttpClient implements HttpClient { - /** - * Provides minimum viable error handling and the logic that executes the abstract methods. - * - * Only supported in Node.js - * @param httpRequest - Object representing the outgoing HTTP request. - * @returns An object representing the incoming HTTP response. - */ - async sendRequest(): Promise { - throw new Error("The FetchHttpClient is not supported in the browser."); - } - - /** - * Abstract method that allows preparing an outgoing HTTP request. - * @param httpRequest - Object representing the outgoing HTTP request. - */ - abstract prepareRequest(httpRequest: WebResourceLike): Promise>; - /** - * Abstract method that allows processing an incoming HTTP response. - * @param operationResponse - Object representing the incoming HTTP response. - */ - abstract processRequest(operationResponse: HttpOperationResponse): Promise; - /** - * Abstract method that defines how to send an HTTP request. - * @param input - String URL of the target HTTP server. - * @param init - Object describing the structure of the outgoing HTTP request. - */ - abstract fetch(input: CommonRequestInfo, init?: CommonRequestInit): Promise; -} diff --git a/sdk/core/core-http/src/fetchHttpClient.ts b/sdk/core/core-http/src/fetchHttpClient.ts deleted file mode 100644 index f3f7347c8f86..000000000000 --- a/sdk/core/core-http/src/fetchHttpClient.ts +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { AbortController, AbortError } from "@azure/abort-controller"; -import FormData from "form-data"; - -import { HttpClient } from "./httpClient"; -import { TransferProgressEvent, WebResourceLike } from "./webResource"; -import { HttpOperationResponse } from "./httpOperationResponse"; -import { HttpHeaders, HttpHeadersLike } from "./httpHeaders"; -import { RestError } from "./restError"; -import { Readable, Transform } from "stream"; -import { logger } from "./log"; - -interface FetchError extends Error { - code?: string; - errno?: string; - type?: string; -} - -/** - * String URLs used when calling to `fetch()`. - */ -export type CommonRequestInfo = string; - -/** - * An object containing information about the outgoing HTTP request. - */ -export type CommonRequestInit = Omit & { - body?: any; - headers?: any; - signal?: any; -}; - -/** - * An object containing information about the incoming HTTP response. - */ -export type CommonResponse = Omit & { - body: any; - trailer: any; - formData: any; -}; - -export class ReportTransform extends Transform { - private loadedBytes: number = 0; - _transform(chunk: string | Buffer, _encoding: string, callback: (arg: any) => void): void { - this.push(chunk); - this.loadedBytes += chunk.length; - this.progressCallback!({ loadedBytes: this.loadedBytes }); - callback(undefined); - } - - constructor(private progressCallback: (progress: TransferProgressEvent) => void) { - super(); - } -} - -/** - * An abstract HTTP client that allows custom methods to prepare and send HTTP requests, as well as a custom method to parse the HTTP response. - * It implements a simple `sendRequest` method that provides minimum viable error handling and the logic that executes the abstract methods. - * It's intended to be used as the base class for HTTP clients that may use `window.fetch` or an isomorphic alternative. - * - * Only supported in Node.js - */ -export abstract class FetchHttpClient implements HttpClient { - /** - * Provides minimum viable error handling and the logic that executes the abstract methods. - * @param httpRequest - Object representing the outgoing HTTP request. - * @returns An object representing the incoming HTTP response. - */ - async sendRequest(httpRequest: WebResourceLike): Promise { - if (!httpRequest && typeof httpRequest !== "object") { - throw new Error( - "'httpRequest' (WebResourceLike) cannot be null or undefined and must be of type object." - ); - } - - const abortController = new AbortController(); - let abortListener: ((event: any) => void) | undefined; - if (httpRequest.abortSignal) { - if (httpRequest.abortSignal.aborted) { - throw new AbortError("The operation was aborted."); - } - - abortListener = (event: Event) => { - if (event.type === "abort") { - abortController.abort(); - } - }; - httpRequest.abortSignal.addEventListener("abort", abortListener); - } - - if (httpRequest.timeout) { - setTimeout(() => { - abortController.abort(); - }, httpRequest.timeout); - } - - if (httpRequest.formData) { - const formData: any = httpRequest.formData; - const requestForm = new FormData(); - const appendFormValue = (key: string, value: any): void => { - // value function probably returns a stream so we can provide a fresh stream on each retry - if (typeof value === "function") { - value = value(); - } - if ( - value && - Object.prototype.hasOwnProperty.call(value, "value") && - Object.prototype.hasOwnProperty.call(value, "options") - ) { - requestForm.append(key, value.value, value.options); - } else { - requestForm.append(key, value); - } - }; - for (const formKey of Object.keys(formData)) { - const formValue = formData[formKey]; - if (Array.isArray(formValue)) { - for (let j = 0; j < formValue.length; j++) { - appendFormValue(formKey, formValue[j]); - } - } else { - appendFormValue(formKey, formValue); - } - } - - httpRequest.body = requestForm; - httpRequest.formData = undefined; - const contentType = httpRequest.headers.get("Content-Type"); - if (contentType && contentType.indexOf("multipart/form-data") !== -1) { - if (typeof requestForm.getBoundary === "function") { - httpRequest.headers.set( - "Content-Type", - `multipart/form-data; boundary=${requestForm.getBoundary()}` - ); - } else { - // browser will automatically apply a suitable content-type header - httpRequest.headers.remove("Content-Type"); - } - } - } - - let body = httpRequest.body - ? typeof httpRequest.body === "function" - ? httpRequest.body() - : httpRequest.body - : undefined; - if (httpRequest.onUploadProgress && httpRequest.body) { - const onUploadProgress = httpRequest.onUploadProgress; - const uploadReportStream = new ReportTransform(onUploadProgress); - if (isReadableStream(body)) { - body.pipe(uploadReportStream); - } else { - uploadReportStream.end(body); - } - - body = uploadReportStream; - } - - const platformSpecificRequestInit: Partial = await this.prepareRequest( - httpRequest - ); - - const requestInit: RequestInit = { - body: body, - headers: httpRequest.headers.rawHeaders(), - method: httpRequest.method, - signal: abortController.signal, - redirect: "manual", - ...platformSpecificRequestInit - }; - - let operationResponse: HttpOperationResponse | undefined; - try { - const response: CommonResponse = await this.fetch(httpRequest.url, requestInit); - - const headers = parseHeaders(response.headers); - - const streaming = - httpRequest.streamResponseStatusCodes?.has(response.status) || - httpRequest.streamResponseBody; - - operationResponse = { - headers: headers, - request: httpRequest, - status: response.status, - readableStreamBody: streaming - ? ((response.body as unknown) as NodeJS.ReadableStream) - : undefined, - bodyAsText: !streaming ? await response.text() : undefined - }; - - const onDownloadProgress = httpRequest.onDownloadProgress; - if (onDownloadProgress) { - const responseBody: ReadableStream | undefined = response.body || undefined; - - if (isReadableStream(responseBody)) { - const downloadReportStream = new ReportTransform(onDownloadProgress); - responseBody.pipe(downloadReportStream); - operationResponse.readableStreamBody = downloadReportStream; - } else { - const length = parseInt(headers.get("Content-Length")!) || undefined; - if (length) { - // Calling callback for non-stream response for consistency with browser - onDownloadProgress({ loadedBytes: length }); - } - } - } - - await this.processRequest(operationResponse); - - return operationResponse; - } catch (error) { - const fetchError: FetchError = error; - if (fetchError.code === "ENOTFOUND") { - throw new RestError( - fetchError.message, - RestError.REQUEST_SEND_ERROR, - undefined, - httpRequest - ); - } else if (fetchError.type === "aborted") { - throw new AbortError("The operation was aborted."); - } - - throw fetchError; - } finally { - // clean up event listener - if (httpRequest.abortSignal && abortListener) { - let uploadStreamDone = Promise.resolve(); - if (isReadableStream(body)) { - uploadStreamDone = isStreamComplete(body); - } - let downloadStreamDone = Promise.resolve(); - if (isReadableStream(operationResponse?.readableStreamBody)) { - downloadStreamDone = isStreamComplete( - operationResponse!.readableStreamBody, - abortController - ); - } - - Promise.all([uploadStreamDone, downloadStreamDone]) - .then(() => { - httpRequest.abortSignal?.removeEventListener("abort", abortListener!); - return; - }) - .catch((e) => { - logger.warning("Error when cleaning up abortListener on httpRequest", e); - }); - } - } - } - - /** - * Abstract method that allows preparing an outgoing HTTP request. - * @param httpRequest - Object representing the outgoing HTTP request. - */ - abstract prepareRequest(httpRequest: WebResourceLike): Promise>; - /** - * Abstract method that allows processing an incoming HTTP response. - * @param operationResponse - Object representing the incoming HTTP response. - */ - abstract processRequest(operationResponse: HttpOperationResponse): Promise; - /** - * Abstract method that defines how to send an HTTP request. - * @param input - String URL of the target HTTP server. - * @param init - Object describing the structure of the outgoing HTTP request. - */ - abstract fetch(input: CommonRequestInfo, init?: CommonRequestInit): Promise; -} - -function isReadableStream(body: any): body is Readable { - return body && typeof body.pipe === "function"; -} - -function isStreamComplete(stream: Readable, aborter?: AbortController): Promise { - return new Promise((resolve) => { - stream.once("close", () => { - aborter?.abort(); - resolve(); - }); - stream.once("end", resolve); - stream.once("error", resolve); - }); -} - -/** - * Transforms a set of headers into the key/value pair defined by {@link HttpHeadersLike} - */ -export function parseHeaders(headers: Headers): HttpHeadersLike { - const httpHeaders = new HttpHeaders(); - - headers.forEach((value, key) => { - httpHeaders.set(key, value); - }); - - return httpHeaders; -} diff --git a/sdk/core/core-http/src/nodeFetchHttpClient.ts b/sdk/core/core-http/src/nodeFetchHttpClient.ts index 36450a620327..93420704fec8 100644 --- a/sdk/core/core-http/src/nodeFetchHttpClient.ts +++ b/sdk/core/core-http/src/nodeFetchHttpClient.ts @@ -5,16 +5,17 @@ import * as tough from "tough-cookie"; import * as http from "http"; import * as https from "https"; import node_fetch from "node-fetch"; +import FormData from "form-data"; +import { AbortError, AbortController } from "@azure/abort-controller"; -import { - FetchHttpClient, - CommonRequestInfo, - CommonRequestInit, - CommonResponse -} from "./fetchHttpClient"; import { HttpOperationResponse } from "./httpOperationResponse"; -import { WebResourceLike } from "./webResource"; +import { TransferProgressEvent, WebResourceLike } from "./webResource"; import { createProxyAgent, ProxyAgent, isUrlHttps } from "./proxyAgent"; +import { HttpClient } from "./httpClient"; +import { Readable, Transform } from "stream"; +import { HttpHeaders, HttpHeadersLike } from "./httpHeaders"; +import { RestError } from "./restError"; +import { logger } from "./log"; interface AgentCache { httpAgent?: http.Agent; @@ -28,10 +29,270 @@ function getCachedAgent( return isHttps ? agentCache.httpsAgent : agentCache.httpAgent; } +interface FetchError extends Error { + code?: string; + errno?: string; + type?: string; +} + +/** + * String URLs used when calling to `fetch()`. + */ +export type CommonRequestInfo = string; + +/** + * An object containing information about the outgoing HTTP request. + */ +export type CommonRequestInit = Omit & { + body?: any; + headers?: any; + signal?: any; +}; + +/** + * An object containing information about the incoming HTTP response. + */ +export type CommonResponse = Omit & { + body: any; + trailer: any; + formData: any; +}; + +export class ReportTransform extends Transform { + private loadedBytes: number = 0; + _transform(chunk: string | Buffer, _encoding: string, callback: (arg: any) => void): void { + this.push(chunk); + this.loadedBytes += chunk.length; + this.progressCallback!({ loadedBytes: this.loadedBytes }); + callback(undefined); + } + + constructor(private progressCallback: (progress: TransferProgressEvent) => void) { + super(); + } +} + +function isReadableStream(body: any): body is Readable { + return body && typeof body.pipe === "function"; +} + +function isStreamComplete(stream: Readable, aborter?: AbortController): Promise { + return new Promise((resolve) => { + stream.once("close", () => { + aborter?.abort(); + resolve(); + }); + stream.once("end", resolve); + stream.once("error", resolve); + }); +} + +/** + * Transforms a set of headers into the key/value pair defined by {@link HttpHeadersLike} + */ +export function parseHeaders(headers: Headers): HttpHeadersLike { + const httpHeaders = new HttpHeaders(); + + headers.forEach((value, key) => { + httpHeaders.set(key, value); + }); + + return httpHeaders; +} + /** * An HTTP client that uses `node-fetch`. */ -export class NodeFetchHttpClient extends FetchHttpClient { +export class NodeFetchHttpClient implements HttpClient { + /** + * Provides minimum viable error handling and the logic that executes the abstract methods. + * @param httpRequest - Object representing the outgoing HTTP request. + * @returns An object representing the incoming HTTP response. + */ + async sendRequest(httpRequest: WebResourceLike): Promise { + if (!httpRequest && typeof httpRequest !== "object") { + throw new Error( + "'httpRequest' (WebResourceLike) cannot be null or undefined and must be of type object." + ); + } + + const abortController = new AbortController(); + let abortListener: ((event: any) => void) | undefined; + if (httpRequest.abortSignal) { + if (httpRequest.abortSignal.aborted) { + throw new AbortError("The operation was aborted."); + } + + abortListener = (event: Event) => { + if (event.type === "abort") { + abortController.abort(); + } + }; + httpRequest.abortSignal.addEventListener("abort", abortListener); + } + + if (httpRequest.timeout) { + setTimeout(() => { + abortController.abort(); + }, httpRequest.timeout); + } + + if (httpRequest.formData) { + const formData: any = httpRequest.formData; + const requestForm = new FormData(); + const appendFormValue = (key: string, value: any): void => { + // value function probably returns a stream so we can provide a fresh stream on each retry + if (typeof value === "function") { + value = value(); + } + if ( + value && + Object.prototype.hasOwnProperty.call(value, "value") && + Object.prototype.hasOwnProperty.call(value, "options") + ) { + requestForm.append(key, value.value, value.options); + } else { + requestForm.append(key, value); + } + }; + for (const formKey of Object.keys(formData)) { + const formValue = formData[formKey]; + if (Array.isArray(formValue)) { + for (let j = 0; j < formValue.length; j++) { + appendFormValue(formKey, formValue[j]); + } + } else { + appendFormValue(formKey, formValue); + } + } + + httpRequest.body = requestForm; + httpRequest.formData = undefined; + const contentType = httpRequest.headers.get("Content-Type"); + if (contentType && contentType.indexOf("multipart/form-data") !== -1) { + if (typeof requestForm.getBoundary === "function") { + httpRequest.headers.set( + "Content-Type", + `multipart/form-data; boundary=${requestForm.getBoundary()}` + ); + } else { + // browser will automatically apply a suitable content-type header + httpRequest.headers.remove("Content-Type"); + } + } + } + + let body = httpRequest.body + ? typeof httpRequest.body === "function" + ? httpRequest.body() + : httpRequest.body + : undefined; + if (httpRequest.onUploadProgress && httpRequest.body) { + const onUploadProgress = httpRequest.onUploadProgress; + const uploadReportStream = new ReportTransform(onUploadProgress); + if (isReadableStream(body)) { + body.pipe(uploadReportStream); + } else { + uploadReportStream.end(body); + } + + body = uploadReportStream; + } + + const platformSpecificRequestInit: Partial = await this.prepareRequest( + httpRequest + ); + + const requestInit: RequestInit = { + body: body, + headers: httpRequest.headers.rawHeaders(), + method: httpRequest.method, + signal: abortController.signal, + redirect: "manual", + ...platformSpecificRequestInit + }; + + let operationResponse: HttpOperationResponse | undefined; + try { + const response: CommonResponse = await this.fetch(httpRequest.url, requestInit); + + const headers = parseHeaders(response.headers); + + const streaming = + httpRequest.streamResponseStatusCodes?.has(response.status) || + httpRequest.streamResponseBody; + + operationResponse = { + headers: headers, + request: httpRequest, + status: response.status, + readableStreamBody: streaming + ? ((response.body as unknown) as NodeJS.ReadableStream) + : undefined, + bodyAsText: !streaming ? await response.text() : undefined + }; + + const onDownloadProgress = httpRequest.onDownloadProgress; + if (onDownloadProgress) { + const responseBody: ReadableStream | undefined = response.body || undefined; + + if (isReadableStream(responseBody)) { + const downloadReportStream = new ReportTransform(onDownloadProgress); + responseBody.pipe(downloadReportStream); + operationResponse.readableStreamBody = downloadReportStream; + } else { + const length = parseInt(headers.get("Content-Length")!) || undefined; + if (length) { + // Calling callback for non-stream response for consistency with browser + onDownloadProgress({ loadedBytes: length }); + } + } + } + + await this.processRequest(operationResponse); + + return operationResponse; + } catch (error) { + const fetchError: FetchError = error; + if (fetchError.code === "ENOTFOUND") { + throw new RestError( + fetchError.message, + RestError.REQUEST_SEND_ERROR, + undefined, + httpRequest + ); + } else if (fetchError.type === "aborted") { + throw new AbortError("The operation was aborted."); + } + + throw fetchError; + } finally { + // clean up event listener + if (httpRequest.abortSignal && abortListener) { + let uploadStreamDone = Promise.resolve(); + if (isReadableStream(body)) { + uploadStreamDone = isStreamComplete(body); + } + let downloadStreamDone = Promise.resolve(); + if (isReadableStream(operationResponse?.readableStreamBody)) { + downloadStreamDone = isStreamComplete( + operationResponse!.readableStreamBody, + abortController + ); + } + + Promise.all([uploadStreamDone, downloadStreamDone]) + .then(() => { + httpRequest.abortSignal?.removeEventListener("abort", abortListener!); + return; + }) + .catch((e) => { + logger.warning("Error when cleaning up abortListener on httpRequest", e); + }); + } + } + } + // a mapping of proxy settings string `${host}:${port}:${username}:${password}` to agent private proxyAgentMap: Map = new Map(); private keepAliveAgents: AgentCache = {}; diff --git a/sdk/core/core-http/test/defaultHttpClientTests.node.ts b/sdk/core/core-http/test/defaultHttpClientTests.node.ts index ae635d825982..f08e3f5eb657 100644 --- a/sdk/core/core-http/test/defaultHttpClientTests.node.ts +++ b/sdk/core/core-http/test/defaultHttpClientTests.node.ts @@ -13,7 +13,7 @@ import { DefaultHttpClient } from "../src/defaultHttpClient"; import { WebResource, TransferProgressEvent } from "../src/webResource"; import { getHttpMock, HttpMockFacade } from "./mockHttp"; import { PassThrough, Readable } from "stream"; -import { ReportTransform, CommonResponse } from "../src/fetchHttpClient"; +import { ReportTransform, CommonResponse } from "../src/nodeFetchHttpClient"; import { CompositeMapper, Serializer } from "../src/serializer"; import { OperationSpec } from "../src/operationSpec"; import { AbortController } from "@azure/abort-controller"; diff --git a/sdk/core/core-http/test/defaultHttpClientTests.ts b/sdk/core/core-http/test/defaultHttpClientTests.ts index 0d8bde425ae5..d986c1fa2579 100644 --- a/sdk/core/core-http/test/defaultHttpClientTests.ts +++ b/sdk/core/core-http/test/defaultHttpClientTests.ts @@ -13,7 +13,7 @@ import { DefaultHttpClient } from "../src/defaultHttpClient"; import { RestError } from "../src/restError"; import { WebResource, TransferProgressEvent } from "../src/webResource"; import { getHttpMock, HttpMockFacade } from "./mockHttp"; -import { CommonResponse } from "../src/fetchHttpClient"; +import { CommonResponse } from "../src/nodeFetchHttpClient"; describe("defaultHttpClient", function() { function sleep(ms: number): Promise {