From 8aa9ce6d20b41b7eb9b438a56ccd34cb0da35f80 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 21 Mar 2024 12:11:31 -0300 Subject: wip --- packages/web-util/src/context/activity.ts | 65 ++++++ packages/web-util/src/context/bank-api.ts | 202 ++++++++++++++++++ packages/web-util/src/context/index.ts | 5 +- packages/web-util/src/context/merchant-api.ts | 226 +++++++++++++++++++++ packages/web-util/src/context/navigation.ts | 102 ++++++++++ .../web-util/src/context/wallet-integration.ts | 83 ++++++++ packages/web-util/src/index.browser.ts | 1 + packages/web-util/src/utils/route.ts | 129 ++++++++++++ 8 files changed, 812 insertions(+), 1 deletion(-) create mode 100644 packages/web-util/src/context/activity.ts create mode 100644 packages/web-util/src/context/bank-api.ts create mode 100644 packages/web-util/src/context/merchant-api.ts create mode 100644 packages/web-util/src/context/navigation.ts create mode 100644 packages/web-util/src/context/wallet-integration.ts create mode 100644 packages/web-util/src/utils/route.ts (limited to 'packages/web-util/src') diff --git a/packages/web-util/src/context/activity.ts b/packages/web-util/src/context/activity.ts new file mode 100644 index 000000000..570729471 --- /dev/null +++ b/packages/web-util/src/context/activity.ts @@ -0,0 +1,65 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { ObservabilityEvent, TalerAuthenticationHttpClient, TalerBankConversionHttpClient, TalerCoreBankHttpClient, TalerMerchantInstanceHttpClient, TalerMerchantManagementHttpClient } from "@gnu-taler/taler-util"; + +type Listener = (e: Event) => void; +type Unsuscriber = () => void; +export type Suscriber = (fn: Listener) => Unsuscriber; + +export class ActiviyTracker { + private observers = new Array>(); + notify(data: Event) { + this.observers.forEach((observer) => observer(data)) + } + subscribe(func: Listener): Unsuscriber { + this.observers.push(func); + return () => { + this.observers.forEach((observer, index) => { + if (observer === func) { + this.observers.splice(index, 1); + } + }); + }; + } +} + +/** + * build http client with cache breaker due to SWR + * @param url + * @returns + */ +export interface APIClient { + getRemoteConfig(): Promise; + VERSION: string; + lib: T, + onActivity: Suscriber; + cancelRequest(id: string): void; +} + +export interface MerchantLib { + management: TalerMerchantManagementHttpClient; + authenticate: TalerAuthenticationHttpClient; + instance: (instanceId: string) => TalerMerchantInstanceHttpClient; + impersonate: (instanceId: string) => TalerAuthenticationHttpClient; +} + +export interface BankLib { + bank: TalerCoreBankHttpClient; + conversion: TalerBankConversionHttpClient; + auth: (user: string) => TalerAuthenticationHttpClient; +} + diff --git a/packages/web-util/src/context/bank-api.ts b/packages/web-util/src/context/bank-api.ts new file mode 100644 index 000000000..645eda183 --- /dev/null +++ b/packages/web-util/src/context/bank-api.ts @@ -0,0 +1,202 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + CacheEvictor, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerAuthenticationHttpClient, + TalerBankConversionCacheEviction, + TalerBankConversionHttpClient, + TalerCoreBankCacheEviction, + TalerCoreBankHttpClient, + TalerCorebankApi, + TalerError +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { APIClient, ActiviyTracker, BankLib, Suscriber } from "./activity.js"; +import { useTranslationContext } from "./translation.js"; +import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type BankContextType = { + url: URL; + config: TalerCorebankApi.Config; + lib: BankLib; + hints: VersionHint[]; + onActivity: Suscriber; + cancelRequest: (eventId: string) => void; +}; + +// @ts-expect-error default value to undefined, should it be another thing? +const BankContext = createContext(undefined); + +export const useBankCoreApiContext = (): BankContextType => useContext(BankContext); + +enum VersionHint { + NONE, +} + +type Evictors = { + conversion?: CacheEvictor; + bank?: CacheEvictor; +} + +type ConfigResult = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +export const BankApiProvider = ({ + baseUrl, + children, + frameOnError, + evictors = {}, +}: { + baseUrl: URL; + children: ComponentChildren; + evictors?: Evictors, + frameOnError: FunctionComponent<{ children: ComponentChildren }>; +}): VNode => { + const [checked, setChecked] = useState>(); + const { i18n } = useTranslationContext(); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = buildBankApiClient(baseUrl, evictors); + + useEffect(() => { + getRemoteConfig() + .then((config) => { + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + }) + .catch((error: unknown) => { + if (error instanceof TalerError) { + setChecked({ type: "error", error }); + } + }); + }, []); + + if (checked === undefined) { + return h(frameOnError, { children: h("div", {}, "checking compatibility with server...") }); + } + if (checked.type === "error") { + return h(frameOnError, { + children: h(ErrorLoading, { error: checked.error, showDetail: true }), + }); + } + if (checked.type === "incompatible") { + return h(frameOnError, { + children: h( + "div", + {}, + i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`, + ), + }); + } + + const value: BankContextType = { + url: baseUrl, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + hints: checked.hints, + }; + return h(BankContext.Provider, { + value, + children, + }); +}; + +function buildBankApiClient(url: URL, evictors: Evictors, +): APIClient { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker(); + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const bank = new TalerCoreBankHttpClient( + url.href, + httpLib, + evictors.bank, + ); + const conversion = new TalerBankConversionHttpClient( + bank.getConversionInfoAPI().href, + httpLib, + evictors.conversion, + ); + const auth = (user: string) => + new TalerAuthenticationHttpClient( + bank.getAuthenticationAPI(user).href, + user, + httpLib, + ); + + async function getRemoteConfig() { + const resp = await bank.getConfig() + return resp.body + } + + return { + getRemoteConfig, + VERSION: bank.PROTOCOL_VERSION, + lib: { + bank, conversion, auth + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + + +export const BankApiProviderTesting = ({ + children, + value, +}: { + value: BankContextType + children: ComponentChildren; +}): VNode => { + return h(BankContext.Provider, { + value, + children, + }); +} diff --git a/packages/web-util/src/context/index.ts b/packages/web-util/src/context/index.ts index 9ed3ef645..0e28b844a 100644 --- a/packages/web-util/src/context/index.ts +++ b/packages/web-util/src/context/index.ts @@ -4,4 +4,7 @@ export { TranslationProvider, useTranslationContext } from "./translation.js"; - +export * from "./bank-api.js"; +export * from "./merchant-api.js"; +export * from "./navigation.js"; +export * from "./wallet-integration.js"; diff --git a/packages/web-util/src/context/merchant-api.ts b/packages/web-util/src/context/merchant-api.ts new file mode 100644 index 000000000..79c79ee9c --- /dev/null +++ b/packages/web-util/src/context/merchant-api.ts @@ -0,0 +1,226 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + CacheEvictor, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerAuthenticationHttpClient, + TalerError, + TalerMerchantApi, + TalerMerchantInstanceCacheEviction, + TalerMerchantInstanceHttpClient, + TalerMerchantManagementCacheEviction, + TalerMerchantManagementHttpClient, +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { + APIClient, + ActiviyTracker, + MerchantLib, + Suscriber, +} from "./activity.js"; +import { useTranslationContext } from "./translation.js"; +import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type MerchantContextType = { + url: URL; + config: TalerMerchantApi.VersionResponse; + lib: MerchantLib; + hints: VersionHint[]; + onActivity: Suscriber; + cancelRequest: (eventId: string) => void; +}; + +// FIXME: below +// @ts-expect-error default value to undefined, should it be another thing? +const MerchantContext = createContext(undefined); + +export const useMerchantApiContext = (): MerchantContextType => + useContext(MerchantContext); + +enum VersionHint { + NONE, +} + +type Evictors = { + management?: CacheEvictor; + instance?: ( + instanceId: string, + ) => CacheEvictor; +}; + +type ConfigResult = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +export const MerchantApiProvider = ({ + baseUrl, + children, + evictors = {}, + frameOnError, +}: { + baseUrl: URL; + evictors?: Evictors; + children: ComponentChildren; + frameOnError: FunctionComponent<{ children: ComponentChildren }>; +}): VNode => { + const [checked, setChecked] = + useState>(); + const { i18n } = useTranslationContext(); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = + buildMerchantApiClient(baseUrl, evictors); + + useEffect(() => { + getRemoteConfig() + .then((config) => { + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + }) + .catch((error: unknown) => { + if (error instanceof TalerError) { + setChecked({ type: "error", error }); + } + }); + }, []); + + if (checked === undefined) { + return h(frameOnError, { + children: h("div", {}, "checking compatibility with server..."), + }); + } + if (checked.type === "error") { + return h(frameOnError, { + children: h(ErrorLoading, { error: checked.error, showDetail: true }), + }); + } + if (checked.type === "incompatible") { + return h(frameOnError, { + children: h( + "div", + {}, + i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`, + ), + }); + } + + const value: MerchantContextType = { + url: baseUrl, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + hints: checked.hints, + }; + return h(MerchantContext.Provider, { + value, + children, + }); +}; + +function buildMerchantApiClient( + url: URL, + evictors: Evictors, +): APIClient { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker(); + + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const management = new TalerMerchantManagementHttpClient( + url.href, + httpLib, + evictors.management, + ); + const instance = (instanceId: string) => + new TalerMerchantInstanceHttpClient( + management.getSubInstanceAPI(instanceId).href, + httpLib, + evictors.instance ? evictors.instance(instanceId) : undefined, + ); + const authenticate = new TalerAuthenticationHttpClient( + management.getAuthenticationAPI().href, + "default", + httpLib, + ); + const impersonate = (instanceId: string) => + new TalerAuthenticationHttpClient( + instance(instanceId).getAuthenticationAPI().href, + instanceId, + httpLib, + ); + + async function getRemoteConfig(): Promise { + const resp = await management.getConfig(); + return resp.body; + } + + return { + getRemoteConfig, + VERSION: management.PROTOCOL_VERSION, + lib: { + management, + authenticate, + impersonate, + instance, + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + +export const MerchantApiProviderTesting = ({ + children, + value, +}: { + value: MerchantContextType; + children: ComponentChildren; +}): VNode => { + return h(MerchantContext.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/context/navigation.ts b/packages/web-util/src/context/navigation.ts new file mode 100644 index 000000000..a2fe3ff12 --- /dev/null +++ b/packages/web-util/src/context/navigation.ts @@ -0,0 +1,102 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { AppLocation, ObjectOf, Location, findMatch, RouteDefinition } from "../utils/route.js"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useCurrentLocation>>( + pagesMap: T, +): Location | undefined { + const pageList = Object.keys(pagesMap as object) as Array; + const { path, params } = useNavigationContext(); + + return findMatch(pagesMap, pageList, path, params); +} + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type Type = { + path: string; + params: Record; + navigateTo: (path: AppLocation) => void; + // addNavigationListener: (listener: (path: string, params: Record) => void) => (() => void); +}; + +// @ts-expect-error should not be used without provider +const Context = createContext(undefined); + +export const useNavigationContext = (): Type => useContext(Context); + +function getPathAndParamsFromWindow() { + const path = + typeof window !== "undefined" ? window.location.hash.substring(1) : "/"; + const params: Record = {}; + if (typeof window !== "undefined") { + for (const [key, value] of new URLSearchParams(window.location.search)) { + params[key] = value; + } + } + return { path, params }; +} + +const { path: initialPath, params: initialParams } = + getPathAndParamsFromWindow(); + +// there is a possibility that if the browser does a redirection +// (which doesn't go through navigatTo function) and that executed +// too early (before addEventListener runs) it won't be taking +// into account +const PopStateEventType = "popstate"; + +export const BrowserHashNavigationProvider = ({ + children, +}: { + children: ComponentChildren; +}): VNode => { + const [{ path, params }, setState] = useState({ + path: initialPath, + params: initialParams, + }); + if (typeof window === "undefined") { + throw Error( + "Can't use BrowserHashNavigationProvider if there is no window object", + ); + } + function navigateTo(path: string) { + const { params } = getPathAndParamsFromWindow(); + setState({ path, params }); + window.location.href = path; + } + + useEffect(() => { + function eventListener() { + setState(getPathAndParamsFromWindow()); + } + window.addEventListener(PopStateEventType, eventListener); + return () => { + window.removeEventListener(PopStateEventType, eventListener); + }; + }, []); + return h(Context.Provider, { + value: { path, params, navigateTo }, + children, + }); +}; diff --git a/packages/web-util/src/context/wallet-integration.ts b/packages/web-util/src/context/wallet-integration.ts new file mode 100644 index 000000000..e14988ed1 --- /dev/null +++ b/packages/web-util/src/context/wallet-integration.ts @@ -0,0 +1,83 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { stringifyTalerUri, TalerUri } from "@gnu-taler/taler-util"; +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext } from "preact/hooks"; + +/** + * https://docs.taler.net/design-documents/039-taler-browser-integration.html + * + * @param uri + */ +function createHeadMetaTag(uri: TalerUri, onNotFound?: () => void) { + const meta = document.createElement("meta"); + meta.setAttribute("name", "taler-uri"); + meta.setAttribute("content", stringifyTalerUri(uri)); + + document.head.appendChild(meta); + + let walletFound = false; + window.addEventListener("beforeunload", () => { + walletFound = true; + }); + setTimeout(() => { + if (!walletFound && onNotFound) { + onNotFound(); + } + }, 10); //very short timeout +} +interface Type { + /** + * Tell the active wallet that an action is found + * + * @param uri + * @returns + */ + publishTalerAction: (uri: TalerUri, onNotFound?: () => void) => void; +} + +// @ts-expect-error default value to undefined, should it be another thing? +const Context = createContext(undefined); + +export const useTalerWalletIntegrationAPI = (): Type => useContext(Context); + +export const TalerWalletIntegrationBrowserProvider = ({ + children, +}: { + children: ComponentChildren; +}): VNode => { + const value: Type = { + publishTalerAction: createHeadMetaTag, + }; + return h(Context.Provider, { + value, + children, + }); +}; + +export const TalerWalletIntegrationTestingProvider = ({ + children, + value, +}: { + children: ComponentChildren; + value: Type; +}): VNode => { + return h(Context.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/index.browser.ts b/packages/web-util/src/index.browser.ts index 82c399bfd..2f3b57b8d 100644 --- a/packages/web-util/src/index.browser.ts +++ b/packages/web-util/src/index.browser.ts @@ -3,6 +3,7 @@ export * from "./utils/request.js"; export * from "./utils/http-impl.browser.js"; export * from "./utils/http-impl.sw.js"; export * from "./utils/observable.js"; +export * from "./utils/route.js"; export * from "./context/index.js"; export * from "./components/index.js"; export * from "./forms/index.js"; diff --git a/packages/web-util/src/utils/route.ts b/packages/web-util/src/utils/route.ts new file mode 100644 index 000000000..4f8a020f6 --- /dev/null +++ b/packages/web-util/src/utils/route.ts @@ -0,0 +1,129 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 + */ + +declare const __location: unique symbol; +/** + * special string that defined a location in the application + * + * this help to prevent wrong path + */ +export type AppLocation = string & { + [__location]: true; +}; + +export type EmptyObject = Record; + +export function urlPattern< + T extends Record = EmptyObject, +>(pattern: RegExp, reverse: (p: T) => string): RouteDefinition { + const url = reverse as (p: T) => AppLocation; + return { + pattern: new RegExp(pattern), + url, + }; +} + +/** + * defines a location in the app + * + * pattern: how a string will trigger this location + * url(): how a state serialize to a location + */ + +export type ObjectOf = Record | EmptyObject; + +export type RouteDefinition< + T extends ObjectOf = EmptyObject, +> = { + pattern: RegExp; + url: (p: T) => AppLocation; +}; + +const nullRountDef = { + pattern: new RegExp(/.*/), + url: () => "" as AppLocation, +}; +export function buildNullRoutDefinition< + T extends ObjectOf, +>(): RouteDefinition { + return nullRountDef; +} + +/** + * Search path in the pageList + * get the values from the path found + * add params from searchParams + * + * @param path + * @param params + */ +export function findMatch>( + pagesMap: T, + pageList: Array, + path: string, + params: Record, +): Location | undefined { + for (let idx = 0; idx < pageList.length; idx++) { + const name = pageList[idx]; + const found = pagesMap[name].pattern.exec(path); + if (found !== null) { + const values = {} as Record; + + Object.entries(params).forEach(([key, value]) => { + values[key] = value; + }); + + if (found.groups !== undefined) { + Object.entries(found.groups).forEach(([key, value]) => { + values[key] = value; + }); + } + + // @ts-expect-error values is a map string which is equivalent to the RouteParamsType + return { name, parent: pagesMap, values }; + } + } + return undefined; +} + +/** + * get the type of the params of a location + * + */ +type RouteParamsType< + RouteType, + Key extends keyof RouteType, +> = RouteType[Key] extends RouteDefinition ? ParamType : never; + +/** + * Helps to create a map of a type with the key + */ +type MapKeyValue = { + [Key in keyof Type]: Key extends string + ? { + parent: Type; + name: Key; + values: RouteParamsType; + } + : never; +}; + +/** + * create a enumeration of value of a mapped type + */ +type EnumerationOf = T[keyof T]; + +export type Location = EnumerationOf>; -- cgit v1.2.3