P8-ExchangeService/core/http_client.js

635 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Сервис интеграции ПП Парус 8 с WEB API
Модуль ядра: работа с HTTP/HTTPS запросами
*/
//------------------------------
// Подключение внешних библиотек
//------------------------------
const http = require("http"); //Встроенная поддержка HTTP
const https = require("https"); //Встроенная поддержка HTTPS
const { URL } = require("url"); //Обслуживание типовых URL
const { Socket } = require("net"); //Встроенная поддержка сокетов
//-------------------------
// Локальные идентификаторы
//-------------------------
//Таймаут по умолчанию
const DEFAULT_TIMEOUT = 30000;
//Ошибка HTTP-запроса
class HttpError extends Error {
constructor(message, response) {
super(message);
this.name = "HttpError";
this.response = response;
}
}
//------------------------
// Вспомогательные функции
//------------------------
//Нормализация параметров запроса (для совместимости с ранее применяемыми библиотеками "Request" и "Request-Promice")
const normalizeOptions = options => {
//Проверим, что нам предлагают обработать объект
if (!options || typeof options !== "object") {
throw new TypeError("Параметры HTTP-запроса должны быть объектом");
}
//Поддержка параметра uri (как в request-promise)
const url = options.uri || options.url;
if (!url) throw new Error("options.url или options.uri обязателен");
//Обработаем параметр "JSON"
const hasJsonOption = Object.prototype.hasOwnProperty.call(options, "json");
const jsonOption = options.json;
let body = options.body;
let jsonRequest = false;
let json = false;
if (hasJsonOption) {
if (jsonOption === true) {
json = true;
} else if (jsonOption && typeof jsonOption === "object") {
if (body === undefined) {
body = jsonOption;
jsonRequest = true;
}
json = true;
}
}
//Определяем HTTP-метод
let method = options.method;
if (!method) {
const hasRequestBody = body != null;
method = hasRequestBody ? "POST" : "GET";
}
//Собираем все уже отконвертированные параметры вместе + конвертируем то, что не требует выноса логики
return {
method: String(method).toUpperCase(),
url,
headers: options.headers || {},
query: options.query || options.qs || {},
body,
json,
jsonRequest,
timeout: options.timeout ?? DEFAULT_TIMEOUT,
followRedirects: options.followRedirects ?? true,
proxy: options.proxy || null,
ca: options.ca,
cert: options.cert,
key: options.key,
passphrase: options.passphrase,
rejectUnauthorized: options.rejectUnauthorized !== undefined ? options.rejectUnauthorized : true,
throwOnErrorStatus: options.throwOnErrorStatus ?? options.simple !== false,
signal: options.signal || null,
resolveWithFullResponse: options.resolveWithFullResponse || false,
encoding: options.encoding || null
};
};
//Формирование ссылки
const buildURL = (base, query = {}) => {
const url = new URL(base);
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null) return;
url.searchParams.append(key, String(value));
});
return url;
};
//Подготовка заголовков запроса
const prepareHeaders = (inputHeaders = {}) => {
const headers = new Map();
Object.entries(inputHeaders).forEach(([key, value]) => {
if (value === undefined || value === null) return;
headers.set(String(key).toLowerCase(), String(value));
});
return headers;
};
//Подготовка тела запроса
const prepareBody = ({ body, jsonRequest, headers }) => {
//Тела нет
if (body === undefined || body === null) {
return { body: undefined, contentLength: undefined, isStream: false };
}
//Явно указано, что тело нужно сериализовать как JSON
if (jsonRequest) {
const payload = Buffer.from(JSON.stringify(body));
if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8");
return { body: payload, contentLength: payload.length, isStream: false };
}
//Тело - бинарные данные
if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
return { body, contentLength: body.length, isStream: false };
}
//Тело - строка
if (typeof body === "string") {
const payload = Buffer.from(body);
return { body: payload, contentLength: payload.length, isStream: false };
}
//Тело - поток
if (typeof body.pipe === "function") {
return { body, contentLength: undefined, isStream: true };
}
//По умолчанию объектное тело сериализуем в JSON
if (typeof body === "object") {
const payload = Buffer.from(JSON.stringify(body));
if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8");
return { body: payload, contentLength: payload.length, isStream: false };
}
//Фолбэк для прочих типов
const payload = Buffer.from(String(body));
return { body: payload, contentLength: payload.length, isStream: false };
};
//Обработка ответа (парсинг JSON, форматирование для совместимости с "Request" и "Request-Promice")
const processResponse = (result, options) => {
//Буфер для обработки тела
let processedBody = result.body;
//Если запрошен автоматический парсинг JSON
if (options.json === true && processedBody) {
try {
const text = processedBody.toString(options.encoding || "utf8");
processedBody = text ? JSON.parse(text) : null;
} catch (e) {
//Если не удалось распарсить JSON, возвращаем как есть
processedBody = processedBody.toString(options.encoding || "utf8");
}
} else if (options.encoding && processedBody) {
//Если указана кодировка, конвертируем в строку
processedBody = processedBody.toString(options.encoding);
}
//Формируем результат в зависимости от "resolveWithFullResponse" (это параметр из "Request": true - отвечать с заголовком, false - отвечать сразу телом)
if (options.resolveWithFullResponse) {
//Просили ответить полным ответом
return {
statusCode: result.statusCode,
statusMessage: result.statusMessage || "",
headers: result.headers,
body: processedBody,
url: result.url
};
} else {
//Просили только тело
return processedBody;
}
};
//Выполнение HTTP/HTTPS запроса через встроенные модули NodeJS (для прокси и TLS)
const httpRequestNative = (options, url, headers, body) => {
//Возвращаем промис - запрос исполняется асинхронно
return new Promise((resolve, reject) => {
//Определяемся с адресом
const urlObj = new URL(url);
//И модулем, обслуживающим протокол
const isHttps = urlObj.protocol === "https:";
const httpModule = isHttps ? https : http;
//Настройки TLS
const tlsOptions = {};
if (options.ca) {
tlsOptions.ca = Array.isArray(options.ca) ? options.ca : [options.ca];
}
if (options.cert) {
tlsOptions.cert = options.cert;
}
if (options.key) {
tlsOptions.key = options.key;
}
if (options.passphrase) {
tlsOptions.passphrase = options.passphrase;
}
if (options.rejectUnauthorized !== undefined) {
tlsOptions.rejectUnauthorized = options.rejectUnauthorized;
}
//Настройки прокси
let proxyUrl = null;
let proxyAuth = null;
if (options.proxy) {
try {
proxyUrl = new URL(options.proxy);
if (proxyUrl.username || proxyUrl.password) {
proxyAuth = `Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username || "")}:${decodeURIComponent(proxyUrl.password || "")}`).toString("base64")}`;
}
} catch (e) {
return reject(new Error(`Некорректный URL прокси: ${e.message}`));
}
}
//Параметры запроса
const requestOptions = {
hostname: proxyUrl ? proxyUrl.hostname : urlObj.hostname,
port: proxyUrl ? proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80) : urlObj.port || (isHttps ? 443 : 80),
path: proxyUrl ? url : urlObj.pathname + urlObj.search,
method: options.method,
headers: Object.fromEntries(headers),
timeout: options.timeout || DEFAULT_TIMEOUT
};
//Добавляем авторизацию прокси в HTTP-заголовки
if (proxyAuth) {
requestOptions.headers["Proxy-Authorization"] = proxyAuth;
}
//Для HTTPS добавляем TLS опции
if (isHttps && Object.keys(tlsOptions).length > 0) {
Object.assign(requestOptions, tlsOptions);
}
//Если используется прокси, нужно использовать CONNECT метод для HTTPS
if (proxyUrl && isHttps) {
//Для HTTPS через прокси используем туннелирование
return httpRequestThroughProxy(requestOptions, urlObj, headers, body, proxyUrl, proxyAuth, tlsOptions, options)
.then(result => resolve(processResponse(result, options)))
.catch(reject);
}
//Обычный запрос (с прокси для HTTP или без прокси)
const req = httpModule.request(requestOptions, res => {
//Буфер для данных ответа
const chunks = [];
//При получении ответа
res.on("data", chunk => {
//Наполняем буфер данными от удаленного сервера
chunks.push(chunk);
});
//При получении признака завершения обмена
res.on("end", () => {
//Собираем тело как бинарный буфер
const responseBody = Buffer.concat(chunks);
//Формируем объект ответа в стиле "Request" для совметимости
const result = {
statusCode: res.statusCode,
statusMessage: res.statusMessage || "",
headers: res.headers,
body: responseBody,
ok: res.statusCode >= 200 && res.statusCode < 300,
url: url
};
//Если установлена опция "Выдавать ошибку для HTTP-статуса != 200" - будем отклонять промис с ней
if (options.throwOnErrorStatus && !result.ok) {
const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result);
const httpError = new Error(error.message);
httpError.response = result;
httpError.statusCode = result.statusCode;
return reject(httpError);
}
//Разрешаем промис с данными ответа
resolve(processResponse(result, options));
});
});
//При получении сетевых ошибок
req.on("error", err => {
const error = new Error(err.message);
error.code = err.code;
error.error = { code: err.code };
reject(error);
});
//При нарушении времени, выделенного на выполнения запроса
req.on("timeout", () => {
req.destroy();
const timeoutError = new Error(`Время ожидания выполнения запроса истекло после ${options.timeout || DEFAULT_TIMEOUT} мс`);
timeoutError.code = "ETIMEDOUT";
timeoutError.error = { code: "ETIMEDOUT" };
reject(timeoutError);
});
//Отправляем тело запроса
if (body) {
if (Buffer.isBuffer(body)) {
req.write(body);
} else if (typeof body === "string") {
req.write(Buffer.from(body));
} else {
req.write(Buffer.from(JSON.stringify(body)));
}
}
//Закрываем запрос и начинаем ждать ответ
req.end();
});
};
//Выполнение HTTPS запроса через HTTP прокси (туннелирование)
const httpRequestThroughProxy = (requestOptions, targetUrl, headers, body, proxyUrl, proxyAuth, tlsOptions, options) => {
//Выставим предельное время исполнения запроса
const timeout = options.timeout || DEFAULT_TIMEOUT;
//Запрос всё ещё асинхронный - возвращаем промис
return new Promise((resolve, reject) => {
//Начальная точка туннеля
const proxyHost = proxyUrl.hostname;
const proxyPort = parseInt(proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80), 10);
//Конечная точка туннеля
const targetHost = targetUrl.hostname;
const targetPort = parseInt(targetUrl.port || 443, 10);
//Создаем соединение с прокси и флаги для отслеживания его состояния (подключено, запрос отправлен)
const proxySocket = new Socket();
let connected = false;
let requestSent = false;
//Взводим таймер ожидания подключения для подчистки сокета, чтобы не было утечек памяти
const connectTimeout = setTimeout(() => {
if (!connected || !requestSent) {
proxySocket.destroy();
reject(new Error(`Время ожидания подключения к прокси истекло после ${timeout} мс`));
}
}, timeout);
//Настраиваем встроенный таймаут сокета
proxySocket.setTimeout(timeout);
proxySocket.on("timeout", () => {
proxySocket.destroy();
reject(new Error(`Время ожидания выполнения запроса истекло после ${timeout} мс`));
});
//При подключении
proxySocket.connect(proxyPort, proxyHost, () => {
//Поднимаем флаг подключенности
connected = true;
//Настраиваем CONNECT-запрос
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n`;
const authHeader = proxyAuth ? `Proxy-Authorization: ${proxyAuth}\r\n` : "";
const connectHeaders = connectRequest + authHeader + "\r\n";
//Отправляем CONNECT-запрос
proxySocket.write(connectHeaders);
//Буфер для данных ответа на CONNECT-запрос
let connectResponse = "";
//Функция для наполнения буфера ответа на CONNECT-запрос и его обработки
const onConnectData = chunk => {
//Добавляем в буфер очередную порцию данных, полученных от удаленного сервера
connectResponse += chunk.toString();
//Вычислим значение флага завершения заголовка и начала тела ответа (в HTTP они отделяются последовательностью "\r\n\r\n")
const headerEnd = connectResponse.indexOf("\r\n\r\n");
//Если был достигнут конец заголовка
if (headerEnd !== -1) {
//Прекращаем прием данных ответа на CONNECT-запрос
proxySocket.removeListener("data", onConnectData);
clearTimeout(connectTimeout);
//Проверяем ответ на CONNECT-запрос
const statusLine = connectResponse.substring(0, connectResponse.indexOf("\r\n"));
if (statusLine.includes("200")) {
//Успешное подключение, создаем TLS соединение
const tls = require("tls");
const secureSocket = tls.connect(
{
socket: proxySocket,
host: targetHost,
servername: targetHost,
...tlsOptions
},
() => {
//Поднимаем флаг отправки запроса
requestSent = true;
clearTimeout(connectTimeout);
//Подготавливаем HTTP-запрос
const path = targetUrl.pathname + targetUrl.search;
const requestLine = `${requestOptions.method} ${path} HTTP/1.1\r\n`;
const hostHeader = `Host: ${targetHost}${targetPort !== 443 ? `:${targetPort}` : ""}\r\n`;
const requestHeaders = Array.from(headers.entries())
.map(([key, value]) => `${key}: ${value}\r\n`)
.join("");
const httpRequest = requestLine + hostHeader + requestHeaders + "\r\n";
//Отправляем HTTP-запрос через TLS
secureSocket.write(httpRequest);
if (body) {
const bodyBuffer = Buffer.isBuffer(body)
? body
: typeof body === "string"
? Buffer.from(body)
: Buffer.from(JSON.stringify(body));
secureSocket.write(bodyBuffer);
}
//Готовим буфер для чтения ответа
let responseBuffer = Buffer.alloc(0);
let responseHeaders = {};
let statusCode = 200;
let headerParsed = false;
let contentLength = -1;
//Функция для наполнения и обработки буфера ответа
const onSecureData = chunk => {
//Добавляем очередную порцию к данным буфера ответа
responseBuffer = Buffer.concat([responseBuffer, chunk]);
//Если ещё не поднят флаг того, что разобрали заголовок
if (!headerParsed) {
//Найдем в буфере метку конца заголовка
const headerEnd = responseBuffer.indexOf("\r\n\r\n");
//Метка конца заголовка найдена
if (headerEnd !== -1) {
//Фиксируем текст заголовка от начала буфера до отметки конца заголовка
const headerText = responseBuffer.subarray(0, headerEnd).toString();
//Остальное - будет телом
const bodyStart = headerEnd + 4;
responseBuffer = responseBuffer.subarray(bodyStart);
//Разбираем полученный текст заголовка
const lines = headerText.split("\r\n");
const statusLine = lines[0];
const statusMatch = statusLine.match(/HTTP\/[\d.]+\s+(\d+)/);
if (statusMatch) {
statusCode = parseInt(statusMatch[1], 10);
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const colonIndex = line.indexOf(":");
if (colonIndex !== -1) {
const key = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim();
responseHeaders[key] = value;
if (key === "content-length") {
contentLength = parseInt(value, 10);
}
}
}
//И поднимаем флаг его разобранности
headerParsed = true;
//Если есть Content-Length и мы уже получили все данные
if (contentLength >= 0 && responseBuffer.length >= contentLength) {
//Прекращаем прием данных в буфер
secureSocket.removeListener("data", onSecureData);
//Закрываем TCP-соединение
secureSocket.end();
}
}
} else {
//Заголовок уже прочитан, но данные ещё могут приходить - проверяем, получили ли мы все данные
if (contentLength >= 0 && responseBuffer.length >= contentLength) {
//Прекращаем прием данных в буфер
secureSocket.removeListener("data", onSecureData);
//Закрываем TCP-соединение
secureSocket.end();
} else if (contentLength < 0) {
//Нет Content-Length, читаем до конца
//Будем ждать закрытия соединения со стороны хоста
}
}
};
//Подключаем определенную выше функцию обработки данных к событию их получения
secureSocket.on("data", onSecureData);
//Слушаем завершение соединения и реагируем на него
secureSocket.on("end", () => {
//Формируем объект с результатами обработки соединения, совместимый с ожиданиями сервера приложений
const result = {
statusCode: statusCode,
statusMessage: "",
headers: responseHeaders,
body: responseBuffer,
ok: statusCode >= 200 && statusCode < 300,
url: targetUrl.toString()
};
//Если установлена опция "Выдавать ошибку для HTTP-статуса != 200" - будем отклонять промис с ней
if (options.throwOnErrorStatus && !result.ok) {
const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result);
const httpError = new Error(error.message);
httpError.response = result;
httpError.statusCode = result.statusCode;
return reject(httpError);
}
//Разрешаем промис предварительно сформировав ответ, совместимый со старыми библиотекаим "Request"
resolve(processResponse(result, options));
});
}
);
//Слушаем сетевые ошибки на защищенном соединении
secureSocket.on("error", err => reject(err));
} else {
//Ответ на CONNECT-запрос содержит статус, отличный от 200 - отключаемся, не прошли прокси
proxySocket.destroy();
reject(new Error(`Прокси вернул ошибку: ${statusLine}`));
}
}
};
//Подключаем функцию обработки данных CONNECT-запроса и слушаем их
proxySocket.on("data", onConnectData);
});
//Слушаем сетевые ошибки при выполнении CONNECT-запроса
proxySocket.on("error", err => {
clearTimeout(connectTimeout);
reject(err);
});
});
};
//------------
// Тело модуля
//------------
//Выполнение HTTP/HTTPS запроса
const httpRequest = async (rawOptions = {}) => {
try {
//Нормализуем параметры запроса
const options = normalizeOptions(rawOptions);
//Сформируем ссылку
const url = buildURL(options.url, options.query);
//Подготовим заголовки и тело запроса
const headers = prepareHeaders(options.headers);
const { body, contentLength, isStream } = prepareBody({
body: options.body,
jsonRequest: options.jsonRequest,
headers
});
//Если не указан размер тела
if (contentLength !== undefined && !headers.has("content-length")) {
//Установим размер тела в заголовок
headers.set("content-length", String(contentLength));
}
//Если есть поток, читаем его
let requestBody = body;
if (isStream && body && typeof body.pipe === "function") {
const chunks = [];
try {
for await (const chunk of body) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
requestBody = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
} catch (streamError) {
throw new Error(`Ошибка чтения потока: ${streamError.message}`);
}
}
//Если нужен прокси или специальные TLS настройки, используем встроенные модули
if (options.proxy || options.ca || options.cert || options.key || options.passphrase || options.rejectUnauthorized === false) {
return await httpRequestNative(options, url.toString(), headers, requestBody);
}
//Для простых запросов используем встроенный fetch
const fetchImpl = globalThis.fetch;
if (typeof fetchImpl !== "function") {
throw new Error('Среда исполнения не поддерживает "fetch", используйте NodeJS >= 22.21.1.');
}
//Добавим к нему аборт-контроллер для управления отменой запроса по таймауту
const timeoutController = new AbortController();
const signals = [];
//Один - на базе параметра "nTimeoutAsynch", получаемого из "Функции сервиса обмена", атрибут "Таймаут асинхронной отправки" (им управляет таймер, взводимый в обёртке "wrapPromiseTimeout" и передаваемый сюда в качестве сигнала)
if (options.signal && options.signal instanceof AbortSignal) {
signals.push(options.signal);
}
//Второй - на базе параметра "nTimeoutConn", получаемого из "Функции сервиса обмена", атрибут "Таймаут сетевого подключения"
signals.push(timeoutController.signal);
const combinedSignal = AbortSignal.any(signals);
//Взводим таймер для "второго" сигнала (по атрибуту "Таймаут сетевого подключения")
const timeoutId =
options.timeout > 0
? setTimeout(
() => timeoutController.abort(new Error(`Время ожидания выполнения запроса истекло после ${options.timeout} мс`)),
options.timeout
)
: null;
//Выполняем запрос
try {
//Соберем параметры запроса в формате сервиса интеграции в формат, ожидаемый "fetch"
const fetchOptions = {
method: options.method,
headers: Object.fromEntries(headers),
body: requestBody,
signal: combinedSignal,
redirect: options.followRedirects ? "follow" : "manual"
};
//Непосредственно запрос и чтение ответа
const response = await fetchImpl(url.toString(), fetchOptions);
//Читаем тело ответа
const responseBody = Buffer.from(await response.arrayBuffer());
//Собираем ответ в формате, совместимом с сервисом интеграции
const result = {
statusCode: response.status,
statusMessage: response.statusText || "",
headers: Object.fromEntries(response.headers.entries()),
body: responseBody,
ok: response.ok,
url: response.url
};
//Если установлена опция "Выдавать ошибку для HTTP-статуса != 200" - будем отклонять промис с ней
if (options.throwOnErrorStatus && !result.ok) {
const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result);
const httpError = new Error(error.message);
httpError.response = result;
httpError.statusCode = result.statusCode;
throw httpError;
}
//Возвращаем ответ, предварительно обеспечив совместимомть со старыми библиотекаим "Request"
return processResponse(result, options);
} finally {
//Подчистка таймера - успели всё сделать до его истечения
if (timeoutId) clearTimeout(timeoutId);
}
} catch (e) {
//Преобразуем ошибки для совместимости с request-promise
if (e.name === "AbortError" || e.message.includes("время ожидания")) {
const timeoutError = new Error(e.message);
timeoutError.code = "ETIMEDOUT";
timeoutError.error = { code: "ETIMEDOUT" };
throw timeoutError;
}
//Если ошибка уже имеет response (из httpRequestNative или fetch), пробрасываем как есть
if (e.response) {
throw e;
}
//Полученную от нативных методов (httpRequestNative) "HttpError" преобразуем в обычный объект ошибки с дополнительными полями для совместимости
if (e instanceof HttpError) {
const httpError = new Error(e.message);
httpError.response = e.response;
httpError.statusCode = e.response.statusCode;
throw httpError;
}
//Для других ошибок добавляем поле error для совместимости
if (e.code) {
e.error = { code: e.code };
}
//Наконец-то её можно отдать в вызывающее окружение
throw e;
}
};
//-----------------
// Интерфейс модуля
//-----------------
exports.httpRequest = httpRequest;