/* This file is part of GNU Taler (C) 2022 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 */ /** * Imports. */ import { Duration, RequestThrottler, TalerError, TalerErrorCode } from "@gnu-taler/taler-util"; import { DEFAULT_REQUEST_TIMEOUT_MS, Headers, HttpLibArgs, HttpRequestLibrary, HttpRequestOptions, HttpResponse, encodeBody, getDefaultHeaders, } from "@gnu-taler/taler-util/http"; /** * An implementation of the [[HttpRequestLibrary]] using the * browser's XMLHttpRequest. */ export class BrowserFetchHttpLib implements HttpRequestLibrary { private throttle = new RequestThrottler(); private throttlingEnabled = true; private requireTls = false; public constructor(args?: HttpLibArgs) { this.throttlingEnabled = args?.enableThrottling ?? true; this.requireTls = args?.requireTls ?? false; } async fetch( requestUrl: string, options?: HttpRequestOptions, ): Promise { const requestMethod = options?.method ?? "GET"; const requestBody = options?.body; const requestHeader = options?.headers; const requestTimeout = options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS); const requestCancel = options?.cancellationToken; const requestRedirect = options?.redirect; const parsedUrl = new URL(requestUrl); if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) { throw TalerError.fromDetail( TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, { requestMethod, requestUrl, throttleStats: this.throttle.getThrottleStats(requestUrl), }, `request to origin ${parsedUrl.origin} was throttled`, ); } if (this.requireTls && parsedUrl.protocol !== "https:") { throw TalerError.fromDetail( TalerErrorCode.WALLET_NETWORK_ERROR, { requestMethod: requestMethod, requestUrl: requestUrl, }, `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`, ); } const myBody: ArrayBuffer | undefined = requestMethod === "POST" || requestMethod === "PUT" || requestMethod === "PATCH" ? encodeBody(requestBody) : undefined; const requestHeadersMap = getDefaultHeaders(requestMethod); if (requestHeader) { Object.entries(requestHeader).forEach(([key, value]) => { if (value === undefined) return; requestHeadersMap[key] = value }) } const controller = new AbortController(); let timeoutId: ReturnType | undefined; if (requestTimeout.d_ms !== "forever") { timeoutId = setTimeout(() => { controller.abort(TalerErrorCode.GENERIC_TIMEOUT); }, requestTimeout.d_ms); } if (requestCancel) { requestCancel.onCancelled(() => { controller.abort(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR) }); } try { const response = await fetch(requestUrl, { headers: requestHeadersMap, body: myBody, method: requestMethod, signal: controller.signal, redirect: requestRedirect }); if (timeoutId) { clearTimeout(timeoutId); } const headerMap = new Headers(); response.headers.forEach((value, key) => { headerMap.set(key, value); }); return { headers: headerMap, status: response.status, requestMethod, requestUrl, json: makeJsonHandler(response, requestUrl, requestMethod), text: makeTextHandler(response, requestUrl, requestMethod), bytes: async () => (await response.blob()).arrayBuffer(), }; } catch (e) { if (controller.signal) { throw TalerError.fromDetail( controller.signal.reason, { requestUrl, requestMethod, timeoutMs: requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms }, `HTTP request failed.`, ); } throw e; } } } function makeTextHandler( response: Response, requestUrl: string, requestMethod: string, ) { return async function getTextFromResponse(): Promise { let respText; try { respText = await response.text(); } catch (e) { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl, requestMethod, httpStatusCode: response.status, }, "Invalid text from HTTP response", ); } return respText; }; } function makeJsonHandler( response: Response, requestUrl: string, requestMethod: string, ) { let responseJson: unknown = undefined; return async function getJsonFromResponse(): Promise { if (responseJson === undefined) { try { responseJson = await response.json(); } catch (e) { const message = e instanceof Error ? `Invalid JSON from HTTP response: ${e.message}` : "Invalid JSON from HTTP response" throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl, requestMethod, httpStatusCode: response.status, }, message, ); } } if (responseJson === null || typeof responseJson !== "object") { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl, requestMethod, httpStatusCode: response.status, }, "Invalid JSON from HTTP response: null or not object", ); } return responseJson; }; }