From 825d2c4352022e7397854b2bd9ba7d3589873c07 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 15 Feb 2023 23:32:42 +0100 Subject: make wallet-cli runnable under qtart --- packages/taler-util/package.json | 30 ++- packages/taler-util/src/clk.ts | 53 ++-- packages/taler-util/src/compat.d.ts | 22 ++ packages/taler-util/src/compat.node.ts | 59 +++++ packages/taler-util/src/compat.qtart.ts | 53 ++++ packages/taler-util/src/errors.ts | 248 ++++++++++++++++++ packages/taler-util/src/http-common.ts | 39 +++ packages/taler-util/src/http-impl.node.d.ts | 17 ++ packages/taler-util/src/http-impl.node.ts | 175 +++++++++++++ packages/taler-util/src/http-impl.qtart.ts | 127 +++++++++ packages/taler-util/src/http.ts | 360 ++++++++++++++++++++++++++ packages/taler-util/src/index.browser.ts | 4 + packages/taler-util/src/index.node.ts | 1 - packages/taler-util/src/index.ts | 1 + packages/taler-util/src/qtart.ts | 36 +++ packages/taler-util/src/twrpc-impl.missing.ts | 9 + 16 files changed, 1201 insertions(+), 33 deletions(-) create mode 100644 packages/taler-util/src/compat.d.ts create mode 100644 packages/taler-util/src/compat.node.ts create mode 100644 packages/taler-util/src/compat.qtart.ts create mode 100644 packages/taler-util/src/errors.ts create mode 100644 packages/taler-util/src/http-common.ts create mode 100644 packages/taler-util/src/http-impl.node.d.ts create mode 100644 packages/taler-util/src/http-impl.node.ts create mode 100644 packages/taler-util/src/http-impl.qtart.ts create mode 100644 packages/taler-util/src/http.ts create mode 100644 packages/taler-util/src/qtart.ts (limited to 'packages/taler-util') diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json index 23ff5dcfa..9cf47d256 100644 --- a/packages/taler-util/package.json +++ b/packages/taler-util/package.json @@ -15,12 +15,38 @@ }, "./twrpc": { "default": "./lib/twrpc.js" + }, + "./compat": { + "types": "./lib/compat.node.js", + "node": "./lib/compat.node.js", + "qtart": "./lib/compat.qtart.js", + "default": "./lib/not-implemented.js" + }, + "./clk": { + "default": "./lib/clk.js" + }, + "./http": { + "default": "./lib/http.js" + }, + "./qtart": { + "types": "./lib/qtart.js", + "qtart": "./lib/qtart.js", + "default": "./lib/not-implemented.js" } }, "imports": { "#twrpc-impl": { - "node": "./lib/twrpc-impl.node.js", - "default": "./lib/twrpc-impl.missing.js" + "node": "./lib/twrpc-impl.node.js" + }, + "#compat-impl": { + "node": "./lib/compat.node.js", + "qtart": "./lib/compat.qtart.js", + "type": "./lib/compat.d.ts" + }, + "#http-impl": { + "type": "./lib/http-impl.node.js", + "node": "./lib/http-impl.node.js", + "qtart": "./lib/http-impl.qtart.js" } }, "scripts": { diff --git a/packages/taler-util/src/clk.ts b/packages/taler-util/src/clk.ts index e99ebf733..7bcd19b04 100644 --- a/packages/taler-util/src/clk.ts +++ b/packages/taler-util/src/clk.ts @@ -17,10 +17,12 @@ /** * Imports. */ -import process from "process"; -import path from "path"; -import readline from "readline"; -import { devNull } from "os"; +import { + processExit, + processArgv, + readlinePrompt, + pathBasename, +} from "#compat-impl"; export namespace clk { class Converter {} @@ -359,13 +361,13 @@ export namespace clk { console.error( `error: unknown option '--${r.key}' for ${currentName}`, ); - process.exit(-1); + processExit(-1); throw Error("not reached"); } if (d.isFlag) { if (r.value !== undefined) { console.error(`error: flag '--${r.key}' does not take a value`); - process.exit(-1); + processExit(-1); throw Error("not reached"); } storeFlag(d, true); @@ -373,7 +375,7 @@ export namespace clk { if (r.value === undefined) { if (i === unparsedArgs.length - 1) { console.error(`error: option '--${r.key}' needs an argument`); - process.exit(-1); + processExit(-1); throw Error("not reached"); } storeOption(d, unparsedArgs[i + 1]); @@ -391,7 +393,7 @@ export namespace clk { const opt = this.shortOptions[chr]; if (!opt) { console.error(`error: option '-${chr}' not known`); - process.exit(-1); + processExit(-1); } if (opt.isFlag) { storeFlag(opt, true); @@ -399,7 +401,7 @@ export namespace clk { if (si == optShort.length - 1) { if (i === unparsedArgs.length - 1) { console.error(`error: option '-${chr}' needs an argument`); - process.exit(-1); + processExit(-1); throw Error("not reached"); } else { storeOption(opt, unparsedArgs[i + 1]); @@ -418,7 +420,7 @@ export namespace clk { const subcmd = this.subcommandMap[argVal]; if (!subcmd) { console.error(`error: unknown command '${argVal}'`); - process.exit(-1); + processExit(-1); throw Error("not reached"); } foundSubcommand = subcmd.commandGroup; @@ -427,7 +429,7 @@ export namespace clk { const d = this.arguments[posArgIndex]; if (!d) { console.error(`error: too many arguments for ${currentName}`); - process.exit(-1); + processExit(-1); throw Error("not reached"); } myArgs[d.name] = unparsedArgs[i]; @@ -437,7 +439,7 @@ export namespace clk { if (parsedArgs[this.argKey].help) { this.printHelp(progname, parents); - process.exit(0); + processExit(0); throw Error("not reached"); } @@ -450,7 +452,7 @@ export namespace clk { console.error( `error: missing positional argument '${d.name}' for ${currentName}`, ); - process.exit(-1); + processExit(-1); throw Error("not reached"); } } @@ -464,7 +466,7 @@ export namespace clk { } else { const name = option.flagspec.join(","); console.error(`error: missing option '${name}'`); - process.exit(-1); + processExit(-1); throw Error("not reached"); } } @@ -492,16 +494,16 @@ export namespace clk { } catch (e) { console.error(`An error occurred while running ${currentName}`); console.error(e); - process.exit(1); + processExit(1); } Promise.resolve(r).catch((e) => { console.error(`An error occurred while running ${currentName}`); console.error(e); - process.exit(1); + processExit(1); }); } else { this.printHelp(progname, parents); - process.exit(-1); + processExit(-1); throw Error("not reached"); } } @@ -524,15 +526,15 @@ export namespace clk { if (cmdlineArgs) { args = cmdlineArgs; } else { - args = process.argv.slice(1); + args = processArgv().slice(1); } if (args.length < 1) { console.error( "Error while parsing command line arguments: not enough arguments", ); - process.exit(-1); + processExit(-1); } - const progname = path.basename(args[0]); + const progname = pathBasename(args[0]); const rest = args.slice(1); this.mainCommand.run(progname, [], rest, {}); @@ -622,15 +624,6 @@ export namespace clk { } export function prompt(question: string): Promise { - const stdinReadline = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve, reject) => { - stdinReadline.question(question, (res) => { - resolve(res); - stdinReadline.close(); - }); - }); + return readlinePrompt(question); } } diff --git a/packages/taler-util/src/compat.d.ts b/packages/taler-util/src/compat.d.ts new file mode 100644 index 000000000..12ba31124 --- /dev/null +++ b/packages/taler-util/src/compat.d.ts @@ -0,0 +1,22 @@ +/* + This file is part of GNU Taler + (C) 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 processExit(status: number): never; +export function processArgv(): string[]; +export function readlinePrompt(prompt: string): Promise; +export function pathBasename(s: string): string; +export function setUnhandledRejectionHandler(h: (e: any) => void): void; +export function getenv(name: string): string | undefined; \ No newline at end of file diff --git a/packages/taler-util/src/compat.node.ts b/packages/taler-util/src/compat.node.ts new file mode 100644 index 000000000..ed27a7acd --- /dev/null +++ b/packages/taler-util/src/compat.node.ts @@ -0,0 +1,59 @@ +/* + This file is part of GNU Taler + (C) 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 process from "node:process"; +import readline from "node:readline"; +import path from "node:path"; +import os from "node:os"; + +export function processExit(status: number): never { + process.exit(1); +} + +export function processArgv(): string[] { + return [...process.argv]; +} + +export function readlinePrompt(prompt: string): Promise { + const stdinReadline = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve, reject) => { + stdinReadline.question(prompt, (res) => { + resolve(res); + stdinReadline.close(); + }); + }); +} + +export function pathBasename(p: string): string { + return path.basename(p); +} + +export function pathHomedir(): string { + return os.homedir(); +} + +export function setUnhandledRejectionHandler(h: (e: any) => void): void { + process.on("unhandledRejection", (e) => { + h(e); + }); +} + +export function getenv(name: string): string | undefined { + return process.env[name]; +} diff --git a/packages/taler-util/src/compat.qtart.ts b/packages/taler-util/src/compat.qtart.ts new file mode 100644 index 000000000..f8b336b11 --- /dev/null +++ b/packages/taler-util/src/compat.qtart.ts @@ -0,0 +1,53 @@ +/* + This file is part of GNU Taler + (C) 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 + */ + +// qtart "std" library +// @ts-ignore +import * as std from "std"; + +export function processExit(status: number): never { + std.exit(status); + throw Error("not reached"); +} + +export function processArgv(): string[] { + // @ts-ignore + return ["qtart", ...globalThis.scriptArgs]; +} + +export function readlinePrompt(prompt: string): Promise { + throw new Error("not supported"); +} + +export function pathBasename(p: string): string { + const slashIndex = p.lastIndexOf("/"); + if (slashIndex < 0) { + return p; + } + return p.substring(0, slashIndex); +} + +export function pathHomedir(): string { + return std.getenv("HOME"); +} + +export function setUnhandledRejectionHandler(h: (e: any) => void): void { + // not supported +} + +export function getenv(name: string): string | undefined { + return std.getenv(name); +} diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts new file mode 100644 index 000000000..038bdbc7c --- /dev/null +++ b/packages/taler-util/src/errors.ts @@ -0,0 +1,248 @@ +/* + This file is part of GNU Taler + (C) 2019-2020 Taler Systems SA + + 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 + */ + +/** + * Classes and helpers for error handling specific to wallet operations. + * + * @author Florian Dold + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + PayMerchantInsufficientBalanceDetails, + PayPeerInsufficientBalanceDetails, + TalerErrorCode, + TalerErrorDetail, + TransactionType, +} from "@gnu-taler/taler-util"; + +type empty = Record; + +export interface DetailsMap { + [TalerErrorCode.WALLET_PENDING_OPERATION_FAILED]: { + innerError: TalerErrorDetail; + transactionId?: string; + }; + [TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT]: { + exchangeBaseUrl: string; + }; + [TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE]: { + exchangeProtocolVersion: string; + walletProtocolVersion: string; + }; + [TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK]: empty; + [TalerErrorCode.WALLET_TIPPING_COIN_SIGNATURE_INVALID]: empty; + [TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED]: { + orderId: string; + claimUrl: string; + }; + [TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED]: empty; + [TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID]: { + merchantPub: string; + orderId: string; + }; + [TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH]: { + baseUrlForDownload: string; + baseUrlFromContractTerms: string; + }; + [TalerErrorCode.WALLET_INVALID_TALER_PAY_URI]: { + talerPayUri: string; + }; + [TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR]: { + requestUrl: string; + requestMethod: string; + httpStatusCode: number; + errorResponse?: any; + }; + [TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION]: { + stack?: string; + }; + [TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE]: { + exchangeProtocolVersion: string; + walletProtocolVersion: string; + }; + [TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN]: { + operation: string; + }; + [TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED]: { + requestUrl: string; + requestMethod: string; + throttleStats: Record; + }; + [TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT]: empty; + [TalerErrorCode.WALLET_NETWORK_ERROR]: { + requestUrl: string; + requestMethod: string; + }; + [TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE]: { + requestUrl: string; + requestMethod: string; + httpStatusCode: number; + validationError?: string; + }; + [TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID]: empty; + [TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE]: { + errorsPerCoin: Record; + }; + [TalerErrorCode.WALLET_CORE_NOT_AVAILABLE]: empty; + [TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR]: { + httpStatusCode: number; + }; + [TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR]: { + requestError: TalerErrorDetail; + }; + [TalerErrorCode.WALLET_CRYPTO_WORKER_ERROR]: { + innerError: TalerErrorDetail; + }; + [TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST]: { + detail: string; + }; + [TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED]: { + kycUrl: string; + }; + [TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE]: { + insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; + }; + [TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE]: { + insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; + }; +} + +type ErrBody = Y extends keyof DetailsMap ? DetailsMap[Y] : empty; + +export function makeErrorDetail( + code: C, + detail: ErrBody, + hint?: string, +): TalerErrorDetail { + if (!hint && !(detail as any).hint) { + hint = getDefaultHint(code); + } + const when = AbsoluteTime.now(); + return { code, when, hint, ...detail }; +} + +export function makePendingOperationFailedError( + innerError: TalerErrorDetail, + tag: TransactionType, + uid: string, +): TalerError { + return TalerError.fromDetail(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED, { + innerError, + transactionId: `${tag}:${uid}`, + }); +} + +export function summarizeTalerErrorDetail(ed: TalerErrorDetail): string { + const errName = TalerErrorCode[ed.code] ?? ""; + return `Error (${ed.code}/${errName})`; +} + +function getDefaultHint(code: number): string { + const errName = TalerErrorCode[code]; + if (errName) { + return `Error (${errName})`; + } else { + return `Error ()`; + } +} + +export class TalerProtocolViolationError extends Error { + constructor(hint?: string) { + let msg: string; + if (hint) { + msg = `Taler protocol violation error (${hint})`; + } else { + msg = `Taler protocol violation error`; + } + super(msg); + Object.setPrototypeOf(this, TalerProtocolViolationError.prototype); + } +} + +export class TalerError extends Error { + errorDetail: TalerErrorDetail & T; + private constructor(d: TalerErrorDetail & T) { + super(d.hint ?? `Error (code ${d.code})`); + this.errorDetail = d; + Object.setPrototypeOf(this, TalerError.prototype); + } + + static fromDetail( + code: C, + detail: ErrBody, + hint?: string, + ): TalerError { + if (!hint) { + hint = getDefaultHint(code); + } + const when = AbsoluteTime.now(); + return new TalerError({ code, when, hint, ...detail }); + } + + static fromUncheckedDetail(d: TalerErrorDetail): TalerError { + return new TalerError({ ...d }); + } + + static fromException(e: any): TalerError { + const errDetail = getErrorDetailFromException(e); + return new TalerError(errDetail); + } + + hasErrorCode( + code: C, + ): this is TalerError { + return this.errorDetail.code === code; + } +} + +/** + * Convert an exception (or anything that was thrown) into + * a TalerErrorDetail object. + */ +export function getErrorDetailFromException(e: any): TalerErrorDetail { + if (e instanceof TalerError) { + return e.errorDetail; + } + if (e instanceof Error) { + const err = makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + { + stack: e.stack, + }, + `unexpected exception (message: ${e.message})`, + ); + return err; + } + // Something was thrown that is not even an exception! + // Try to stringify it. + let excString: string; + try { + excString = e.toString(); + } catch (e) { + // Something went horribly wrong. + excString = "can't stringify exception"; + } + const err = makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + {}, + `unexpected exception (not an exception, ${excString})`, + ); + return err; +} diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts new file mode 100644 index 000000000..eeb335ba7 --- /dev/null +++ b/packages/taler-util/src/http-common.ts @@ -0,0 +1,39 @@ +/* + This file is part of GNU Taler + (C) 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 + + SPDX-License-Identifier: AGPL3.0-or-later +*/ + +const textEncoder = new TextEncoder(); + +export interface HttpLibArgs { + enableThrottling?: boolean, +} + +export function encodeBody(body: any): ArrayBuffer { + if (body == null) { + return new ArrayBuffer(0); + } + if (typeof body === "string") { + return textEncoder.encode(body).buffer; + } else if (ArrayBuffer.isView(body)) { + return body.buffer; + } else if (body instanceof ArrayBuffer) { + return body; + } else if (typeof body === "object") { + return textEncoder.encode(JSON.stringify(body)).buffer; + } + throw new TypeError("unsupported request body type"); +} diff --git a/packages/taler-util/src/http-impl.node.d.ts b/packages/taler-util/src/http-impl.node.d.ts new file mode 100644 index 000000000..b0fba9b30 --- /dev/null +++ b/packages/taler-util/src/http-impl.node.d.ts @@ -0,0 +1,17 @@ +import { HttpLibArgs } from "./http-common.js"; +import { HttpRequestLibrary, HttpRequestOptions, HttpResponse } from "./http.js"; +/** + * Implementation of the HTTP request library interface for node. + */ +export declare class HttpLibImpl implements HttpRequestLibrary { + private throttle; + private throttlingEnabled; + constructor(args?: HttpLibArgs); + /** + * Set whether requests should be throttled. + */ + setThrottling(enabled: boolean): void; + fetch(url: string, opt?: HttpRequestOptions): Promise; + get(url: string, opt?: HttpRequestOptions): Promise; + postJson(url: string, body: any, opt?: HttpRequestOptions): Promise; +} diff --git a/packages/taler-util/src/http-impl.node.ts b/packages/taler-util/src/http-impl.node.ts new file mode 100644 index 000000000..5f2b3ac8a --- /dev/null +++ b/packages/taler-util/src/http-impl.node.ts @@ -0,0 +1,175 @@ +/* + This file is part of GNU Taler + (C) 2019 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 + + SPDX-License-Identifier: AGPL3.0-or-later +*/ + +/** + * Imports. + */ +import * as http from "node:http"; +import { RequestOptions } from "node:http"; +import { TalerError } from "./errors.js"; +import { encodeBody, HttpLibArgs } from "./http-common.js"; +import { + DEFAULT_REQUEST_TIMEOUT_MS, + Headers, + HttpRequestLibrary, + HttpRequestOptions, + HttpResponse, +} from "./http.js"; +import { + Logger, + RequestThrottler, + TalerErrorCode, + typedArrayConcat, + URL, +} from "./index.js"; + +const logger = new Logger("http-impl.node.ts"); + +const textDecoder = new TextDecoder(); + +/** + * Implementation of the HTTP request library interface for node. + */ +export class HttpLibImpl implements HttpRequestLibrary { + private throttle = new RequestThrottler(); + private throttlingEnabled = true; + + constructor(args?: HttpLibArgs) { + this.throttlingEnabled = args?.enableThrottling ?? false; + } + + /** + * Set whether requests should be throttled. + */ + setThrottling(enabled: boolean): void { + this.throttlingEnabled = enabled; + } + + async fetch(url: string, opt?: HttpRequestOptions): Promise { + const method = opt?.method?.toUpperCase() ?? "GET"; + let body = opt?.body; + + logger.trace(`Requesting ${method} ${url}`); + + const parsedUrl = new URL(url); + if (this.throttlingEnabled && this.throttle.applyThrottle(url)) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + { + requestMethod: method, + requestUrl: url, + throttleStats: this.throttle.getThrottleStats(url), + }, + `request to origin ${parsedUrl.origin} was throttled`, + ); + } + let timeoutMs: number | undefined; + if (typeof opt?.timeout?.d_ms === "number") { + timeoutMs = opt.timeout.d_ms; + } else { + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS; + } + + const headers = { ...opt?.headers }; + headers["Content-Type"] = "application/json"; + + let reqBody: ArrayBuffer | undefined; + + if (opt?.method == "POST") { + reqBody = encodeBody(opt.body); + } + + const options: RequestOptions = { + protocol: parsedUrl.protocol, + port: parsedUrl.port, + host: parsedUrl.host, + method: method, + path: parsedUrl.pathname, + headers: opt?.headers, + }; + + const chunks: Uint8Array[] = []; + + return new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + res.on("data", (d) => { + chunks.push(d); + }); + res.on("end", () => { + const headers: Headers = new Headers(); + for (const [k, v] of Object.entries(res.headers)) { + if (!v) { + continue; + } + if (typeof v === "string") { + headers.set(k, v); + } else { + headers.set(k, v.join(", ")); + } + } + const data = typedArrayConcat(chunks); + const resp: HttpResponse = { + requestMethod: method, + requestUrl: parsedUrl.href, + status: res.statusCode || 0, + headers, + async bytes() { + return data; + }, + json() { + const text = textDecoder.decode(data); + return JSON.parse(text); + }, + async text() { + const text = textDecoder.decode(data); + return text; + }, + }; + resolve(resp); + }); + res.on("error", (e) => { + reject(e); + }); + }); + + if (reqBody) { + req.write(reqBody); + } + req.end(); + }); + } + + async get(url: string, opt?: HttpRequestOptions): Promise { + return this.fetch(url, { + method: "GET", + ...opt, + }); + } + + async postJson( + url: string, + body: any, + opt?: HttpRequestOptions, + ): Promise { + return this.fetch(url, { + method: "POST", + body, + ...opt, + }); + } +} diff --git a/packages/taler-util/src/http-impl.qtart.ts b/packages/taler-util/src/http-impl.qtart.ts new file mode 100644 index 000000000..954b41802 --- /dev/null +++ b/packages/taler-util/src/http-impl.qtart.ts @@ -0,0 +1,127 @@ +/* + This file is part of GNU Taler + (C) 2019 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 + + SPDX-License-Identifier: AGPL3.0-or-later +*/ + +/** + * Imports. + */ +import { Logger } from "@gnu-taler/taler-util"; +import { TalerError } from "./errors.js"; +import { encodeBody, HttpLibArgs } from "./http-common.js"; +import { + Headers, + HttpRequestLibrary, + HttpRequestOptions, + HttpResponse, +} from "./http.js"; +import { RequestThrottler, TalerErrorCode, URL } from "./index.js"; +import { qjsOs } from "./qtart.js"; + +const logger = new Logger("http-impl.qtart.ts"); + +const textDecoder = new TextDecoder(); + +/** + * Implementation of the HTTP request library interface for node. + */ +export class HttpLibImpl implements HttpRequestLibrary { + private throttle = new RequestThrottler(); + private throttlingEnabled = true; + + constructor(args?: HttpLibArgs) { + this.throttlingEnabled = args?.enableThrottling ?? false; + } + + /** + * Set whether requests should be throttled. + */ + setThrottling(enabled: boolean): void { + this.throttlingEnabled = enabled; + } + + async fetch(url: string, opt?: HttpRequestOptions): Promise { + const method = opt?.method ?? "GET"; + + logger.trace(`Requesting ${method} ${url}`); + + const parsedUrl = new URL(url); + if (this.throttlingEnabled && this.throttle.applyThrottle(url)) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + { + requestMethod: method, + requestUrl: url, + throttleStats: this.throttle.getThrottleStats(url), + }, + `request to origin ${parsedUrl.origin} was throttled`, + ); + } + + let data: ArrayBuffer | undefined = undefined; + let headers: string[] = []; + if (opt?.headers) { + for (let headerName of Object.keys(opt.headers)) { + headers.push(`${headerName}: ${opt.headers[headerName]}`); + } + } + if (method.toUpperCase() === "POST") { + data = encodeBody(opt?.body); + } + const res = await qjsOs.fetchHttp(url, { + method, + data, + headers, + }); + return { + requestMethod: method, + // FIXME: We don't return headers! + headers: new Headers(), + async bytes() { + return res.data; + }, + json() { + const text = textDecoder.decode(res.data); + return JSON.parse(text); + }, + async text() { + const text = textDecoder.decode(res.data); + return text; + }, + requestUrl: url, + status: res.status, + }; + } + + async get(url: string, opt?: HttpRequestOptions): Promise { + return this.fetch(url, { + method: "GET", + ...opt, + }); + } + + async postJson( + url: string, + body: any, + opt?: HttpRequestOptions, + ): Promise { + return this.fetch(url, { + method: "POST", + body, + ...opt, + }); + } +} diff --git a/packages/taler-util/src/http.ts b/packages/taler-util/src/http.ts new file mode 100644 index 000000000..fd594b655 --- /dev/null +++ b/packages/taler-util/src/http.ts @@ -0,0 +1,360 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see + */ + +/** + * Helpers for doing XMLHttpRequest-s that are based on ES6 promises. + * Allows for easy mocking for test cases. + * + * The API is inspired by the HTML5 fetch API. + */ + +/** + * Imports + */ +import { + Logger, + Duration, + AbsoluteTime, + TalerErrorDetail, + Codec, + j2s, + CancellationToken, +} from "@gnu-taler/taler-util"; +import { TalerErrorCode } from "@gnu-taler/taler-util"; +import { makeErrorDetail, TalerError } from "./errors.js"; +import * as impl from "#http-impl"; +import { HttpLibArgs } from "./http-common.js"; + +const logger = new Logger("http.ts"); + +/** + * An HTTP response that is returned by all request methods of this library. + */ +export interface HttpResponse { + requestUrl: string; + requestMethod: string; + status: number; + headers: Headers; + json(): Promise; + text(): Promise; + bytes(): Promise; +} + +export const DEFAULT_REQUEST_TIMEOUT_MS = 60000; + +export interface HttpRequestOptions { + method?: "POST" | "PUT" | "GET"; + headers?: { [name: string]: string }; + + /** + * Timeout after which the request should be aborted. + */ + timeout?: Duration; + + /** + * Cancellation token that should abort the request when + * cancelled. + */ + cancellationToken?: CancellationToken; + + body?: string | ArrayBuffer | Record; +} + +/** + * Headers, roughly modeled after the fetch API's headers object. + */ +export class Headers { + private headerMap = new Map(); + + get(name: string): string | null { + const r = this.headerMap.get(name.toLowerCase()); + if (r) { + return r; + } + return null; + } + + set(name: string, value: string): void { + const normalizedName = name.toLowerCase(); + const existing = this.headerMap.get(normalizedName); + if (existing !== undefined) { + this.headerMap.set(normalizedName, existing + "," + value); + } else { + this.headerMap.set(normalizedName, value); + } + } + + toJSON(): any { + const m: Record = {}; + this.headerMap.forEach((v, k) => (m[k] = v)); + return m; + } +} + +/** + * Interface for the HTTP request library used by the wallet. + * + * The request library is bundled into an interface to make mocking and + * request tunneling easy. + */ +export interface HttpRequestLibrary { + /** + * Make an HTTP GET request. + * + * FIXME: Get rid of this, we want the API surface to be minimal. + */ + get(url: string, opt?: HttpRequestOptions): Promise; + + /** + * Make an HTTP POST request with a JSON body. + * + * FIXME: Get rid of this, we want the API surface to be minimal. + */ + postJson( + url: string, + body: any, + opt?: HttpRequestOptions, + ): Promise; + + /** + * Make an HTTP POST request with a JSON body. + */ + fetch(url: string, opt?: HttpRequestOptions): Promise; +} + +type TalerErrorResponse = { + code: number; +} & unknown; + +type ResponseOrError = + | { isError: false; response: T } + | { isError: true; talerErrorResponse: TalerErrorResponse }; + +export async function readTalerErrorResponse( + httpResponse: HttpResponse, +): Promise { + const errJson = await httpResponse.json(); + const talerErrorCode = errJson.code; + if (typeof talerErrorCode !== "number") { + logger.warn( + `malformed error response (status ${httpResponse.status}): ${j2s( + errJson, + )}`, + ); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + httpStatusCode: httpResponse.status, + }, + "Error response did not contain error code", + ); + } + return errJson; +} + +export async function readUnexpectedResponseDetails( + httpResponse: HttpResponse, +): Promise { + const errJson = await httpResponse.json(); + const talerErrorCode = errJson.code; + if (typeof talerErrorCode !== "number") { + return makeErrorDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + httpStatusCode: httpResponse.status, + }, + "Error response did not contain error code", + ); + } + return makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + { + requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + httpStatusCode: httpResponse.status, + errorResponse: errJson, + }, + `Unexpected HTTP status (${httpResponse.status}) in response`, + ); +} + +export async function readSuccessResponseJsonOrErrorCode( + httpResponse: HttpResponse, + codec: Codec, +): Promise> { + if (!(httpResponse.status >= 200 && httpResponse.status < 300)) { + return { + isError: true, + talerErrorResponse: await readTalerErrorResponse(httpResponse), + }; + } + const respJson = await httpResponse.json(); + let parsedResponse: T; + try { + parsedResponse = codec.decode(respJson); + } catch (e: any) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + httpStatusCode: httpResponse.status, + validationError: e.toString(), + }, + "Response invalid", + ); + } + return { + isError: false, + response: parsedResponse, + }; +} + +type HttpErrorDetails = { + requestUrl: string; + requestMethod: string; + httpStatusCode: number; +}; + +export function getHttpResponseErrorDetails( + httpResponse: HttpResponse, +): HttpErrorDetails { + return { + requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + httpStatusCode: httpResponse.status, + }; +} + +export function throwUnexpectedRequestError( + httpResponse: HttpResponse, + talerErrorResponse: TalerErrorResponse, +): never { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + { + requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + httpStatusCode: httpResponse.status, + errorResponse: talerErrorResponse, + }, + `Unexpected HTTP status ${httpResponse.status} in response`, + ); +} + +export async function readSuccessResponseJsonOrThrow( + httpResponse: HttpResponse, + codec: Codec, +): Promise { + const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codec); + if (!r.isError) { + return r.response; + } + throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); +} + +export async function readSuccessResponseTextOrErrorCode( + httpResponse: HttpResponse, +): Promise> { + if (!(httpResponse.status >= 200 && httpResponse.status < 300)) { + const errJson = await httpResponse.json(); + const talerErrorCode = errJson.code; + if (typeof talerErrorCode !== "number") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + httpStatusCode: httpResponse.status, + requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + }, + "Error response did not contain error code", + ); + } + return { + isError: true, + talerErrorResponse: errJson, + }; + } + const respJson = await httpResponse.text(); + return { + isError: false, + response: respJson, + }; +} + +export async function checkSuccessResponseOrThrow( + httpResponse: HttpResponse, +): Promise { + if (!(httpResponse.status >= 200 && httpResponse.status < 300)) { + const errJson = await httpResponse.json(); + const talerErrorCode = errJson.code; + if (typeof talerErrorCode !== "number") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + httpStatusCode: httpResponse.status, + requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + }, + "Error response did not contain error code", + ); + } + throwUnexpectedRequestError(httpResponse, errJson); + } +} + +export async function readSuccessResponseTextOrThrow( + httpResponse: HttpResponse, +): Promise { + const r = await readSuccessResponseTextOrErrorCode(httpResponse); + if (!r.isError) { + return r.response; + } + throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); +} + +/** + * Get the timestamp at which the response's content is considered expired. + */ +export function getExpiry( + httpResponse: HttpResponse, + opt: { minDuration?: Duration }, +): AbsoluteTime { + const expiryDateMs = new Date( + httpResponse.headers.get("expiry") ?? "", + ).getTime(); + let t: AbsoluteTime; + if (Number.isNaN(expiryDateMs)) { + t = AbsoluteTime.now(); + } else { + t = { + t_ms: expiryDateMs, + }; + } + if (opt.minDuration) { + const t2 = AbsoluteTime.addDuration(AbsoluteTime.now(), opt.minDuration); + return AbsoluteTime.max(t, t2); + } + return t; +} + +export function createPlatformHttpLib(args?: HttpLibArgs): HttpRequestLibrary { + return new impl.HttpLibImpl(args); +} diff --git a/packages/taler-util/src/index.browser.ts b/packages/taler-util/src/index.browser.ts index 3b8e194b3..2a600644d 100644 --- a/packages/taler-util/src/index.browser.ts +++ b/packages/taler-util/src/index.browser.ts @@ -19,3 +19,7 @@ import { loadBrowserPrng } from "./prng-browser.js"; loadBrowserPrng(); export * from "./index.js"; + +// The web stuff doesn't support package.json export declarations yet, +// so we export more stuff here than we should. +export * from "./http.js"; diff --git a/packages/taler-util/src/index.node.ts b/packages/taler-util/src/index.node.ts index bd59f320a..018b4767f 100644 --- a/packages/taler-util/src/index.node.ts +++ b/packages/taler-util/src/index.node.ts @@ -21,4 +21,3 @@ initNodePrng(); export * from "./index.js"; export * from "./talerconfig.js"; export * from "./globbing/minimatch.js"; -export { clk } from "./clk.js"; diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts index 661b0332f..cf4f545a4 100644 --- a/packages/taler-util/src/index.ts +++ b/packages/taler-util/src/index.ts @@ -36,3 +36,4 @@ export * from "./CancellationToken.js"; export * from "./contract-terms.js"; export * from "./base64.js"; export * from "./merchant-api-types.js"; +export * from "./errors.js"; diff --git a/packages/taler-util/src/qtart.ts b/packages/taler-util/src/qtart.ts new file mode 100644 index 000000000..f8edf234e --- /dev/null +++ b/packages/taler-util/src/qtart.ts @@ -0,0 +1,36 @@ + +// @ts-ignore +import * as _qjsOsImp from "os"; +// @ts-ignore +import * as _qjsStdImp from "std"; + + +export interface QjsHttpResp { + status: number; + data: ArrayBuffer; +} + +export interface QjsHttpOptions { + method: string; + debug?: boolean; + data?: ArrayBuffer; + headers?: string[]; +} + + +export interface QjsOsLib { + fetchHttp(url: string, options?: QjsHttpOptions): Promise; + postMessageToHost(s: string): void; + setMessageFromHostHandler(h: (s: string) => void): void; + rename(oldPath: string, newPath: string): number; +} + +export interface QjsStdLib { + writeFile(filename: string, contents: string): void; + loadFile(filename: string): string; +} + +// This is not the nodejs "os" module, but the qjs "os" module. +export const qjsOs: QjsOsLib = _qjsOsImp as any; + +export const qjsStd: QjsStdLib = _qjsStdImp as any; \ No newline at end of file diff --git a/packages/taler-util/src/twrpc-impl.missing.ts b/packages/taler-util/src/twrpc-impl.missing.ts index d9ed37815..7d7fa84ae 100644 --- a/packages/taler-util/src/twrpc-impl.missing.ts +++ b/packages/taler-util/src/twrpc-impl.missing.ts @@ -14,4 +14,13 @@ GNU Taler; see the file COPYING. If not, see */ +import type { RpcConnectArgs, RpcServerArgs } from "./twrpc.js"; + // Not implemented. +export async function connectRpc(args: RpcConnectArgs): Promise { + throw Error("not implemented"); +} + +export async function runRpcServer(args: RpcServerArgs): Promise { + throw Error("not implemented"); +} -- cgit v1.2.3