/* This file is part of GNU Taler (C) 2021-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 */ import { HttpStatusCode } from "@gnu-taler/taler-util"; import { base64encode } from "./base64.js"; export enum ErrorType { CLIENT, SERVER, UNREADABLE, TIMEOUT, UNEXPECTED, } /** * * @param baseUrl URL where the service is located * @param endpoint endpoint of the service to be called * @param options auth, method and params * @returns */ export async function defaultRequestHandler( baseUrl: string, endpoint: string, options: RequestOptions = {}, ): Promise> { const requestHeaders: Record = {}; if (options.token) { requestHeaders.Authorization = `Bearer ${options.token}`; } else if (options.basicAuth) { requestHeaders.Authorization = `Basic ${base64encode( `${options.basicAuth.username}:${options.basicAuth.password}`, )}`; } requestHeaders["Content-Type"] = !options.contentType || options.contentType === "json" ? "application/json" : "text/plain"; if (options.talerAmlOfficerSignature) { requestHeaders["Taler-AML-Officer-Signature"] = options.talerAmlOfficerSignature; } const requestMethod = options?.method ?? "GET"; const requestBody = options?.data; const requestTimeout = options?.timeout ?? 5 * 1000; const requestParams = options.params ?? {}; const requestPreventCache = options.preventCache ?? false; const requestPreventCors = options.preventCors ?? false; const validURL = validateURL(baseUrl, endpoint); if (!validURL) { const error: HttpResponseUnexpectedError = { info: { url: `${baseUrl}${endpoint}`, payload: {}, hasToken: !!options.token, status: 0, options, }, type: ErrorType.UNEXPECTED, exception: undefined, loading: false, message: `invalid URL: "${baseUrl}${endpoint}"`, }; throw new RequestError(error) } Object.entries(requestParams).forEach(([key, value]) => { validURL.searchParams.set(key, String(value)); }); let payload: BodyInit | undefined = undefined; if (requestBody != null) { if (typeof requestBody === "string") { payload = requestBody; } else if (requestBody instanceof ArrayBuffer) { payload = requestBody; } else if (ArrayBuffer.isView(requestBody)) { payload = requestBody; } else if (typeof requestBody === "object") { payload = JSON.stringify(requestBody); } else { const error: HttpResponseUnexpectedError = { info: { url: validURL.href, payload: {}, hasToken: !!options.token, status: 0, options, }, type: ErrorType.UNEXPECTED, exception: undefined, loading: false, message: `unsupported request body type: "${typeof requestBody}"`, }; throw new RequestError(error) } } const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort("HTTP_REQUEST_TIMEOUT"); }, requestTimeout); let response; try { response = await fetch(validURL.href, { headers: requestHeaders, method: requestMethod, credentials: "omit", mode: requestPreventCors ? "no-cors" : "cors", cache: requestPreventCache ? "no-cache" : "default", body: payload, signal: controller.signal, }); } catch (ex) { const info: RequestInfo = { payload, url: validURL.href, hasToken: !!options.token, status: 0, options, }; if (ex instanceof Error) { if (ex.message === "HTTP_REQUEST_TIMEOUT") { const error: HttpRequestTimeoutError = { info, type: ErrorType.TIMEOUT, message: "request timeout", }; throw new RequestError(error); } } const error: HttpResponseUnexpectedError = { info, type: ErrorType.UNEXPECTED, exception: ex, loading: false, message: (ex instanceof Error ? ex.message : ""), }; throw new RequestError(error); } if (timeoutId) { clearTimeout(timeoutId); } const headerMap = new Headers(); response.headers.forEach((value, key) => { headerMap.set(key, value); }); if (response.ok) { const result = await buildRequestOk( response, validURL.href, payload, !!options.token, options, ); return result; } else { const dataTxt = await response.text(); const error = buildRequestFailed( validURL.href, dataTxt, response.status, payload, options, ); throw new RequestError(error); } } export type HttpResponse = | HttpResponseOk | HttpResponseLoading | HttpError; export type HttpResponsePaginated = | HttpResponseOkPaginated | HttpResponseLoading | HttpError; export interface RequestInfo { url: string; hasToken: boolean; payload: any; status: number; options: RequestOptions; } interface HttpResponseLoading { ok?: false; loading: true; clientError?: false; serverError?: false; data?: T; } export interface HttpResponseOk { ok: true; loading?: false; clientError?: false; serverError?: false; data: T; info?: RequestInfo; } export type HttpResponseOkPaginated = HttpResponseOk & WithPagination; export interface WithPagination { loadMore: () => void; loadMorePrev: () => void; isReachingEnd?: boolean; isReachingStart?: boolean; } export type HttpError = | HttpRequestTimeoutError | HttpResponseClientError | HttpResponseServerError | HttpResponseUnreadableError | HttpResponseUnexpectedError; export interface HttpResponseServerError { ok?: false; loading?: false; type: ErrorType.SERVER; payload: ErrorDetail; status: HttpStatusCode; message: string; info: RequestInfo; } interface HttpRequestTimeoutError { ok?: false; loading?: false; type: ErrorType.TIMEOUT; info: RequestInfo; message: string; } interface HttpResponseClientError { ok?: false; loading?: false; type: ErrorType.CLIENT; info: RequestInfo; status: HttpStatusCode; payload: ErrorDetail; message: string; } interface HttpResponseUnexpectedError { ok?: false; loading: false; type: ErrorType.UNEXPECTED; info: RequestInfo; status?: HttpStatusCode; exception: unknown; message: string; } interface HttpResponseUnreadableError { ok?: false; loading: false; type: ErrorType.UNREADABLE; info: RequestInfo; status: HttpStatusCode; exception: unknown; body: string; message: string; } export class RequestError extends Error { /** * @deprecated use cause */ info: HttpError; cause: HttpError; constructor(d: HttpError) { super(d.message); this.info = d; this.cause = d; } } type Methods = "GET" | "POST" | "PATCH" | "DELETE" | "PUT"; export interface RequestOptions { method?: Methods; token?: string; basicAuth?: { username: string; password: string; }; preventCache?: boolean; preventCors?: boolean; data?: any; params?: unknown; timeout?: number; contentType?: "text" | "json"; talerAmlOfficerSignature?: string; } async function buildRequestOk( response: Response, url: string, payload: any, hasToken: boolean, options: RequestOptions, ): Promise> { const dataTxt = await response.text(); const data = dataTxt ? JSON.parse(dataTxt) : undefined; return { ok: true, data, info: { payload, url, hasToken, options, status: response.status, }, }; } export function buildRequestFailed( url: string, dataTxt: string, status: number, payload: any, maybeOptions?: RequestOptions, ): | HttpResponseClientError | HttpResponseServerError | HttpResponseUnreadableError | HttpResponseUnexpectedError { const options = maybeOptions ?? {}; const info: RequestInfo = { payload, url, hasToken: !!options.token, options, status: status || 0, }; // const dataTxt = await response.text(); try { const data = dataTxt ? JSON.parse(dataTxt) : undefined; const errorCode = !data || !data.code ? "" : `(code: ${data.code})`; const errorHint = !data || !data.hint ? "Not hint." : `${data.hint} ${errorCode}`; if (status && status >= 400 && status < 500) { const message = data === undefined ? `Client error (${status}) without data.` : errorHint; const error: HttpResponseClientError = { type: ErrorType.CLIENT, status, info, message, payload: data, }; return error; } if (status && status >= 500 && status < 600) { const message = data === undefined ? `Server error (${status}) without data.` : errorHint; const error: HttpResponseServerError = { type: ErrorType.SERVER, status, info, message, payload: data, }; return error; } return { info, loading: false, type: ErrorType.UNEXPECTED, status, exception: undefined, message: `http status code not handled: ${status}`, }; } catch (ex) { const error: HttpResponseUnreadableError = { info, loading: false, status, type: ErrorType.UNREADABLE, exception: ex, body: dataTxt, message: "Could not parse body as json", }; return error; } } function validateURL(baseUrl: string, endpoint: string): URL | undefined { try { return new URL(`${baseUrl}${endpoint}`) } catch (ex) { return undefined } }