/*
This file is part of GNU Taler
(C) 2019 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see
SPDX-License-Identifier: AGPL3.0-or-later
*/
/**
* Imports.
*/
import { j2s, Logger, openPromise } from "@gnu-taler/taler-util";
import { TalerError } from "./errors.js";
import { encodeBody, getDefaultHeaders, HttpLibArgs } from "./http-common.js";
import {
Headers,
HttpRequestLibrary,
HttpRequestOptions,
HttpResponse,
} from "./http.js";
import { RequestThrottler, TalerErrorCode, URL } from "./index.js";
import { QjsHttpResp, qjsOs } from "./qtart.js";
const logger = new Logger("http-impl.qtart.ts");
const textDecoder = new TextDecoder();
export class RequestTimeoutError extends Error {
public constructor() {
super("Request timed out");
Object.setPrototypeOf(this, RequestTimeoutError.prototype);
}
}
export class RequestCancelledError extends Error {
public constructor() {
super("Request cancelled");
Object.setPrototypeOf(this, RequestCancelledError.prototype);
}
}
/**
* Implementation of the HTTP request library interface for node.
*/
export class HttpLibImpl implements HttpRequestLibrary {
private throttle = new RequestThrottler();
private throttlingEnabled = true;
private requireTls = false;
constructor(args?: HttpLibArgs) {
this.throttlingEnabled = args?.enableThrottling ?? true;
this.requireTls = args?.requireTls ?? false;
}
/**
* Set whether requests should be throttled.
*/
setThrottling(enabled: boolean): void {
this.throttlingEnabled = enabled;
}
async fetch(url: string, opt?: HttpRequestOptions): Promise {
const method = (opt?.method ?? "GET").toUpperCase();
logger.trace(`Requesting ${method} ${url}`);
const parsedUrl = new URL(url);
if (this.throttlingEnabled && this.throttle.applyThrottle(url)) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
{
requestMethod: method,
requestUrl: url,
throttleStats: this.throttle.getThrottleStats(url),
},
`request to origin ${parsedUrl.origin} was throttled`,
);
}
if (this.requireTls && parsedUrl.protocol !== "https:") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_NETWORK_ERROR,
{
requestMethod: method,
requestUrl: url,
},
`request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`,
);
}
let data: ArrayBuffer | undefined = undefined;
const requestHeadersMap = getDefaultHeaders(method);
if (opt?.headers) {
Object.entries(opt?.headers).forEach(([key, value]) => {
if (value === undefined) return;
requestHeadersMap[key] = value;
});
}
let headersList: string[] = [];
for (let headerName of Object.keys(requestHeadersMap)) {
headersList.push(`${headerName}: ${requestHeadersMap[headerName]}`);
}
if (method === "POST") {
data = encodeBody(opt?.body);
}
const cancelPromCap = openPromise();
logger.trace(`calling qtart fetchHttp`);
// Just like WHATWG fetch(), the qjs http client doesn't
// really support cancellation, so cancellation here just
// means that the result is ignored!
const { promise: fetchProm, cancelFn } = qjsOs.fetchHttp(url, {
method,
data,
headers: headersList,
});
let timeoutHandle: any = undefined;
let cancelCancelledHandler: (() => void) | undefined = undefined;
if (opt?.timeout && opt.timeout.d_ms !== "forever") {
timeoutHandle = setTimeout(() => {
cancelPromCap.reject(new RequestTimeoutError());
}, opt.timeout.d_ms);
}
if (opt?.cancellationToken) {
cancelCancelledHandler = opt.cancellationToken.onCancelled(() => {
logger.trace(`cancelling quickjs request`);
cancelFn();
cancelPromCap.reject(new RequestCancelledError());
});
}
let res: QjsHttpResp;
try {
res = await Promise.race([fetchProm, cancelPromCap.promise]);
} catch (e) {
logger.trace(`got exception while waiting for qtart http response`);
if (e instanceof RequestCancelledError) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
{
requestUrl: url,
requestMethod: method,
httpStatusCode: 0,
},
`Request cancelled`,
);
}
if (e instanceof RequestTimeoutError) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
{
requestUrl: url,
requestMethod: method,
httpStatusCode: 0,
},
`Request timed out (timeout ${opt?.timeout?.d_ms ?? "none"})`,
);
}
throw e;
}
if (logger.shouldLogTrace()) {
logger.trace(`got qtart http response, status ${res.status}`);
logger.trace(`response headers: ${j2s(res.headers)}`);
}
if (timeoutHandle != null) {
clearTimeout(timeoutHandle);
}
if (cancelCancelledHandler != null) {
cancelCancelledHandler();
}
const headers: Headers = new Headers();
if (res.headers) {
for (const headerStr of res.headers) {
const splitPos = headerStr.indexOf(":");
if (splitPos < 0) {
continue;
}
const headerName = headerStr.slice(0, splitPos).trim().toLowerCase();
let headerValue = headerStr.slice(splitPos + 1).trim();
// FIXME: This is a hotfix for the broken native networking implementation on Android
// that sends the content type header value in square brackets
if (
headerName === "content-type" &&
headerValue.startsWith("[") &&
headerValue.endsWith("]")
) {
headerValue = headerValue.substring(1, headerValue.length - 2);
}
headers.set(headerName, headerValue);
}
}
return {
requestMethod: method,
headers,
async bytes() {
return res.data;
},
json() {
const text = textDecoder.decode(res.data);
return JSON.parse(text);
},
async text() {
const text = textDecoder.decode(res.data);
return text;
},
requestUrl: url,
status: res.status,
};
}
}