434 lines
10 KiB
TypeScript
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
|
|
}
|
|
|
|
} |