237 lines
9.2 KiB
JavaScript
237 lines
9.2 KiB
JavaScript
/*
|
||
Сервис интеграции ПП Парус 8 с WEB API
|
||
Модуль ядра: работа с HTTP/HTTPS запросами
|
||
*/
|
||
|
||
//------------------------------
|
||
// Подключение внешних библиотек
|
||
//------------------------------
|
||
|
||
const { URL } = require("url");
|
||
|
||
//--------------------------
|
||
// Локальные идентификаторы
|
||
//--------------------------
|
||
|
||
const DEFAULT_TIMEOUT = 30000; //Таймаут по умолчанию
|
||
|
||
//Выполнение 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,
|
||
json: options.json,
|
||
headers
|
||
});
|
||
//Если не указан размер тела
|
||
if (contentLength !== undefined && !headers.has("content-length")) {
|
||
//Установим размер тела в заголовок
|
||
headers.set("content-length", String(contentLength));
|
||
}
|
||
//Выполним запрос с использованием fetch
|
||
return httpRequestFetch({ options, url, headers, body, isStream });
|
||
} catch (e) {
|
||
throw e;
|
||
}
|
||
};
|
||
|
||
//Ошибка HTTP-запроса
|
||
class HttpError extends Error {
|
||
constructor(message, response) {
|
||
super(message);
|
||
this.name = "HttpError";
|
||
this.response = response;
|
||
}
|
||
}
|
||
|
||
//Выполнение запроса с использованием fetch
|
||
const httpRequestFetch = async ({ options, url, headers, body, isStream }) => {
|
||
try {
|
||
const fetchImpl = globalThis.fetch;
|
||
if (typeof fetchImpl !== "function") {
|
||
throw new Error("globalThis.fetch недоступен. Используйте Node.js >= 18.");
|
||
}
|
||
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
const timeoutController = new AbortController();
|
||
const signals = [];
|
||
|
||
if (options.signal && options.signal instanceof AbortSignal) {
|
||
signals.push(options.signal);
|
||
}
|
||
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 {
|
||
const fetchOptions = {
|
||
method: options.method,
|
||
headers: Object.fromEntries(headers),
|
||
body: requestBody,
|
||
signal: combinedSignal,
|
||
redirect: options.followRedirects ? "follow" : "manual"
|
||
};
|
||
|
||
if (options.proxy || options.ca || options.cert || options.key || options.passphrase || options.rejectUnauthorized === false) {
|
||
const { Agent, ProxyAgent } = require("undici");
|
||
const connectOptions = {
|
||
rejectUnauthorized: options.rejectUnauthorized !== undefined ? options.rejectUnauthorized : true
|
||
};
|
||
|
||
if (options.ca) {
|
||
connectOptions.ca = Array.isArray(options.ca) ? options.ca : [options.ca];
|
||
}
|
||
if (options.cert) {
|
||
connectOptions.cert = options.cert;
|
||
}
|
||
if (options.key) {
|
||
connectOptions.key = options.key;
|
||
}
|
||
if (options.passphrase) {
|
||
connectOptions.passphrase = options.passphrase;
|
||
}
|
||
|
||
if (options.proxy) {
|
||
const proxyUrl = new URL(options.proxy);
|
||
if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") {
|
||
throw new Error("Поддерживаются только HTTP/HTTPS-прокси (protocol=http|https).");
|
||
}
|
||
const proxyAuth =
|
||
proxyUrl.username || proxyUrl.password
|
||
? `Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString(
|
||
"base64"
|
||
)}`
|
||
: null;
|
||
fetchOptions.dispatcher = new ProxyAgent({
|
||
uri: options.proxy,
|
||
token: proxyAuth,
|
||
connect: connectOptions
|
||
});
|
||
} else {
|
||
fetchOptions.dispatcher = new Agent({
|
||
connect: connectOptions
|
||
});
|
||
}
|
||
}
|
||
|
||
const response = await fetchImpl(url.toString(), fetchOptions);
|
||
const responseBody = Buffer.from(await response.arrayBuffer());
|
||
const result = {
|
||
statusCode: response.status,
|
||
headers: Object.fromEntries(response.headers.entries()),
|
||
body: responseBody,
|
||
ok: response.ok,
|
||
url: response.url
|
||
};
|
||
|
||
if (options.throwOnErrorStatus && !result.ok) {
|
||
throw new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result);
|
||
}
|
||
|
||
return result;
|
||
} finally {
|
||
if (timeoutId) clearTimeout(timeoutId);
|
||
}
|
||
} catch (e) {
|
||
throw e;
|
||
}
|
||
};
|
||
|
||
//Нормализация параметров запроса
|
||
const normalizeOptions = options => {
|
||
if (!options || typeof options !== "object") {
|
||
throw new TypeError("options должен быть объектом");
|
||
}
|
||
if (!options.url) throw new Error("options.url обязателен");
|
||
return {
|
||
method: (options.method || "GET").toUpperCase(),
|
||
url: options.url,
|
||
headers: options.headers || {},
|
||
query: options.query || options.qs || {},
|
||
body: options.body,
|
||
json: options.json,
|
||
timeout: options.timeout ?? DEFAULT_TIMEOUT,
|
||
followRedirects: options.followRedirects ?? false,
|
||
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 ?? false,
|
||
signal: options.signal || 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, json, headers }) => {
|
||
if (json !== undefined) {
|
||
const payload = Buffer.from(JSON.stringify(json));
|
||
if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8");
|
||
return { body: payload, contentLength: payload.length, isStream: false };
|
||
}
|
||
if (body === undefined || body === null) return { body: undefined, contentLength: undefined, 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 };
|
||
}
|
||
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 };
|
||
};
|
||
|
||
exports.httpRequest = httpRequest;
|
||
exports.HttpError = HttpError;
|