/* 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, 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 === "json" ? "application/json" : "text/plain"; const requestMethod = options?.method ?? "GET"; const requestBody = options?.data; const requestTimeout = options?.timeout ?? 5 * 1000; const requestParams = options.params ?? {}; const _url = new URL(`${baseUrl}${endpoint}`); Object.entries(requestParams).forEach(([key, value]) => { _url.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 { throw Error("unsupported request body type"); } } const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort("HTTP_REQUEST_TIMEOUT"); }, requestTimeout); let response; try { response = await fetch(_url.href, { headers: requestHeaders, method: requestMethod, credentials: "omit", mode: "cors", body: payload, signal: controller.signal, }); } catch (ex) { const info: RequestInfo = { payload, url: _url.href, hasToken: !!options.token, status: 0, }; const error: HttpRequestTimeoutError = { clientError: true, isNotfound: false, isUnauthorized: false, error: undefined, info, type: ErrorType.TIMEOUT, message: "Request timeout", }; 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, _url.href, payload, !!options.token, ); return result; } else { const error = await buildRequestFailed( response, _url.href, payload, !!options.token, ); 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; } 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 | HttpResponseUnexpectedError; export interface HttpResponseServerError { ok?: false; loading?: false; /** * @deprecated use status */ clientError?: false; /** * @deprecated use status */ serverError: true; type: ErrorType.SERVER; /** * @deprecated use payload */ error: ErrorDetail; payload: ErrorDetail; status: HttpStatusCode; message: string; info?: RequestInfo; } interface HttpRequestTimeoutError { ok?: false; loading?: false; /** * @deprecated use type */ clientError: true; /** * @deprecated use type */ serverError?: false; type: ErrorType.TIMEOUT; info?: RequestInfo; error: undefined; isUnauthorized: false; isNotfound: false; message: string; } interface HttpResponseClientError { ok?: false; loading?: false; /** * @deprecated use type */ clientError: true; /** * @deprecated use type */ serverError?: false; type: ErrorType.CLIENT; info?: RequestInfo; /** * @deprecated use status */ isUnauthorized: boolean; /** * @deprecated use status */ isNotfound: boolean; status: HttpStatusCode; /** * @deprecated use payload */ error: ErrorDetail; payload: ErrorDetail; message: string; } interface HttpResponseUnexpectedError { ok?: false; loading?: false; /** * @deprecated use type */ clientError?: false; /** * @deprecated use type */ serverError?: false; type: ErrorType.UNEXPECTED; info?: RequestInfo; status?: HttpStatusCode; /** * @deprecated use exception */ error: unknown; exception: unknown; 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; }; data?: any; params?: unknown; timeout?: number; contentType?: "text" | "json"; } async function buildRequestOk( response: Response, url: string, payload: any, hasToken: boolean, ): Promise> { const dataTxt = await response.text(); const data = dataTxt ? JSON.parse(dataTxt) : undefined; return { ok: true, data, info: { payload, url, hasToken, status: response.status, }, }; } async function buildRequestFailed( response: Response, url: string, payload: any, hasToken: boolean, ): Promise< | HttpResponseClientError | HttpResponseServerError | HttpResponseUnexpectedError > { const status = response?.status; const info: RequestInfo = { payload, url, hasToken, status: status || 0, }; try { const dataTxt = await response.text(); const data = dataTxt ? JSON.parse(dataTxt) : undefined; if (status && status >= 400 && status < 500) { const error: HttpResponseClientError = { clientError: true, isNotfound: status === 404, isUnauthorized: status === 401, type: ErrorType.CLIENT, status, info, message: data?.hint, error: data, // remove this payload: data, }; return error; } if (status && status >= 500 && status < 600) { const error: HttpResponseServerError = { serverError: true, type: ErrorType.SERVER, status, info, message: `${data?.hint} (code ${data?.code})`, error: data, //remove this payload: data, }; return error; } return { info, type: ErrorType.UNEXPECTED, status, error: {}, // remove this exception: undefined, message: "NOT DEFINED", }; } catch (ex) { const error: HttpResponseUnexpectedError = { info, status, type: ErrorType.UNEXPECTED, error: ex, exception: ex, message: "NOT DEFINED", }; return error; } } // export function isAxiosError( // error: AxiosError | any, // ): error is AxiosError { // return error && error.isAxiosError; // }