/* 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 { Logger, RequestThrottler, TalerErrorCode, TalerError, Duration, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, HttpRequestOptions, HttpResponse, Headers, getDefaultHeaders, encodeBody, DEFAULT_REQUEST_TIMEOUT_MS, } from "@gnu-taler/taler-util/http"; const logger = new Logger("browserHttpLib"); /** * An implementation of the [[HttpRequestLibrary]] using the * browser's XMLHttpRequest. */ export class BrowserHttpLib implements HttpRequestLibrary { private throttle = new RequestThrottler(); private throttlingEnabled = true; 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); if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) { const parsedUrl = new URL(requestUrl); throw TalerError.fromDetail( TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, { requestMethod, requestUrl, throttleStats: this.throttle.getThrottleStats(requestUrl), }, `request to origin ${parsedUrl.origin} was throttled`, ); } let myBody: ArrayBuffer | undefined = requestMethod === "POST" || requestMethod === "PUT" || requestMethod === "PATCH" ? encodeBody(requestBody) : undefined; const requestHeadersMap = { ...getDefaultHeaders(requestMethod), ...requestHeader, }; return new Promise((resolve, reject) => { const myRequest = new XMLHttpRequest(); myRequest.onerror = (e) => { logger.error("http request error"); reject( TalerError.fromDetail( TalerErrorCode.WALLET_NETWORK_ERROR, { requestUrl, requestMethod, }, "Could not make request", ), ); }; myRequest.open(requestMethod, requestUrl); let timeoutId: any | undefined; if (requestTimeout.d_ms !== "forever") { timeoutId = setTimeout(() => { myRequest.abort(); reject( TalerError.fromDetail( TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT, { requestUrl, requestMethod, timeoutMs: requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms }, `request to ${requestUrl} timed out`, ), ); }, requestTimeout.d_ms); } Object.keys(requestHeadersMap).forEach((headerName) => { myRequest.setRequestHeader(headerName, requestHeadersMap[headerName]); }); myRequest.responseType = "arraybuffer"; myRequest.send(myBody); myRequest.addEventListener("readystatechange", (e) => { if (myRequest.readyState === XMLHttpRequest.DONE) { if (myRequest.status === 0) { const exc = TalerError.fromDetail( TalerErrorCode.WALLET_NETWORK_ERROR, { requestUrl, requestMethod, }, "HTTP request failed (status 0, maybe URI scheme was wrong?)", ); reject(exc); return; } const makeText = async (): Promise => { const td = new TextDecoder(); return td.decode(myRequest.response); }; let responseJson: unknown = undefined; const makeJson = async (): Promise => { if (responseJson === undefined) { try { const td = new TextDecoder(); const responseString = td.decode(myRequest.response); responseJson = JSON.parse(responseString); } catch (e) { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl, requestMethod, httpStatusCode: myRequest.status, }, "Invalid JSON from HTTP response", ); } } if (responseJson === null || typeof responseJson !== "object") { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl, requestMethod, httpStatusCode: myRequest.status, }, "Invalid JSON from HTTP response", ); } return responseJson; }; const headers = myRequest.getAllResponseHeaders(); const arr = headers.trim().split(/[\r\n]+/); // Create a map of header names to values const headerMap: Headers = new Headers(); arr.forEach(function (line) { const parts = line.split(": "); const headerName = parts.shift(); if (!headerName) { logger.warn("skipping invalid header"); return; } const value = parts.join(": "); headerMap.set(headerName, value); }); const resp: HttpResponse = { requestUrl: requestUrl, status: myRequest.status, headers: headerMap, requestMethod: requestMethod, json: makeJson, text: makeText, bytes: async () => myRequest.response, }; resolve(resp); } }); }); } get(url: string, opt?: HttpRequestOptions): Promise { return this.fetch(url, { method: "GET", ...opt, }); } postJson( url: string, body: any, opt?: HttpRequestOptions, ): Promise { return this.fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), ...opt, }); } stop(): void { // Nothing to do } }