/* 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; }); } /** * default header assume everything is json * in case of formData the content-type will be * auto generated */ if (requestBody instanceof FormData) { delete requestHeadersMap["Content-Type"] } else if (requestBody instanceof URLSearchParams) { requestHeadersMap["Content-Type"] = "application/x-www-form-urlencoded" } 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); }); const text = makeTextHandler(response, requestUrl, requestMethod); const json = makeJsonHandler(response, requestUrl, requestMethod, text); return { headers: headerMap, status: response.status, requestMethod, requestUrl, json, text, 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, ) { let firstTime = true; let respText: string; let error: TalerError | undefined; return async function getTextFromResponse(): Promise { if (firstTime) { firstTime = false; try { respText = await response.text(); } catch (e) { error = TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl, requestMethod, httpStatusCode: response.status, validationError: e instanceof Error ? e.message : String(e), }, "Invalid text from HTTP response", ); } } if (error !== undefined) { throw error; } return respText; }; } function makeJsonHandler( response: Response, requestUrl: string, requestMethod: string, readTextHandler: () => Promise, ) { let firstTime = true; let responseJson: string | undefined = undefined; let error: TalerError | undefined; return async function getJsonFromResponse(): Promise { if (firstTime) { let responseText: string; try { responseText = await readTextHandler(); } catch (e) { const message = e instanceof Error ? `Couldn't read HTTP response: ${e.message}` : "Couldn't read HTTP response"; error = TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl, requestMethod, httpStatusCode: response.status, validationError: e instanceof Error ? e.message : String(e), }, message, ); } if (!error) { try { // @ts-expect-error no error then text is initialized responseJson = JSON.parse(responseText); } catch (e) { const message = e instanceof Error ? `Invalid JSON from HTTP response: ${e.message}` : "Invalid JSON from HTTP response"; error = TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl, requestMethod, // @ts-expect-error no error then text is initialized response: responseText, httpStatusCode: response.status, validationError: e instanceof Error ? e.message : String(e), }, message, ); } if (responseJson === null || typeof responseJson !== "object") { error = TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl, requestMethod, response: JSON.stringify(responseJson), httpStatusCode: response.status, }, "Invalid JSON from HTTP response: null or not object", ); } } } if (error !== undefined) { throw error; } return responseJson; }; }