// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, Method } from "axios"; import { Transform, Readable } from "stream"; import FormData from "form-data"; import * as tough from "tough-cookie"; import { HttpClient } from "./httpClient"; import { HttpHeaders } from "./httpHeaders"; import { HttpOperationResponse } from "./httpOperationResponse"; import { RestError } from "./restError"; import { WebResource, HttpRequestBody } from "./webResource"; import * as tunnel from "tunnel"; import { ProxySettings } from "./serviceClient"; import * as http from "http"; import * as https from "https"; import { URLBuilder } from "./url"; export const axiosInstance = axios.create(); // This hack is still required with 0.19.0 version of axios since axios tries to merge the // Content-Type header from it's config[""] where the method name is lower-case, // into the request header. It could be possible that the Content-Type header is not present // in the original request and this would create problems while creating the signature for // storage data plane sdks. axiosInstance.interceptors.request.use((config: AxiosRequestConfig) => ({ ...config, method: (config.method as Method) && (config.method as Method).toUpperCase() as Method })); // keepalive agents are reused across instances to provide maximum socket reuse for // outbound requests const keepaliveAgents = { http: new http.Agent({keepAlive: true}), https: new https.Agent({keepAlive: true}), }; /** * A HttpClient implementation that uses axios to send HTTP requests. */ export class AxiosHttpClient implements HttpClient { private readonly cookieJar = new tough.CookieJar(); public async sendRequest(httpRequest: WebResource): Promise { if (typeof httpRequest !== "object") { throw new Error("httpRequest (WebResource) cannot be null or undefined and must be of type object."); } if (httpRequest.formData) { const formData: any = httpRequest.formData; const requestForm = new FormData(); const appendFormValue = (key: string, value: any) => { // value function probably returns a stream so we can provide a fresh stream on each retry if (typeof value === "function") { value = value(); } if (value && value.hasOwnProperty("value") && value.hasOwnProperty("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"); } } } if (this.cookieJar && !httpRequest.headers.get("Cookie")) { const cookieString = await new Promise((resolve, reject) => { this.cookieJar!.getCookieString(httpRequest.url, (err, cookie) => { if (err) { reject(err); } else { resolve(cookie); } }); }); httpRequest.headers.set("Cookie", cookieString); } const abortSignal = httpRequest.abortSignal; if (abortSignal && abortSignal.aborted) { throw new RestError("The request was aborted", RestError.REQUEST_ABORTED_ERROR, undefined, httpRequest); } let abortListener: (() => void) | undefined; const cancelToken = abortSignal && new axios.CancelToken(canceler => { abortListener = () => canceler(); abortSignal.addEventListener("abort", abortListener); }); const rawHeaders: { [headerName: string]: string } = httpRequest.headers.rawHeaders(); const httpRequestBody: HttpRequestBody = httpRequest.body; let axiosBody = // Workaround for https://github.com/axios/axios/issues/755 // tslint:disable-next-line:no-null-keyword typeof httpRequestBody === "undefined" ? null : typeof httpRequestBody === "function" ? httpRequestBody() : httpRequestBody; const onUploadProgress = httpRequest.onUploadProgress; if (onUploadProgress && axiosBody) { let loadedBytes = 0; const uploadReportStream = new Transform({ transform: (chunk: string | Buffer, _encoding, callback) => { loadedBytes += chunk.length; onUploadProgress({ loadedBytes }); callback(undefined, chunk); } }); if (isReadableStream(axiosBody)) { axiosBody.pipe(uploadReportStream); } else { uploadReportStream.end(axiosBody); } axiosBody = uploadReportStream; } let res: AxiosResponse; try { const config: AxiosRequestConfig = { method: httpRequest.method as Method, url: httpRequest.url, headers: rawHeaders, data: axiosBody, transformResponse: (data) => { return data; }, validateStatus: () => true, // Workaround for https://github.com/axios/axios/issues/1362 maxContentLength: Infinity, responseType: httpRequest.streamResponseBody ? "stream" : "text", cancelToken, timeout: httpRequest.timeout, proxy: false }; if (httpRequest.agentSettings) { const {http: httpAgent, https: httpsAgent} = httpRequest.agentSettings; if (httpsAgent) { config.httpsAgent = httpsAgent; } if (httpAgent) { config.httpAgent = httpAgent; } } else if (httpRequest.proxySettings) { const agent = createProxyAgent(httpRequest.url, httpRequest.proxySettings, httpRequest.headers); if (agent.isHttps) { config.httpsAgent = agent.agent; } else { config.httpAgent = agent.agent; } } if (httpRequest.keepAlive === true) { if (config.httpAgent) { config.httpAgent.keepAlive = true; } else { config.httpAgent = keepaliveAgents.http; } if (config.httpsAgent) { config.httpsAgent.keepAlive = true; } else { config.httpsAgent = keepaliveAgents.https; } } res = await axiosInstance.request(config); } catch (err) { if (err instanceof axios.Cancel) { throw new RestError(err.message, RestError.REQUEST_SEND_ERROR, undefined, httpRequest); } else { const axiosErr = err as AxiosError; throw new RestError(axiosErr.message, RestError.REQUEST_SEND_ERROR, undefined, httpRequest); } } finally { if (abortSignal && abortListener) { abortSignal.removeEventListener("abort", abortListener); } } const headers = new HttpHeaders(res.headers); const onDownloadProgress = httpRequest.onDownloadProgress; let responseBody: Readable | string = res.data; if (onDownloadProgress) { if (isReadableStream(responseBody)) { let loadedBytes = 0; const downloadReportStream = new Transform({ transform: (chunk: string | Buffer, _encoding, callback) => { loadedBytes += chunk.length; onDownloadProgress({ loadedBytes }); callback(undefined, chunk); } }); responseBody.pipe(downloadReportStream); responseBody = downloadReportStream; } else { const length = parseInt(headers.get("Content-Length")!) || (responseBody as string).length || undefined; if (length) { // Calling callback for non-stream response for consistency with browser onDownloadProgress({ loadedBytes: length }); } } } const operationResponse: HttpOperationResponse = { request: httpRequest, status: res.status, headers, readableStreamBody: httpRequest.streamResponseBody ? responseBody as Readable : undefined, bodyAsText: httpRequest.streamResponseBody ? undefined : responseBody as string }; if (this.cookieJar) { const setCookieHeader = operationResponse.headers.get("Set-Cookie"); if (setCookieHeader != undefined) { await new Promise((resolve, reject) => { this.cookieJar!.setCookie(setCookieHeader, httpRequest.url, (err) => { if (err) { reject(err); } else { resolve(); } }); }); } } return operationResponse; } } function isReadableStream(body: any): body is Readable { return typeof body.pipe === "function"; } declare type ProxyAgent = { isHttps: boolean; agent: http.Agent | https.Agent }; export function createProxyAgent(requestUrl: string, proxySettings: ProxySettings, headers?: HttpHeaders): ProxyAgent { const tunnelOptions: tunnel.HttpsOverHttpsOptions = { proxy: { host: URLBuilder.parse(proxySettings.host).getHost(), port: proxySettings.port, headers: (headers && headers.rawHeaders()) || {} } }; if ((proxySettings.username && proxySettings.password)) { tunnelOptions.proxy!.proxyAuth = `${proxySettings.username}:${proxySettings.password}`; } const requestScheme = URLBuilder.parse(requestUrl).getScheme() || ""; const isRequestHttps = requestScheme.toLowerCase() === "https"; const proxyScheme = URLBuilder.parse(proxySettings.host).getScheme() || ""; const isProxyHttps = proxyScheme.toLowerCase() === "https"; const proxyAgent = { isHttps: isRequestHttps, agent: createTunnel(isRequestHttps, isProxyHttps, tunnelOptions) }; return proxyAgent; } // Duplicate tunnel.HttpsOverHttpsOptions to avoid exporting createTunnel() with dependency on @types/tunnel // createIunnel() is only imported by tests. export interface HttpsProxyOptions { host?: string; port?: number; localAddress?: string; proxyAuth?: string; headers: { [key: string]: any }; ca?: Buffer[]; servername?: string; key?: Buffer; cert?: Buffer; } interface HttpsOverHttpsOptions { maxSockets?: number; ca?: Buffer[]; key?: Buffer; cert?: Buffer; proxy?: HttpsProxyOptions; } export function createTunnel(isRequestHttps: boolean, isProxyHttps: boolean, tunnelOptions: HttpsOverHttpsOptions): http.Agent | https.Agent { if (isRequestHttps && isProxyHttps) { return tunnel.httpsOverHttps(tunnelOptions); } else if (isRequestHttps && !isProxyHttps) { return tunnel.httpsOverHttp(tunnelOptions); } else if (!isRequestHttps && isProxyHttps) { return tunnel.httpOverHttps(tunnelOptions); } else { return tunnel.httpOverHttp(tunnelOptions); } }