From be01d1479cf650fe8eb0c8e567620abfa4544e1e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 8 Feb 2023 17:36:26 -0300 Subject: [PATCH] move request api to web-util --- packages/web-util/package.json | 2 +- packages/web-util/src/context/api.ts | 43 ++++ packages/web-util/src/context/index.ts | 4 +- packages/web-util/src/index.browser.ts | 1 + packages/web-util/src/utils/base64.ts | 243 +++++++++++++++++++ packages/web-util/src/utils/request.ts | 319 +++++++++++++++++++++++++ pnpm-lock.yaml | 22 +- 7 files changed, 628 insertions(+), 6 deletions(-) create mode 100644 packages/web-util/src/context/api.ts create mode 100644 packages/web-util/src/utils/base64.ts create mode 100644 packages/web-util/src/utils/request.ts diff --git a/packages/web-util/package.json b/packages/web-util/package.json index ad44ed67f..1d3dcfca6 100644 --- a/packages/web-util/package.json +++ b/packages/web-util/package.json @@ -37,7 +37,7 @@ "preact-render-to-string": "^5.2.6", "prettier": "^2.5.1", "rimraf": "^3.0.2", - "swr": "1.3.0", + "swr": "2.0.3", "tslib": "^2.4.0", "typescript": "^4.9.4", "ws": "7.4.5" diff --git a/packages/web-util/src/context/api.ts b/packages/web-util/src/context/api.ts new file mode 100644 index 000000000..81586bd35 --- /dev/null +++ b/packages/web-util/src/context/api.ts @@ -0,0 +1,43 @@ +/* + 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 + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext } from "preact/hooks"; +import { defaultRequestHandler } from "../utils/request.js"; + +interface Type { + request: typeof defaultRequestHandler; +} + +const Context = createContext({ + request: defaultRequestHandler, +}); + +export const useApiContext = (): Type => useContext(Context); +export const ApiContextProvider = ({ + children, + value, +}: { + value: Type; + children: ComponentChildren; +}): VNode => { + return h(Context.Provider, { value, children }); +}; diff --git a/packages/web-util/src/context/index.ts b/packages/web-util/src/context/index.ts index 4bc1b22f2..9ed3ef645 100644 --- a/packages/web-util/src/context/index.ts +++ b/packages/web-util/src/context/index.ts @@ -1,5 +1,7 @@ +export { ApiContextProvider, useApiContext } from "./api.js"; export { InternationalizationAPI, TranslationProvider, - useTranslationContext, + useTranslationContext } from "./translation.js"; + diff --git a/packages/web-util/src/index.browser.ts b/packages/web-util/src/index.browser.ts index d3aeae168..2ae3f2a0b 100644 --- a/packages/web-util/src/index.browser.ts +++ b/packages/web-util/src/index.browser.ts @@ -1,4 +1,5 @@ export * from "./hooks/index.js"; +export * from "./utils/request.js"; export * from "./context/index.js"; export * from "./components/index.js"; export * as tests from "./tests/index.js"; diff --git a/packages/web-util/src/utils/base64.ts b/packages/web-util/src/utils/base64.ts new file mode 100644 index 000000000..0e075880f --- /dev/null +++ b/packages/web-util/src/utils/base64.ts @@ -0,0 +1,243 @@ +/* + 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 + */ + + +export function base64encode(str: string): string { + return base64EncArr(strToUTF8Arr(str)) +} + +export function base64decode(str: string): string { + return UTF8ArrToStr(base64DecToArr(str)) +} + +// from https://developer.mozilla.org/en-US/docs/Glossary/Base64 + +// Array of bytes to Base64 string decoding +function b64ToUint6(nChr: number): number { + return nChr > 64 && nChr < 91 + ? nChr - 65 + : nChr > 96 && nChr < 123 + ? nChr - 71 + : nChr > 47 && nChr < 58 + ? nChr + 4 + : nChr === 43 + ? 62 + : nChr === 47 + ? 63 + : 0; +} + +function base64DecToArr(sBase64: string, nBlocksSize?: number): Uint8Array { + const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, ""); // Only necessary if the base64 includes whitespace such as line breaks. + const nInLen = sB64Enc.length; + const nOutLen = nBlocksSize + ? Math.ceil(((nInLen * 3 + 1) >> 2) / nBlocksSize) * nBlocksSize + : (nInLen * 3 + 1) >> 2; + const taBytes = new Uint8Array(nOutLen); + + let nMod3; + let nMod4; + let nUint24 = 0; + let nOutIdx = 0; + for (let nInIdx = 0; nInIdx < nInLen; nInIdx++) { + nMod4 = nInIdx & 3; + nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (6 * (3 - nMod4)); + if (nMod4 === 3 || nInLen - nInIdx === 1) { + nMod3 = 0; + while (nMod3 < 3 && nOutIdx < nOutLen) { + taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255; + nMod3++; + nOutIdx++; + } + nUint24 = 0; + } + } + + return taBytes; +} + +/* Base64 string to array encoding */ +function uint6ToB64(nUint6: number): number { + return nUint6 < 26 + ? nUint6 + 65 + : nUint6 < 52 + ? nUint6 + 71 + : nUint6 < 62 + ? nUint6 - 4 + : nUint6 === 62 + ? 43 + : nUint6 === 63 + ? 47 + : 65; +} + +function base64EncArr(aBytes: Uint8Array): string { + let nMod3 = 2; + let sB64Enc = ""; + + const nLen = aBytes.length; + let nUint24 = 0; + for (let nIdx = 0; nIdx < nLen; nIdx++) { + nMod3 = nIdx % 3; + // To break your base64 into several 80-character lines, add: + // if (nIdx > 0 && ((nIdx * 4) / 3) % 76 === 0) { + // sB64Enc += "\r\n"; + // } + + nUint24 |= aBytes[nIdx] << ((16 >>> nMod3) & 24); + if (nMod3 === 2 || aBytes.length - nIdx === 1) { + sB64Enc += String.fromCodePoint( + uint6ToB64((nUint24 >>> 18) & 63), + uint6ToB64((nUint24 >>> 12) & 63), + uint6ToB64((nUint24 >>> 6) & 63), + uint6ToB64(nUint24 & 63) + ); + nUint24 = 0; + } + } + return ( + sB64Enc.substring(0, sB64Enc.length - 2 + nMod3) + + (nMod3 === 2 ? "" : nMod3 === 1 ? "=" : "==") + ); +} + +/* UTF-8 array to JS string and vice versa */ + +function UTF8ArrToStr(aBytes: Uint8Array): string { + let sView = ""; + let nPart; + const nLen = aBytes.length; + for (let nIdx = 0; nIdx < nLen; nIdx++) { + nPart = aBytes[nIdx]; + sView += String.fromCodePoint( + nPart > 251 && nPart < 254 && nIdx + 5 < nLen /* six bytes */ + ? /* (nPart - 252 << 30) may be not so safe in ECMAScript! So…: */ + (nPart - 252) * 1073741824 + + ((aBytes[++nIdx] - 128) << 24) + + ((aBytes[++nIdx] - 128) << 18) + + ((aBytes[++nIdx] - 128) << 12) + + ((aBytes[++nIdx] - 128) << 6) + + aBytes[++nIdx] - + 128 + : nPart > 247 && nPart < 252 && nIdx + 4 < nLen /* five bytes */ + ? ((nPart - 248) << 24) + + ((aBytes[++nIdx] - 128) << 18) + + ((aBytes[++nIdx] - 128) << 12) + + ((aBytes[++nIdx] - 128) << 6) + + aBytes[++nIdx] - + 128 + : nPart > 239 && nPart < 248 && nIdx + 3 < nLen /* four bytes */ + ? ((nPart - 240) << 18) + + ((aBytes[++nIdx] - 128) << 12) + + ((aBytes[++nIdx] - 128) << 6) + + aBytes[++nIdx] - + 128 + : nPart > 223 && nPart < 240 && nIdx + 2 < nLen /* three bytes */ + ? ((nPart - 224) << 12) + + ((aBytes[++nIdx] - 128) << 6) + + aBytes[++nIdx] - + 128 + : nPart > 191 && nPart < 224 && nIdx + 1 < nLen /* two bytes */ + ? ((nPart - 192) << 6) + aBytes[++nIdx] - 128 + : /* nPart < 127 ? */ /* one byte */ + nPart + ); + } + return sView; +} + +function strToUTF8Arr(sDOMStr: string): Uint8Array { + let nChr; + const nStrLen = sDOMStr.length; + let nArrLen = 0; + + /* mapping… */ + for (let nMapIdx = 0; nMapIdx < nStrLen; nMapIdx++) { + nChr = sDOMStr.codePointAt(nMapIdx); + if (nChr === undefined) { + throw Error(`No char at ${nMapIdx} on string with length: ${sDOMStr.length}`) + } + + if (nChr >= 0x10000) { + nMapIdx++; + } + + nArrLen += + nChr < 0x80 + ? 1 + : nChr < 0x800 + ? 2 + : nChr < 0x10000 + ? 3 + : nChr < 0x200000 + ? 4 + : nChr < 0x4000000 + ? 5 + : 6; + } + + const aBytes = new Uint8Array(nArrLen); + + /* transcription… */ + let nIdx = 0; + let nChrIdx = 0; + while (nIdx < nArrLen) { + nChr = sDOMStr.codePointAt(nChrIdx); + if (nChr === undefined) { + throw Error(`No char at ${nChrIdx} on string with length: ${sDOMStr.length}`) + } + if (nChr < 128) { + /* one byte */ + aBytes[nIdx++] = nChr; + } else if (nChr < 0x800) { + /* two bytes */ + aBytes[nIdx++] = 192 + (nChr >>> 6); + aBytes[nIdx++] = 128 + (nChr & 63); + } else if (nChr < 0x10000) { + /* three bytes */ + aBytes[nIdx++] = 224 + (nChr >>> 12); + aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + } else if (nChr < 0x200000) { + /* four bytes */ + aBytes[nIdx++] = 240 + (nChr >>> 18); + aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + nChrIdx++; + } else if (nChr < 0x4000000) { + /* five bytes */ + aBytes[nIdx++] = 248 + (nChr >>> 24); + aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + nChrIdx++; + } /* if (nChr <= 0x7fffffff) */ else { + /* six bytes */ + aBytes[nIdx++] = 252 + (nChr >>> 30); + aBytes[nIdx++] = 128 + ((nChr >>> 24) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + nChrIdx++; + } + nChrIdx++; + } + + return aBytes; +} diff --git a/packages/web-util/src/utils/request.ts b/packages/web-util/src/utils/request.ts new file mode 100644 index 000000000..24342bb80 --- /dev/null +++ b/packages/web-util/src/utils/request.ts @@ -0,0 +1,319 @@ +/* + 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"; + +/** + * + * @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 ?? 2 * 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: HttpResponseUnexpectedError = { + info, + status: 0, + error: ex, + 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 = + | HttpResponseClientError + | HttpResponseServerError + | HttpResponseUnexpectedError; + +export interface HttpResponseServerError { + ok?: false; + loading?: false; + clientError?: false; + serverError: true; + + error?: ErrorDetail; + status: HttpStatusCode; + message: string; + info?: RequestInfo; +} +interface HttpResponseClientError { + ok?: false; + loading?: false; + clientError: true; + serverError?: false; + + info?: RequestInfo; + isUnauthorized: boolean; + isNotfound: boolean; + status: HttpStatusCode; + error?: ErrorDetail; + message: string; +} + +interface HttpResponseUnexpectedError { + ok?: false; + loading?: false; + clientError?: false; + serverError?: false; + + info?: RequestInfo; + status?: HttpStatusCode; + error: unknown; + message: string; +} + +export class RequestError extends Error { + info: HttpError; + constructor(d: HttpError) { + super(d.message) + this.info = 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, + status, + info, + message: data?.hint, + error: data, + }; + return error; + } + if (status && status >= 500 && status < 600) { + const error: HttpResponseServerError = { + serverError: true, + status, + info, + message: `${data?.hint} (code ${data?.code})`, + error: data, + }; + return error; + } + return { + info, + status, + error: {}, + message: "NOT DEFINED", + }; + } catch (ex) { + const error: HttpResponseUnexpectedError = { + info, + status, + error: ex, + message: "NOT DEFINED", + }; + + return error; + } +} + +// export function isAxiosError( +// error: AxiosError | any, +// ): error is AxiosError { +// return error && error.isAxiosError; +// } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eef15f25e..4058ca82a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,7 +119,7 @@ importers: preact-router: 3.2.1 qrcode-generator: ^1.4.4 sass: 1.56.1 - swr: 1.3.0 + swr: 2.0.3 typescript: 4.9.4 dependencies: '@gnu-taler/taler-util': link:../taler-util @@ -130,7 +130,7 @@ importers: preact: 10.11.3 preact-router: 3.2.1_preact@10.11.3 qrcode-generator: 1.4.4 - swr: 1.3.0 + swr: 2.0.3 devDependencies: '@creativebulma/bulma-tooltip': 1.2.0 '@gnu-taler/pogen': link:../pogen @@ -640,7 +640,7 @@ importers: preact-render-to-string: ^5.2.6 prettier: ^2.5.1 rimraf: ^3.0.2 - swr: 1.3.0 + swr: 2.0.3 tslib: ^2.4.0 typescript: ^4.9.4 ws: 7.4.5 @@ -658,7 +658,7 @@ importers: preact-render-to-string: 5.2.6_preact@10.11.3 prettier: 2.7.1 rimraf: 3.0.2 - swr: 1.3.0 + swr: 2.0.3 tslib: 2.4.1 typescript: 4.9.4 ws: 7.4.5 @@ -14646,6 +14646,15 @@ packages: resolution: {integrity: sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw==} peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 + dev: false + + /swr/2.0.3: + resolution: {integrity: sha512-sGvQDok/AHEWTPfhUWXEHBVEXmgGnuahyhmRQbjl9XBYxT/MSlAzvXEKQpyM++bMPaI52vcWS2HiKNaW7+9OFw==} + engines: {pnpm: '7'} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + dependencies: + use-sync-external-store: 1.2.0 /symbol-tree/3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -15301,6 +15310,11 @@ packages: querystring: 0.2.0 dev: true + /use-sync-external-store/1.2.0: + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + /use/3.1.1: resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==} engines: {node: '>=0.10.0'}