wallet-core/packages/web-util/src/utils/request.ts

434 lines
10 KiB
TypeScript

/*
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 <http://www.gnu.org/licenses/>
*/
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<T>(
baseUrl: string,
endpoint: string,
options: RequestOptions = {},
): Promise<HttpResponseOk<T>> {
const requestHeaders: Record<string, string> = {};
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<T>(
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<T, ErrorDetail> =
| HttpResponseOk<T>
| HttpResponseLoading<T>
| HttpError<ErrorDetail>;
export type HttpResponsePaginated<T, ErrorDetail> =
| HttpResponseOkPaginated<T>
| HttpResponseLoading<T>
| HttpError<ErrorDetail>;
export interface RequestInfo {
url: string;
hasToken: boolean;
payload: any;
status: number;
options: RequestOptions;
}
interface HttpResponseLoading<T> {
ok?: false;
loading: true;
clientError?: false;
serverError?: false;
data?: T;
}
export interface HttpResponseOk<T> {
ok: true;
loading?: false;
clientError?: false;
serverError?: false;
data: T;
info?: RequestInfo;
}
export type HttpResponseOkPaginated<T> = HttpResponseOk<T> & WithPagination;
export interface WithPagination {
loadMore: () => void;
loadMorePrev: () => void;
isReachingEnd?: boolean;
isReachingStart?: boolean;
}
export type HttpError<ErrorDetail> =
| HttpRequestTimeoutError
| HttpResponseClientError<ErrorDetail>
| HttpResponseServerError<ErrorDetail>
| HttpResponseUnreadableError
| HttpResponseUnexpectedError;
export interface HttpResponseServerError<ErrorDetail> {
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<ErrorDetail> {
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<ErrorDetail> extends Error {
/**
* @deprecated use cause
*/
info: HttpError<ErrorDetail>;
cause: HttpError<ErrorDetail>;
constructor(d: HttpError<ErrorDetail>) {
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<T>(
response: Response,
url: string,
payload: any,
hasToken: boolean,
options: RequestOptions,
): Promise<HttpResponseOk<T>> {
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<ErrorDetail>(
url: string,
dataTxt: string,
status: number,
payload: any,
maybeOptions?: RequestOptions,
):
| HttpResponseClientError<ErrorDetail>
| HttpResponseServerError<ErrorDetail>
| 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<ErrorDetail> = {
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<ErrorDetail> = {
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
}
}