snowflake-sdk
Version:
Node.js driver for Snowflake
377 lines (340 loc) • 13.3 kB
JavaScript
/*
* Copyright (c) 2015-2024 Snowflake Computing Inc. All rights reserved.
*/
const zlib = require('zlib');
const Util = require('../util');
const Logger = require('../logger');
const ExecutionTimer = require('../logger/execution_timer');
const axios = require('axios');
const URL = require('node:url').URL;
const requestUtil = require('./request_util');
const DEFAULT_REQUEST_TIMEOUT = 360000;
/**
* Creates a new HTTP client.
*
* @param connectionConfig
* @constructor
*/
function HttpClient(connectionConfig) {
// save the connection config
Logger.getInstance().trace('Initializing base HttpClient with Connection Config[%s]',
connectionConfig.describeIdentityAttributes());
this._connectionConfig = connectionConfig;
}
/**
* Issues an HTTP request.
*
* @param {Object} options
*
* @returns {Object} an object representing the request that was issued.
*/
HttpClient.prototype.request = function (options) {
Logger.getInstance().trace('Request%s - preparing for sending.', requestUtil.describeRequestFromOptions(options));
const requestObject = {};
const requestAbortController = new AbortController();
const requestHandlers = { signal: requestAbortController.signal };
const requestOptions = prepareRequestOptions.call(this, options, requestHandlers);
let sendRequest = async function sendRequest() {
Logger.getInstance().trace('Request%s - sending.', requestUtil.describeRequestFromOptions(requestOptions));
const timer = new ExecutionTimer().start();
requestObject.requestPromise = axios.request(requestOptions).then(response => {
const httpResponseTime = timer.getDuration();
Logger.getInstance().debug('Request%s - response received after %s milliseconds with status %s.', requestUtil.describeRequestFromOptions(requestOptions), httpResponseTime, response.status);
sanitizeAxiosResponse(response);
if (Util.isFunction(options.callback)) {
Logger.getInstance().trace('Request%s - calling callback function.', requestUtil.describeRequestFromOptions(requestOptions));
return options.callback(null, normalizeResponse(response), response.data);
} else {
Logger.getInstance().trace('Request%s - callback function was not provided.', requestUtil.describeRequestFromOptions(requestOptions));
return null;
}
}).catch(err => {
const httpResponseTime = timer.getDuration();
Logger.getInstance().debug('Request%s - failed after %s milliseconds.', requestUtil.describeRequestFromOptions(requestOptions), httpResponseTime);
sanitizeAxiosError(err);
if (Util.isFunction(options.callback)) {
if (err.response) { // axios returns error for not 2xx responses - let's unwrap it
Logger.getInstance().trace('Request%s - calling callback function for error from response. Received code: ', requestUtil.describeRequestFromOptions(requestOptions), err.response.status);
options.callback(null, normalizeResponse(err.response), err.response.data);
} else {
Logger.getInstance().trace('Request%s - calling callback function for error without response.', requestUtil.describeRequestFromOptions(requestOptions));
options.callback(err, normalizeResponse(null), null);
}
return null;
} else {
Logger.getInstance().warn('Request%s - callback function was not provided. Error will be re-raised.', requestUtil.describeRequestFromOptions(requestOptions));
throw err;
}
});
};
sendRequest = sendRequest.bind(this);
Logger.getInstance().trace('Request%s - issued for the next tick.', requestUtil.describeRequestFromOptions(requestOptions));
process.nextTick(sendRequest);
// return an externalized request object that only contains
// methods we're comfortable exposing to the outside world
requestObject.abort = function () {
if (requestAbortController) {
Logger.getInstance().trace('Request%s - aborting.', requestUtil.describeRequestFromOptions(requestOptions));
requestAbortController.abort();
Logger.getInstance().debug('Request%s - aborted.', requestUtil.describeRequestFromOptions(requestOptions));
}
};
return requestObject;
};
/**
* Issues an HTTP request.
*
* @param {Object} options
*
* @returns {Object} an object representing the request that was issued.
*/
HttpClient.prototype.requestAsync = async function (options) {
Logger.getInstance().trace('Request%s - preparing for async sending.', requestUtil.describeRequestFromOptions(options));
const timer = new ExecutionTimer();
try {
const requestOptions = prepareRequestOptions.call(this, options);
timer.start();
const response = await axios.request(requestOptions);
const httpResponseTime = timer.getDuration();
Logger.getInstance().debug('Request%s - response received after %s milliseconds with status %s.', requestUtil.describeRequestFromOptions(requestOptions), httpResponseTime, response.status);
parseResponseData(response);
sanitizeAxiosResponse(response);
return normalizeResponse(response);
} catch (err) {
const httpResponseTime = timer.getDuration();
Logger.getInstance().debug('Request%s - failed after %s milliseconds. Error will be re-raised.', requestUtil.describeRequestFromOptions(options), httpResponseTime);
sanitizeAxiosError(err);
throw err;
}
};
function parseResponseData(response) {
Logger.getInstance().trace('Request%s - parsing response data.', requestUtil.describeRequestFromResponse(response));
parseIfJSONData(response);
}
function parseIfJSONData(response) {
if (Util.isString(response['data']) &&
response['headers']['content-type'] === 'application/json') {
response['data'] = JSON.parse(response['data']);
}
}
/**
* Issues an HTTP POST request.
*
* @param {String} url
* @param {String} body
* @param {Object} options
*
* @returns {Object} an object representing the request that was issued.
*/
HttpClient.prototype.post = async function (url, body, options) {
return this.requestAsync({
url: url,
method: 'POST',
data: body,
...options
});
};
/**
* Issues an HTTP GET request.
*
* @param {String} url
* @param {Object} params
*
* @returns {Object} an object representing the request that was issued.
*/
HttpClient.prototype.get = async function (url, params) {
return this.requestAsync({
url: url,
method: 'GET',
...params,
});
};
/**
* Issues an HTTP HEAD request.
*
* @param {String} url
* @param {Object} config
*
* @returns {Object} an object representing the request that was issued.
*/
HttpClient.prototype.head = async function (url, config) {
return this.requestAsync({
url: url,
method: 'HEAD',
...config
});
};
/**
* Issues an HTTP PUT request.
*
* @param {String} url
* @param {Object} data
* @param {Object} config
*
* @returns {Object} an object representing the request that was issued.
*/
HttpClient.prototype.put = async function (url, data, config) {
return this.requestAsync({
url: url,
method: 'PUT',
data: data,
...config
});
};
/**
* @abstract
* Returns the module to use when making HTTP requests. Subclasses must override
* and provide their own implementations.
*
* @returns {*}
*/
HttpClient.prototype.getRequestModule = function () {
return null;
};
/**
* Returns the agent and proxy options.
*
* @returns {*}
*/
HttpClient.prototype.getAgent = function () {
return null;
};
module.exports = HttpClient;
function sanitizeAxiosResponse(response) {
Logger.getInstance().trace('Request%s - sanitizing response data.', requestUtil.describeRequestFromResponse(response));
response.request = undefined;
if (response.config) {
response.config.data = undefined;
response.config.headers = undefined;
}
}
function sanitizeAxiosError(error) {
error.request = undefined;
error.config = undefined;
if (error.response) {
Logger.getInstance().trace('Request%s - sanitizing response error data.', requestUtil.describeRequestFromResponse(error.response));
sanitizeAxiosResponse(error.response);
}
}
function prepareRequestOptions(options, requestHandlers = {}) {
Logger.getInstance().trace('Request%s - constructing options.', requestUtil.describeRequestFromOptions(options));
const headers = normalizeHeaders(options.headers) || {};
const timeout = options.timeout ||
this._connectionConfig.getTimeout() ||
DEFAULT_REQUEST_TIMEOUT;
let data = options.data || options.json;
if (data) {
const bufferUncompressed = Buffer.from(JSON.stringify(data), 'utf8');
zlib.gzip(bufferUncompressed, null, function (err, bufferCompressed) {
// if the compression was successful
if (!err) {
data = bufferCompressed;
headers['Content-Encoding'] = 'gzip';
Logger.getInstance().debug('Request%s - original buffer length: %d bytes. Compressed buffer length: %d bytes.', requestUtil.describeRequestFromOptions(options), bufferUncompressed.buffer.byteLength, bufferCompressed.buffer.byteLength);
} else {
// Logging 'err' variable value should not be done, since it may contain compressed customer's data.
// It can be added only for debugging purposes.
Logger.getInstance().warn('Request%s - could not compress request data.', requestUtil.describeRequestFromOptions(options));
}
});
}
const params = options.params;
let mock;
if (this._connectionConfig.agentClass) {
mock = {
agentClass: this._connectionConfig.agentClass
};
}
const backoffStrategy = this.constructExponentialBackoffStrategy();
const requestOptions = {
method: options.method,
url: options.url,
headers: headers,
data: data,
params: params,
timeout: timeout,
requestOCSP: true,
retryDelay: backoffStrategy,
rejectUnauthorized: true,
// we manually parse jsons or other structures from the server so they need to be text
responseType: options.responseType || 'text',
proxy: false,
...requestHandlers
};
const url = new URL(options.url);
const isHttps = url.protocol === 'https:';
const agent = this.getAgent(url, this._connectionConfig.getProxy(), mock);
if (isHttps) {
requestOptions.httpsAgent = agent;
} else {
requestOptions.httpAgent = agent;
}
Logger.getInstance().debug('Request%s - options - timeout: %s, retryDelay: %s, responseType: %s', requestUtil.describeRequestFromOptions(options), requestOptions.timeout, requestOptions.retryDelay, requestOptions.responseType);
return requestOptions;
}
/**
* Normalizes a request headers object so that we get the same behavior
* regardless of whether we're using request.js or browser-request.js.
*
* @param {Object} headers
*
* @returns {Object}
*/
function normalizeHeaders(headers) {
Logger.getInstance().trace('Normalizing headers');
if (Util.isObject(headers)) {
const normalizedHeaders = {
'user-agent': Util.userAgent
};
// shallow copy the headers object and convert some headers like 'Accept'
// and 'Content-Type' to lower case while copying; this is necessary
// because the browser-request module, which we use to make http requests in
// the browser, does not do case-insensitive checks when deciding whether to
// insert default values for the 'accept' and 'content-type' headers; in
// otherwise, if someone specifies an 'Accept': 'application/json' header,
// browser-request will inject its own 'accept': 'application/json' header
// and the browser XMLHttpRequest object will concatenate the two values and
// send 'Accept': 'application/json, application/json' with the request
let headerNameLowerCase;
for (const headerName in headers) {
if (Object.prototype.hasOwnProperty.call(headers, headerName)) {
headerNameLowerCase = headerName.toLowerCase();
if ((headerNameLowerCase === 'accept') ||
(headerNameLowerCase === 'content-type')) {
normalizedHeaders[headerNameLowerCase] = headers[headerName];
} else {
normalizedHeaders[headerName] = headers[headerName];
}
}
}
Logger.getInstance().trace('Headers were normalized');
return normalizedHeaders;
} else {
Logger.getInstance().trace('Headers were not an object. Original value will be returned.');
return headers;
}
}
/**
* Normalizes the response object so that we can extract response headers from
* it in a uniform way regardless of whether we're using request.js or
* browser-request.js.
*
* @param {Object} response
*
* @return {Object}
*/
function normalizeResponse(response) {
// if the response doesn't already have a getResponseHeader() method, add one
if (response && !response.getResponseHeader) {
Logger.getInstance().trace('Request%s - normalizing.', requestUtil.describeRequestFromResponse(response));
response.getResponseHeader = function (header) {
return response.headers && response.headers[
Util.isString(header) ? header.toLowerCase() : header];
};
}
if (response) {
response.body = response.data; // converting axios response body to old expected body attribute
response.statusCode = response.status; // converting axios status to old expected statusCode
}
return response;
}