/* This file is part of GNU Taler (C) 2023 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 */ import type { CancellationToken } from "./CancellationToken.js"; import { Codec } from "./codec.js"; import { j2s } from "./helpers.js"; import { TalerError, base64FromArrayBuffer, makeErrorDetail, stringToBytes, } from "./index.js"; import { Logger } from "./logging.js"; import { TalerErrorCode } from "./taler-error-codes.js"; import { AbsoluteTime, Duration } from "./time.js"; import { TalerErrorDetail } from "./wallet-types.js"; const textEncoder = new TextEncoder(); const logger = new Logger("http.ts"); /** * An HTTP response that is returned by all request methods of this library. */ export interface HttpResponse { requestUrl: string; requestMethod: string; status: number; headers: Headers; json(): Promise; text(): Promise; bytes(): Promise; } export const DEFAULT_REQUEST_TIMEOUT_MS = 60000; export interface HttpRequestOptions { method?: "POST" | "PATCH" | "PUT" | "GET" | "DELETE"; headers?: { [name: string]: string | undefined }; /** * Timeout after which the request should be aborted. */ timeout?: Duration; /** * Cancellation token that should abort the request when * cancelled. */ cancellationToken?: CancellationToken; body?: string | ArrayBuffer | object; /** * How to handle redirects. * Same semantics as WHATWG fetch. */ redirect?: "follow" | "error" | "manual"; } /** * Headers, roughly modeled after the fetch API's headers object. */ export class Headers { private headerMap = new Map(); get(name: string): string | null { const r = this.headerMap.get(name.toLowerCase()); if (r) { return r; } return null; } set(name: string, value: string): void { const normalizedName = name.toLowerCase(); const existing = this.headerMap.get(normalizedName); if (existing !== undefined) { this.headerMap.set(normalizedName, existing + "," + value); } else { this.headerMap.set(normalizedName, value); } } toJSON(): any { const m: Record = {}; this.headerMap.forEach((v, k) => (m[k] = v)); return m; } } /** * Interface for the HTTP request library used by the wallet. * * The request library is bundled into an interface to make mocking and * request tunneling easy. */ export interface HttpRequestLibrary { /** * Make an HTTP POST request with a JSON body. */ fetch(url: string, opt?: HttpRequestOptions): Promise; } type TalerErrorResponse = { code: number; } & unknown; type ResponseOrError = | { isError: false; response: T } | { isError: true; talerErrorResponse: TalerErrorResponse }; /** * Read Taler error details from an HTTP response. */ export async function readTalerErrorResponse( httpResponse: HttpResponse, ): Promise { const contentType = httpResponse.headers.get("content-type"); if (contentType !== "application/json") { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, contentType: contentType || "", }, "Error response did not even contain JSON. The request URL might be wrong or the service might be unavailable.", ); } let errJson; try { errJson = await httpResponse.json(); } catch (e: any) { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, validationError: e.toString(), }, "Couldn't parse JSON format from error response", ); } const talerErrorCode = errJson.code; if (typeof talerErrorCode !== "number") { logger.warn( `malformed error response (status ${httpResponse.status}): ${j2s( errJson, )}`, ); throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, }, "Error response did not contain error code", ); } return errJson; } export async function readUnexpectedResponseDetails( httpResponse: HttpResponse, ): Promise { let errJson; try { errJson = await httpResponse.json(); } catch (e: any) { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, validationError: e.toString(), }, "Couldn't parse JSON format from error response", ); } const talerErrorCode = errJson.code; if (typeof talerErrorCode !== "number") { return makeErrorDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, }, "Error response did not contain error code", ); } return makeErrorDetail( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, errorResponse: errJson, }, `Unexpected HTTP status (${httpResponse.status}) in response`, ); } export async function readSuccessResponseJsonOrErrorCode( httpResponse: HttpResponse, codec: Codec, ): Promise> { if (!(httpResponse.status >= 200 && httpResponse.status < 300)) { return { isError: true, talerErrorResponse: await readTalerErrorResponse(httpResponse), }; } let respJson; try { respJson = await httpResponse.json(); } catch (e: any) { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, validationError: e.toString(), }, "Couldn't parse JSON format from response", ); } let parsedResponse: T; try { parsedResponse = codec.decode(respJson); } catch (e: any) { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, validationError: e.toString(), }, "Response invalid", ); } return { isError: false, response: parsedResponse, }; } type HttpErrorDetails = { requestUrl: string; requestMethod: string; httpStatusCode: number; }; export function getHttpResponseErrorDetails( httpResponse: HttpResponse, ): HttpErrorDetails { return { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, }; } export function throwUnexpectedRequestError( httpResponse: HttpResponse, talerErrorResponse: TalerErrorResponse, ): never { throw TalerError.fromDetail( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, errorResponse: talerErrorResponse, }, `Unexpected HTTP status ${httpResponse.status} in response`, ); } export async function readSuccessResponseJsonOrThrow( httpResponse: HttpResponse, codec: Codec, ): Promise { const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codec); if (!r.isError) { return r.response; } throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); } export async function expectSuccessResponseOrThrow( httpResponse: HttpResponse, ): Promise { if (httpResponse.status >= 200 && httpResponse.status <= 299) { return; } const errResp = await readTalerErrorResponse(httpResponse); throwUnexpectedRequestError(httpResponse, errResp); } export async function readSuccessResponseTextOrErrorCode( httpResponse: HttpResponse, ): Promise> { if (!(httpResponse.status >= 200 && httpResponse.status < 300)) { let errJson; try { errJson = await httpResponse.json(); } catch (e: any) { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, validationError: e.toString(), }, "Couldn't parse JSON format from error response", ); } const talerErrorCode = errJson.code; if (typeof talerErrorCode !== "number") { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { httpStatusCode: httpResponse.status, requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, }, "Error response did not contain error code", ); } return { isError: true, talerErrorResponse: errJson, }; } const respJson = await httpResponse.text(); return { isError: false, response: respJson, }; } export async function checkSuccessResponseOrThrow( httpResponse: HttpResponse, ): Promise { if (!(httpResponse.status >= 200 && httpResponse.status < 300)) { let errJson; try { errJson = await httpResponse.json(); } catch (e: any) { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, httpStatusCode: httpResponse.status, validationError: e.toString(), }, "Couldn't parse JSON format from error response", ); } const talerErrorCode = errJson.code; if (typeof talerErrorCode !== "number") { throw TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { httpStatusCode: httpResponse.status, requestUrl: httpResponse.requestUrl, requestMethod: httpResponse.requestMethod, }, "Error response did not contain error code", ); } throwUnexpectedRequestError(httpResponse, errJson); } } export async function readSuccessResponseTextOrThrow( httpResponse: HttpResponse, ): Promise { const r = await readSuccessResponseTextOrErrorCode(httpResponse); if (!r.isError) { return r.response; } throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); } /** * Get the timestamp at which the response's content is considered expired. */ export function getExpiry( httpResponse: HttpResponse, opt: { minDuration?: Duration }, ): AbsoluteTime { const expiryDateMs = new Date( httpResponse.headers.get("expiry") ?? "", ).getTime(); let t: AbsoluteTime; if (Number.isNaN(expiryDateMs)) { t = AbsoluteTime.now(); } else { t = AbsoluteTime.fromMilliseconds(expiryDateMs); } if (opt.minDuration) { const t2 = AbsoluteTime.addDuration(AbsoluteTime.now(), opt.minDuration); return AbsoluteTime.max(t, t2); } return t; } export interface HttpLibArgs { enableThrottling?: boolean; /** * Only allow HTTPS connections, not plain http. */ requireTls?: boolean; } export function encodeBody(body: any): ArrayBuffer { if (body == null) { return new ArrayBuffer(0); } if (typeof body === "string") { return textEncoder.encode(body).buffer; } else if (ArrayBuffer.isView(body)) { return body.buffer; } else if (body instanceof ArrayBuffer) { return body; } else if (typeof body === "object") { return textEncoder.encode(JSON.stringify(body)).buffer; } throw new TypeError("unsupported request body type"); } export function getDefaultHeaders(method: string): Record { const headers: Record = {}; if (method === "POST" || method === "PUT" || method === "PATCH") { // Default to JSON if we have a body headers["Content-Type"] = "application/json"; } headers["Accept"] = "application/json"; return headers; } /** * Helper function to generate the "Authorization" HTTP header. */ export function makeBasicAuthHeader( username: string, password: string, ): string { const auth = `${username}:${password}`; const authEncoded: string = base64FromArrayBuffer(stringToBytes(auth)); return `Basic ${authEncoded}`; }