diff options
| author | Florian Dold <florian@dold.me> | 2023-02-15 23:32:42 +0100 | 
|---|---|---|
| committer | Florian Dold <florian@dold.me> | 2023-02-16 02:50:29 +0100 | 
| commit | 825d2c4352022e7397854b2bd9ba7d3589873c07 (patch) | |
| tree | d23530bf8408367439e6b3820ea0c4269bfeb39a /packages/taler-util | |
| parent | cb2f4c21d85707abb0221cbf2a859a98836b2d44 (diff) | |
make wallet-cli runnable under qtart
Diffstat (limited to 'packages/taler-util')
| -rw-r--r-- | packages/taler-util/package.json | 30 | ||||
| -rw-r--r-- | packages/taler-util/src/clk.ts | 53 | ||||
| -rw-r--r-- | packages/taler-util/src/compat.d.ts | 22 | ||||
| -rw-r--r-- | packages/taler-util/src/compat.node.ts | 59 | ||||
| -rw-r--r-- | packages/taler-util/src/compat.qtart.ts | 53 | ||||
| -rw-r--r-- | packages/taler-util/src/errors.ts | 248 | ||||
| -rw-r--r-- | packages/taler-util/src/http-common.ts | 39 | ||||
| -rw-r--r-- | packages/taler-util/src/http-impl.node.d.ts | 17 | ||||
| -rw-r--r-- | packages/taler-util/src/http-impl.node.ts | 175 | ||||
| -rw-r--r-- | packages/taler-util/src/http-impl.qtart.ts | 127 | ||||
| -rw-r--r-- | packages/taler-util/src/http.ts | 360 | ||||
| -rw-r--r-- | packages/taler-util/src/index.browser.ts | 4 | ||||
| -rw-r--r-- | packages/taler-util/src/index.node.ts | 1 | ||||
| -rw-r--r-- | packages/taler-util/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/taler-util/src/qtart.ts | 36 | ||||
| -rw-r--r-- | packages/taler-util/src/twrpc-impl.missing.ts | 9 | 
16 files changed, 1201 insertions, 33 deletions
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<T> {} @@ -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<string> { -    const stdinReadline = readline.createInterface({ -      input: process.stdin, -      output: process.stdout, -    }); -    return new Promise<string>((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 <http://www.gnu.org/licenses/> + */ + +export function processExit(status: number): never; +export function processArgv(): string[]; +export function readlinePrompt(prompt: string): Promise<string>; +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 <http://www.gnu.org/licenses/> + */ + +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<string> { +  const stdinReadline = readline.createInterface({ +    input: process.stdin, +    output: process.stdout, +  }); +  return new Promise<string>((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 <http://www.gnu.org/licenses/> + */ + +// 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<string> { +  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 <http://www.gnu.org/licenses/> + */ + +/** + * Classes and helpers for error handling specific to wallet operations. + * + * @author Florian Dold <dold@taler.net> + */ + +/** + * Imports. + */ +import { +  AbsoluteTime, +  PayMerchantInsufficientBalanceDetails, +  PayPeerInsufficientBalanceDetails, +  TalerErrorCode, +  TalerErrorDetail, +  TransactionType, +} from "@gnu-taler/taler-util"; + +type empty = Record<string, never>; + +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<string, unknown>; +  }; +  [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<number, TalerErrorDetail>; +  }; +  [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> = Y extends keyof DetailsMap ? DetailsMap[Y] : empty; + +export function makeErrorDetail<C extends TalerErrorCode>( +  code: C, +  detail: ErrBody<C>, +  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] ?? "<unknown>"; +  return `Error (${ed.code}/${errName})`; +} + +function getDefaultHint(code: number): string { +  const errName = TalerErrorCode[code]; +  if (errName) { +    return `Error (${errName})`; +  } else { +    return `Error (<unknown>)`; +  } +} + +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<T = any> 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<C extends TalerErrorCode>( +    code: C, +    detail: ErrBody<C>, +    hint?: string, +  ): TalerError { +    if (!hint) { +      hint = getDefaultHint(code); +    } +    const when = AbsoluteTime.now(); +    return new TalerError<unknown>({ code, when, hint, ...detail }); +  } + +  static fromUncheckedDetail(d: TalerErrorDetail): TalerError { +    return new TalerError<unknown>({ ...d }); +  } + +  static fromException(e: any): TalerError { +    const errDetail = getErrorDetailFromException(e); +    return new TalerError(errDetail); +  } + +  hasErrorCode<C extends keyof DetailsMap>( +    code: C, +  ): this is TalerError<DetailsMap[C]> { +    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 <http://www.gnu.org/licenses/> + + 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<HttpResponse>; +    get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>; +    postJson(url: string, body: any, opt?: HttpRequestOptions): Promise<HttpResponse>; +} 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 <http://www.gnu.org/licenses/> + + 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<HttpResponse> { +    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<HttpResponse> { +    return this.fetch(url, { +      method: "GET", +      ...opt, +    }); +  } + +  async postJson( +    url: string, +    body: any, +    opt?: HttpRequestOptions, +  ): Promise<HttpResponse> { +    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 <http://www.gnu.org/licenses/> + + 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<HttpResponse> { +    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<HttpResponse> { +    return this.fetch(url, { +      method: "GET", +      ...opt, +    }); +  } + +  async postJson( +    url: string, +    body: any, +    opt?: HttpRequestOptions, +  ): Promise<HttpResponse> { +    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 <http://www.gnu.org/licenses/> + */ + +/** + * 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<any>; +  text(): Promise<string>; +  bytes(): Promise<ArrayBuffer>; +} + +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<string, unknown>; +} + +/** + * Headers, roughly modeled after the fetch API's headers object. + */ +export class Headers { +  private headerMap = new Map<string, string>(); + +  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<string, string> = {}; +    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<HttpResponse>; + +  /** +   * 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<HttpResponse>; + +  /** +   * Make an HTTP POST request with a JSON body. +   */ +  fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>; +} + +type TalerErrorResponse = { +  code: number; +} & unknown; + +type ResponseOrError<T> = +  | { isError: false; response: T } +  | { isError: true; talerErrorResponse: TalerErrorResponse }; + +export async function readTalerErrorResponse( +  httpResponse: HttpResponse, +): Promise<TalerErrorDetail> { +  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<TalerErrorDetail> { +  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<T>( +  httpResponse: HttpResponse, +  codec: Codec<T>, +): Promise<ResponseOrError<T>> { +  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<T>( +  httpResponse: HttpResponse, +  codec: Codec<T>, +): Promise<T> { +  const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codec); +  if (!r.isError) { +    return r.response; +  } +  throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); +} + +export async function readSuccessResponseTextOrErrorCode<T>( +  httpResponse: HttpResponse, +): Promise<ResponseOrError<string>> { +  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<void> { +  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<T>( +  httpResponse: HttpResponse, +): Promise<string> { +  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<QjsHttpResp>; +  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 <http://www.gnu.org/licenses/>   */ +import type { RpcConnectArgs, RpcServerArgs } from "./twrpc.js"; +  // Not implemented. +export async function connectRpc<T>(args: RpcConnectArgs<T>): Promise<T> { +  throw Error("not implemented"); +} + +export async function runRpcServer(args: RpcServerArgs): Promise<void> { +  throw Error("not implemented"); +}  | 
