Initial Commit
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# `error-codes` — Typed error registry for currency providers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable `CURRENCY_PROVIDER_CODES` registry and the
|
||||
`CurrencyProviderError` class every provider in
|
||||
`@suaveplan/currency-providers` raises. The registry guarantees that
|
||||
downstream code — most importantly `CurrencyConverter`'s fallback chain —
|
||||
can programmatically dispatch on the namespaced code string instead of
|
||||
parsing free-form error messages. This is the suaveplan-quality replacement
|
||||
for the upstream package's plain `throw new Error(...)` pattern, which
|
||||
gave fallback logic no way to tell "Fixer returned 401 — try the next
|
||||
provider" apart from "Fixer URL was malformed — give up".
|
||||
|
||||
`CurrencyProviderError` extends `SuaveplanError<CurrencyProviderErrorCode>`,
|
||||
preserving the full `SuaveplanError` contract — `cause` chaining,
|
||||
`toWire()` redaction, OTel span enrichment, and i18n-key dispatch — while
|
||||
adding three optional discriminants relevant to provider failures:
|
||||
`providerName` (which provider hit the error), `status` (the HTTP status
|
||||
when the failure came from an HTTP response), and `upstreamMessage` (the
|
||||
free-form error string the upstream API supplied).
|
||||
|
||||
## Features
|
||||
|
||||
- Eight stable error codes covering every provider failure mode plus the
|
||||
slim-core registry dispatch: HTTP_ERROR, PARSE_ERROR, UPSTREAM_ERROR,
|
||||
UNSUPPORTED_BASE, MISSING_CONFIG, UNKNOWN_CURRENCY,
|
||||
PROVIDER_NOT_REGISTERED, PROVIDER_ALREADY_REGISTERED
|
||||
- `CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>` —
|
||||
full `SuaveplanError` contract preserved
|
||||
- Optional `providerName` / `status` / `upstreamMessage` discriminants
|
||||
- Registered via `defineCodes` so the code values are typed as the
|
||||
namespaced strings (`"CURRENCY_PROVIDER.HTTP_ERROR"` etc.)
|
||||
- The first six codes are reachable from sibling
|
||||
`@suaveplan/currency-provider-<name>` packages (where the concrete HTTP
|
||||
call sites live); the two `PROVIDER_*` codes are reachable from this
|
||||
package's `CurrencyProviderRegistry`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers";
|
||||
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.HTTP_ERROR,
|
||||
"ECB API error: 503 Service Unavailable",
|
||||
{ providerName: "ecb", status: 503 }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Use the codes to drive fallback-chain decisions in a custom converter:
|
||||
|
||||
```ts
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers";
|
||||
|
||||
async function fetchWithFallback(providers: readonly CurrencyProvider[]) {
|
||||
let lastError: unknown;
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
return await provider.fetchRates();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!(error instanceof CurrencyProviderError)) throw error;
|
||||
// 4xx auth / config errors are not worth retrying with the next
|
||||
// provider — re-throw to bubble up
|
||||
if (error.code === CURRENCY_PROVIDER_CODES.MISSING_CONFIG) throw error;
|
||||
if (error.status === 401 || error.status === 403) throw error;
|
||||
// anything else: try next provider
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const CURRENCY_PROVIDER_CODES` — frozen registry mapping each code name
|
||||
to its namespaced string (`"CURRENCY_PROVIDER.HTTP_ERROR"`, etc.).
|
||||
- `type CurrencyProviderErrorCode` — union of the eight namespaced strings.
|
||||
- `interface CurrencyProviderErrorOptions extends SuaveplanErrorOptions` —
|
||||
adds optional `providerName: string`, `status: number`,
|
||||
`upstreamMessage: string`.
|
||||
- `class CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>`
|
||||
— constructor `(code, message, options?)`. Read-only `providerName`,
|
||||
`status`, `upstreamMessage` properties.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The discriminants are surfaced as top-level read-only properties (rather
|
||||
than fields on `context`) to make destructured `try/catch` handling
|
||||
ergonomic — `if (error instanceof CurrencyProviderError && error.status === 429)`
|
||||
reads naturally.
|
||||
- Codes that don't have a natural HTTP shape (e.g. `MISSING_CONFIG`,
|
||||
`UNKNOWN_CURRENCY`) simply omit `status` and `upstreamMessage`. The
|
||||
optional fields are populated only when meaningful.
|
||||
- `defineCodes` enforces namespace prefix at runtime — `CURRENCY_PROVIDER.X`
|
||||
cannot collide with `CURRENCY.X` from `@suaveplan/currency` or any other
|
||||
package's registry.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("CURRENCY_PROVIDER_CODES registry", () => {
|
||||
it("declares every required code with namespaced value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(CURRENCY_PROVIDER_CODES.HTTP_ERROR).toBe(
|
||||
"CURRENCY_PROVIDER.HTTP_ERROR"
|
||||
);
|
||||
expect(CURRENCY_PROVIDER_CODES.PARSE_ERROR).toBe(
|
||||
"CURRENCY_PROVIDER.PARSE_ERROR"
|
||||
);
|
||||
expect(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR).toBe(
|
||||
"CURRENCY_PROVIDER.UPSTREAM_ERROR"
|
||||
);
|
||||
expect(CURRENCY_PROVIDER_CODES.UNSUPPORTED_BASE).toBe(
|
||||
"CURRENCY_PROVIDER.UNSUPPORTED_BASE"
|
||||
);
|
||||
expect(CURRENCY_PROVIDER_CODES.MISSING_CONFIG).toBe(
|
||||
"CURRENCY_PROVIDER.MISSING_CONFIG"
|
||||
);
|
||||
expect(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY).toBe(
|
||||
"CURRENCY_PROVIDER.UNKNOWN_CURRENCY"
|
||||
);
|
||||
expect(CURRENCY_PROVIDER_CODES.PROVIDER_NOT_REGISTERED).toBe(
|
||||
"CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED"
|
||||
);
|
||||
expect(CURRENCY_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED).toBe(
|
||||
"CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED"
|
||||
);
|
||||
});
|
||||
|
||||
it("contains exactly eight codes and no others", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = Object.keys(CURRENCY_PROVIDER_CODES).sort();
|
||||
expect(keys).toEqual(
|
||||
[
|
||||
"HTTP_ERROR",
|
||||
"MISSING_CONFIG",
|
||||
"PARSE_ERROR",
|
||||
"PROVIDER_ALREADY_REGISTERED",
|
||||
"PROVIDER_NOT_REGISTERED",
|
||||
"UNKNOWN_CURRENCY",
|
||||
"UNSUPPORTED_BASE",
|
||||
"UPSTREAM_ERROR",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CurrencyProviderError contract", () => {
|
||||
it("preserves SuaveplanError contract", { timeout: 5000 }, () => {
|
||||
const error = new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.HTTP_ERROR,
|
||||
"boom",
|
||||
{ providerName: "ecb", status: 503, upstreamMessage: "down" }
|
||||
);
|
||||
expect(error).toBeInstanceOf(SuaveplanError);
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
expect(error.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
|
||||
expect(error.providerName).toBe("ecb");
|
||||
expect(error.status).toBe(503);
|
||||
expect(error.upstreamMessage).toBe("down");
|
||||
});
|
||||
|
||||
it("omits unset discriminants", { timeout: 5000 }, () => {
|
||||
const error = new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
"bad"
|
||||
);
|
||||
expect(error.providerName).toBeUndefined();
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.upstreamMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can be thrown with any registered code", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(CURRENCY_PROVIDER_CODES)) {
|
||||
const error = new CurrencyProviderError(code, `msg-${code}`);
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.message).toBe(`msg-${code}`);
|
||||
expect(error).toBeInstanceOf(SuaveplanError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/currency-providers`. Every
|
||||
* failure path raises a `CurrencyProviderError` carrying one of these
|
||||
* codes; consumers dispatch on the namespaced string
|
||||
* (`"CURRENCY_PROVIDER.HTTP_ERROR"` etc.).
|
||||
*/
|
||||
export const CURRENCY_PROVIDER_CODES = defineCodes("CURRENCY_PROVIDER", [
|
||||
"HTTP_ERROR",
|
||||
"PARSE_ERROR",
|
||||
"UPSTREAM_ERROR",
|
||||
"UNSUPPORTED_BASE",
|
||||
"MISSING_CONFIG",
|
||||
"UNKNOWN_CURRENCY",
|
||||
"PROVIDER_NOT_REGISTERED",
|
||||
"PROVIDER_ALREADY_REGISTERED",
|
||||
] as const);
|
||||
|
||||
export type CurrencyProviderErrorCode =
|
||||
(typeof CURRENCY_PROVIDER_CODES)[keyof typeof CURRENCY_PROVIDER_CODES];
|
||||
|
||||
export interface CurrencyProviderErrorOptions extends SuaveplanErrorOptions {
|
||||
/** Provider name that surfaced the error. */
|
||||
readonly providerName?: string;
|
||||
/** HTTP status code, when the failure originated from an HTTP response. */
|
||||
readonly status?: number;
|
||||
/** Upstream-supplied error message, when the upstream payload carried one. */
|
||||
readonly upstreamMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated error class for `@suaveplan/currency-providers`. Extends
|
||||
* `SuaveplanError` so `instanceof` narrows `.code` to
|
||||
* `CurrencyProviderErrorCode`; carries optional `providerName` / `status`
|
||||
* / `upstreamMessage` discriminants for diagnostics without polluting the
|
||||
* base `context` map.
|
||||
*/
|
||||
export class CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode> {
|
||||
public readonly providerName?: string;
|
||||
public readonly status?: number;
|
||||
public readonly upstreamMessage?: string;
|
||||
|
||||
public constructor(
|
||||
code: CurrencyProviderErrorCode,
|
||||
message: string,
|
||||
options: CurrencyProviderErrorOptions = {}
|
||||
) {
|
||||
super(code, message, options);
|
||||
if (options.providerName !== undefined) {
|
||||
this.providerName = options.providerName;
|
||||
}
|
||||
if (options.status !== undefined) {
|
||||
this.status = options.status;
|
||||
}
|
||||
if (options.upstreamMessage !== undefined) {
|
||||
this.upstreamMessage = options.upstreamMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
132
packages/money/finance/currency-providers/src/factory/factory.md
Normal file
132
packages/money/finance/currency-providers/src/factory/factory.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# `factory` — defineCurrencyProvider factory helper
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Exposes `defineCurrencyProvider`, the low-ceremony factory that sibling
|
||||
`@suaveplan/currency-provider-<name>` packages use to declare a provider as
|
||||
a plain object literal instead of a hand-rolled class. The factory accepts a
|
||||
`DefineCurrencyProviderInput` (the static identity flags plus a `fetchRates`
|
||||
function) and returns an `Object.freeze`-d `CurrencyProvider` that
|
||||
`CurrencyProviderRegistry` and `CurrencyConverter` accept identically to a
|
||||
class-based provider.
|
||||
|
||||
Two idioms are equally supported across the ecosystem — `class
|
||||
EcbProvider implements CurrencyProvider` for sibling packages that prefer
|
||||
the OO shape, and `defineCurrencyProvider({ ... })` for packages that prefer
|
||||
the functional shape. Neither is privileged; the factory exists so sibling
|
||||
authors do not need to write the `name` / `supportsCrypto` / `supportsFiat`
|
||||
boilerplate by hand when a closure over a config object is the natural
|
||||
expression.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns an `Object.frozen` `CurrencyProvider` — identity stable, no
|
||||
accidental mutation
|
||||
- Forwards every `RateFetchOptions` field unchanged to the supplied
|
||||
`fetchRates` function
|
||||
- Preserves the optional `supportsVirtual` flag only when explicitly
|
||||
provided (matches the `CurrencyProvider` contract from
|
||||
`@suaveplan/currency`)
|
||||
- Named `DefineCurrencyProviderInput` interface (no inline object literals
|
||||
in the factory signature) — satisfies AGENTS.md §11
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineCurrencyProvider } from "@suaveplan/currency-providers";
|
||||
|
||||
const provider = defineCurrencyProvider({
|
||||
name: "static-eur",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
async fetchRates() {
|
||||
return {
|
||||
base: "EUR",
|
||||
rates: { USD: 1.08, GBP: 0.86 },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
console.log(provider.name); // "static-eur"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Compose the factory with the shared `httpRequest` helper to author a real
|
||||
provider with three lines of HTTP wiring:
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineCurrencyProvider,
|
||||
httpRequest,
|
||||
type NoKeyConfig,
|
||||
} from "@suaveplan/currency-providers";
|
||||
|
||||
const DEFAULT_URL = "https://api.example.com/v1/rates";
|
||||
|
||||
export function createExampleProvider(config?: NoKeyConfig) {
|
||||
const baseUrl = config?.baseUrl ?? DEFAULT_URL;
|
||||
const fetchImpl = config?.fetch ?? globalThis.fetch;
|
||||
|
||||
return defineCurrencyProvider({
|
||||
name: "example",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
async fetchRates(options) {
|
||||
const response = await httpRequest({
|
||||
url: baseUrl,
|
||||
fetch: fetchImpl,
|
||||
providerName: "example",
|
||||
signal: options?.signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
base: string;
|
||||
rates: Record<string, number>;
|
||||
timestamp: number;
|
||||
};
|
||||
return {
|
||||
base: payload.base,
|
||||
rates: payload.rates,
|
||||
timestamp: payload.timestamp * 1000,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface DefineCurrencyProviderInput` — input contract.
|
||||
- `name: string` — stable identifier surfaced as `provider.name`.
|
||||
- `supportsCrypto: boolean`
|
||||
- `supportsFiat: boolean`
|
||||
- `supportsVirtual?: boolean` — optional. When omitted, the returned
|
||||
provider does not declare the property at all (matches the
|
||||
`CurrencyProvider` contract).
|
||||
- `fetchRates: (options?: RateFetchOptions) => Promise<RateFetchResult>`
|
||||
— the user-supplied implementation. Called directly; the factory
|
||||
inserts no wrapping behaviour.
|
||||
- `function defineCurrencyProvider(input: DefineCurrencyProviderInput): CurrencyProvider`
|
||||
— returns an `Object.freeze`-d `CurrencyProvider`. Identity-stable; safe
|
||||
to compare with `===`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory deliberately does **not** apply HTTP error mapping, abort
|
||||
composition, or schema validation — those concerns live in the
|
||||
`httpRequest` helper and in each sibling provider's own implementation.
|
||||
`defineCurrencyProvider` is a thin shape adapter, not a framework.
|
||||
- `Object.freeze` is shallow — the `name` / `supportsCrypto` /
|
||||
`supportsFiat` / `supportsVirtual` properties are immutable, but the
|
||||
closure variables that `fetchRates` captures are owned by the caller and
|
||||
remain mutable if the caller chose to make them so.
|
||||
- The `supportsVirtual` field is conditionally included so that providers
|
||||
that do not deal with virtual currencies surface `undefined` via
|
||||
`provider.supportsVirtual === undefined` (the contract's "optional"
|
||||
semantics), rather than always being literally present as `false`.
|
||||
@@ -0,0 +1,164 @@
|
||||
import type {
|
||||
CurrencyProvider,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
} from "@suaveplan/currency";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { ApiKeyConfig, NoKeyConfig } from "../types/configs.js";
|
||||
import {
|
||||
type DefineCurrencyProviderInput,
|
||||
defineCurrencyProvider,
|
||||
} from "./factory.js";
|
||||
|
||||
const trivialResult: RateFetchResult = {
|
||||
base: "USD",
|
||||
rates: {},
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
describe("defineCurrencyProvider", () => {
|
||||
it("returns a frozen CurrencyProvider", { timeout: 5000 }, () => {
|
||||
const provider = defineCurrencyProvider({
|
||||
name: "fake",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
async fetchRates() {
|
||||
return trivialResult;
|
||||
},
|
||||
});
|
||||
|
||||
expect(Object.isFrozen(provider)).toBe(true);
|
||||
expect(provider.name).toBe("fake");
|
||||
expect(provider.supportsCrypto).toBe(false);
|
||||
expect(provider.supportsFiat).toBe(true);
|
||||
expect(provider.supportsVirtual).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards fetchRates calls to the supplied function", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const received: RateFetchOptions[] = [];
|
||||
const result: RateFetchResult = {
|
||||
base: "EUR",
|
||||
rates: { USD: 1.08 },
|
||||
timestamp: 17,
|
||||
};
|
||||
const provider = defineCurrencyProvider({
|
||||
name: "spy",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
async fetchRates(options?: RateFetchOptions) {
|
||||
if (options !== undefined) received.push(options);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
const out = await provider.fetchRates({
|
||||
base: "EUR",
|
||||
targets: ["USD"],
|
||||
});
|
||||
|
||||
expect(out).toBe(result);
|
||||
expect(received).toEqual([{ base: "EUR", targets: ["USD"] }]);
|
||||
});
|
||||
|
||||
it("forwards an undefined options argument", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const provider = defineCurrencyProvider({
|
||||
name: "noopt",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
async fetchRates(options?: RateFetchOptions) {
|
||||
calls += 1;
|
||||
expect(options).toBeUndefined();
|
||||
return trivialResult;
|
||||
},
|
||||
});
|
||||
|
||||
await provider.fetchRates();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves supportsVirtual when provided", { timeout: 5000 }, () => {
|
||||
const virtualProvider = defineCurrencyProvider({
|
||||
name: "virtual",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: false,
|
||||
supportsVirtual: true,
|
||||
async fetchRates() {
|
||||
return trivialResult;
|
||||
},
|
||||
});
|
||||
expect(virtualProvider.supportsVirtual).toBe(true);
|
||||
|
||||
const omitted = defineCurrencyProvider({
|
||||
name: "no-virtual",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
async fetchRates() {
|
||||
return trivialResult;
|
||||
},
|
||||
});
|
||||
// Optional field absent (not literally `false`) when omitted.
|
||||
expect("supportsVirtual" in omitted).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves supportsVirtual === false when explicitly provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = defineCurrencyProvider({
|
||||
name: "explicit-false",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
supportsVirtual: false,
|
||||
async fetchRates() {
|
||||
return trivialResult;
|
||||
},
|
||||
});
|
||||
expect(provider.supportsVirtual).toBe(false);
|
||||
});
|
||||
|
||||
it("NoKeyConfig and ApiKeyConfig are assignable to the factory input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Type-only check: the shared config types compose cleanly with
|
||||
// the factory.
|
||||
function buildFromApiKey(config: ApiKeyConfig): CurrencyProvider {
|
||||
return defineCurrencyProvider({
|
||||
name: `keyed-${config.apiKey}`,
|
||||
supportsCrypto: true,
|
||||
supportsFiat: false,
|
||||
async fetchRates() {
|
||||
return trivialResult;
|
||||
},
|
||||
});
|
||||
}
|
||||
function buildFromNoKey(config: NoKeyConfig): CurrencyProvider {
|
||||
return defineCurrencyProvider({
|
||||
name: `keyless-${config.baseUrl ?? "default"}`,
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
async fetchRates() {
|
||||
return trivialResult;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
expect(buildFromApiKey({ apiKey: "k" }).name).toBe("keyed-k");
|
||||
expect(buildFromNoKey({}).name).toBe("keyless-default");
|
||||
expect(buildFromNoKey({ baseUrl: "https://x" }).name).toBe(
|
||||
"keyless-https://x"
|
||||
);
|
||||
|
||||
// Type sanity — input shape is callable as the documented contract
|
||||
const _input: DefineCurrencyProviderInput = {
|
||||
name: "t",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
fetchRates: async () => trivialResult,
|
||||
};
|
||||
expect(defineCurrencyProvider(_input).name).toBe("t");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
CurrencyProvider,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
} from "@suaveplan/currency";
|
||||
|
||||
/**
|
||||
* Input contract for {@link defineCurrencyProvider}. A sibling provider
|
||||
* package supplies the static identity flags plus a `fetchRates`
|
||||
* implementation, and the factory returns a frozen `CurrencyProvider`
|
||||
* implementation.
|
||||
*/
|
||||
export interface DefineCurrencyProviderInput {
|
||||
/** Stable identifier — surfaced as `provider.name`. */
|
||||
readonly name: string;
|
||||
/** Whether this provider answers for crypto pairs. */
|
||||
readonly supportsCrypto: boolean;
|
||||
/** Whether this provider answers for fiat pairs. */
|
||||
readonly supportsFiat: boolean;
|
||||
/** Whether this provider answers for virtual / game currencies. Optional. */
|
||||
readonly supportsVirtual?: boolean;
|
||||
/**
|
||||
* Fetch latest rates from the provider. The factory forwards every
|
||||
* `RateFetchOptions` field unchanged.
|
||||
*/
|
||||
readonly fetchRates: (
|
||||
options?: RateFetchOptions
|
||||
) => Promise<RateFetchResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `CurrencyProvider` from a plain object literal. The returned value
|
||||
* is `Object.freeze`-d so consumers can rely on identity stability.
|
||||
*
|
||||
* Sibling `@suaveplan/currency-provider-<name>` packages use this factory
|
||||
* when they prefer the "config-driven function" idiom over a hand-rolled
|
||||
* class — both shapes are equally supported by `CurrencyProviderRegistry`
|
||||
* and `CurrencyConverter`.
|
||||
*/
|
||||
export function defineCurrencyProvider(
|
||||
input: DefineCurrencyProviderInput
|
||||
): CurrencyProvider {
|
||||
const provider: CurrencyProvider = {
|
||||
name: input.name,
|
||||
supportsCrypto: input.supportsCrypto,
|
||||
supportsFiat: input.supportsFiat,
|
||||
...(input.supportsVirtual !== undefined
|
||||
? { supportsVirtual: input.supportsVirtual }
|
||||
: {}),
|
||||
fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> {
|
||||
return input.fetchRates(options);
|
||||
},
|
||||
};
|
||||
return Object.freeze(provider);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { DefineCurrencyProviderInput } from "./factory.js";
|
||||
export { defineCurrencyProvider } from "./factory.js";
|
||||
155
packages/money/finance/currency-providers/src/http/http.md
Normal file
155
packages/money/finance/currency-providers/src/http/http.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# `http` — Shared HTTP helper for sibling provider packages
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Exposes the two HTTP utilities every sibling
|
||||
`@suaveplan/currency-provider-<name>` package repeats: `httpRequest` (an
|
||||
async request through the injected `FetchLike` with typed-error mapping and
|
||||
abort-signal composition) and `composeAbortSignal` (a pure helper that
|
||||
merges a caller `AbortSignal` with an optional timeout-derived one).
|
||||
|
||||
The previous shape of `@suaveplan/currency-providers` had every provider
|
||||
re-implement the same `await this.fetchImpl(url); if (!response.ok) throw
|
||||
new CurrencyProviderError(HTTP_ERROR, ...)` block by hand. That repetition
|
||||
is exactly the signal that the helper belongs in the slim core: every
|
||||
sibling now `import { httpRequest } from "@suaveplan/currency-providers"`
|
||||
and the HTTP_ERROR call site lives in one place, exercised by one test
|
||||
file.
|
||||
|
||||
## Features
|
||||
|
||||
- `httpRequest` — single async helper covering the full HTTP lifecycle:
|
||||
abort-signal composition, timeout derivation, fetch invocation, non-2xx
|
||||
→ `CurrencyProviderError(HTTP_ERROR)` mapping
|
||||
- `composeAbortSignal` — pure four-way composition: `undefined`, caller
|
||||
only, timeout only, both (via `AbortSignal.any`)
|
||||
- Returns the raw `Response` so callers handle `.json()` / `.text()` /
|
||||
`.arrayBuffer()` according to their own payload shape — no premature
|
||||
body-format assumption
|
||||
- Universal — uses only WHATWG `fetch`, `AbortSignal.timeout`, and
|
||||
`AbortSignal.any` (all available in Node 20+, Bun, and modern browsers)
|
||||
- Typed error carries `providerName` and `status` discriminants so audit
|
||||
log handlers can dispatch without parsing the message string
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { httpRequest } from "@suaveplan/currency-providers";
|
||||
|
||||
const response = await httpRequest({
|
||||
url: "https://api.example.com/v1/rates",
|
||||
fetch: globalThis.fetch,
|
||||
providerName: "example",
|
||||
});
|
||||
const payload = await response.json();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire `httpRequest` into a sibling provider with both a timeout and the
|
||||
caller's `RateFetchOptions.signal`, then handle `HTTP_ERROR` against the
|
||||
typed code rather than the message:
|
||||
|
||||
```ts
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
httpRequest,
|
||||
type NoKeyConfig,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import type { RateFetchOptions, RateFetchResult } from "@suaveplan/currency";
|
||||
|
||||
const DEFAULT_URL = "https://api.example.com/v1/rates";
|
||||
const NAME = "example";
|
||||
|
||||
export class ExampleProvider {
|
||||
public readonly name = NAME;
|
||||
public readonly supportsCrypto = false;
|
||||
public readonly supportsFiat = true;
|
||||
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl;
|
||||
|
||||
constructor(config?: NoKeyConfig) {
|
||||
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
|
||||
this.fetchImpl = config?.fetch ?? globalThis.fetch;
|
||||
}
|
||||
|
||||
async fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> {
|
||||
try {
|
||||
const response = await httpRequest({
|
||||
url: this.baseUrl,
|
||||
fetch: this.fetchImpl,
|
||||
providerName: NAME,
|
||||
signal: options?.signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
base: string;
|
||||
rates: Record<string, number>;
|
||||
timestamp: number;
|
||||
};
|
||||
return {
|
||||
base: payload.base,
|
||||
rates: payload.rates,
|
||||
timestamp: payload.timestamp * 1000,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof CurrencyProviderError &&
|
||||
error.code === CURRENCY_PROVIDER_CODES.HTTP_ERROR
|
||||
) {
|
||||
// optionally: backoff, fallback, audit-log etc.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface HttpRequestInput`
|
||||
- `url: string` — fully-resolved request URL (query string already
|
||||
appended at the call site).
|
||||
- `fetch: FetchLike` — injected `FetchLike` (the sibling provider
|
||||
defaults to `globalThis.fetch` at construction time).
|
||||
- `providerName: string` — carried on the raised
|
||||
`CurrencyProviderError.providerName`.
|
||||
- `init?: RequestInit` — optional WHATWG `RequestInit` (method, headers,
|
||||
body, etc.). The helper preserves caller-supplied `headers` and
|
||||
`method`; it only inserts `signal` when an abort signal needs to be
|
||||
attached.
|
||||
- `signal?: AbortSignal` — caller's abort signal (usually from
|
||||
`RateFetchOptions.signal`).
|
||||
- `timeoutMs?: number` — when set, the helper derives
|
||||
`AbortSignal.timeout(timeoutMs)` and composes it with the caller signal.
|
||||
- `function httpRequest(input: HttpRequestInput): Promise<Response>` —
|
||||
resolves to the raw `Response` on 2xx, rejects with
|
||||
`CurrencyProviderError(HTTP_ERROR)` on non-2xx.
|
||||
- `function composeAbortSignal(caller, timeout): AbortSignal | undefined`
|
||||
— pure four-way composition. Returns the single input verbatim when only
|
||||
one is provided (no wrapping cost). Returns
|
||||
`AbortSignal.any([caller, timeout])` when both are present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The helper does **not** apply retry, backoff, or rate-limit semantics —
|
||||
those wrappers compose at a higher layer (see the deferred surfaces in
|
||||
the 2026-05-17 archive's `## Out of scope`).
|
||||
- `AbortSignal.timeout` was chosen over a manual `setTimeout +
|
||||
AbortController` to keep the helper universal (no `setTimeout`
|
||||
scheduling owned by the helper) and to avoid the cleanup foot-gun of a
|
||||
pending timer outliving a fast-success request.
|
||||
- `composeAbortSignal` deliberately returns the **single input** unchanged
|
||||
when only one is provided. Wrapping a single signal through
|
||||
`AbortSignal.any([s])` would still produce a valid signal but would
|
||||
break identity-comparison invariants downstream consumers might rely on.
|
||||
- The non-2xx error message intentionally uses `response.statusText` (the
|
||||
HTTP reason phrase) so the human-readable form survives audit log
|
||||
redaction; programmatic dispatch should use `error.code` and
|
||||
`error.status` instead.
|
||||
203
packages/money/finance/currency-providers/src/http/http.test.ts
Normal file
203
packages/money/finance/currency-providers/src/http/http.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { composeAbortSignal, httpRequest } from "./http.js";
|
||||
|
||||
interface FetchCall {
|
||||
url: string;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
function recordingFetch(
|
||||
handler: (call: FetchCall) => Promise<Response> | Response
|
||||
): { fetch: FetchLike; calls: FetchCall[] } {
|
||||
const calls: FetchCall[] = [];
|
||||
const fetchImpl: FetchLike = async (
|
||||
url: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const call: FetchCall = { url: String(url), init };
|
||||
calls.push(call);
|
||||
return await handler(call);
|
||||
};
|
||||
return { fetch: fetchImpl, calls };
|
||||
}
|
||||
|
||||
describe("httpRequest", () => {
|
||||
it("returns the response on a 2xx", { timeout: 5000 }, async () => {
|
||||
const expected = new Response("ok", { status: 200 });
|
||||
const { fetch, calls } = recordingFetch(() => expected);
|
||||
|
||||
const response = await httpRequest({
|
||||
url: "https://x/y",
|
||||
fetch,
|
||||
providerName: "fake",
|
||||
});
|
||||
|
||||
expect(response).toBe(expected);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]?.url).toBe("https://x/y");
|
||||
});
|
||||
|
||||
it("raises HTTP_ERROR on non-2xx response", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() => new Response("nope", { status: 503, statusText: "Down" })
|
||||
);
|
||||
|
||||
try {
|
||||
await httpRequest({
|
||||
url: "https://x/y",
|
||||
fetch,
|
||||
providerName: "fake",
|
||||
});
|
||||
expect.fail("httpRequest should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
|
||||
expect(err.status).toBe(503);
|
||||
expect(err.providerName).toBe("fake");
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards init.headers to the injected fetch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(
|
||||
() => new Response("ok", { status: 200 })
|
||||
);
|
||||
|
||||
await httpRequest({
|
||||
url: "https://x/y",
|
||||
fetch,
|
||||
providerName: "fake",
|
||||
init: { headers: { "x-test": "1" } },
|
||||
});
|
||||
|
||||
const headers = calls[0]?.init?.headers as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
expect(headers?.["x-test"]).toBe("1");
|
||||
});
|
||||
|
||||
it("does not attach a signal when neither timeout nor caller signal is set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(
|
||||
() => new Response("ok", { status: 200 })
|
||||
);
|
||||
|
||||
await httpRequest({
|
||||
url: "https://x/y",
|
||||
fetch,
|
||||
providerName: "fake",
|
||||
});
|
||||
|
||||
expect(calls[0]?.init?.signal).toBeUndefined();
|
||||
});
|
||||
|
||||
it("aborts when the caller signal aborts", { timeout: 5000 }, async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchImpl: FetchLike = async (
|
||||
_url: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(
|
||||
init?.signal?.reason ??
|
||||
new DOMException("aborted", "AbortError")
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const promise = httpRequest({
|
||||
url: "https://x/y",
|
||||
fetch: fetchImpl,
|
||||
providerName: "fake",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
controller.abort(new Error("caller cancelled"));
|
||||
|
||||
await expect(promise).rejects.toThrow(/caller cancelled/);
|
||||
});
|
||||
|
||||
it("aborts when the timeout signal fires", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async (
|
||||
_url: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(
|
||||
init?.signal?.reason ??
|
||||
new DOMException("aborted", "AbortError")
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await expect(
|
||||
httpRequest({
|
||||
url: "https://x/y",
|
||||
fetch: fetchImpl,
|
||||
providerName: "fake",
|
||||
timeoutMs: 5,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeAbortSignal", () => {
|
||||
it("returns undefined when both inputs are undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(composeAbortSignal(undefined, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the single input when only one is provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const caller = new AbortController().signal;
|
||||
const timeout = new AbortController().signal;
|
||||
|
||||
expect(composeAbortSignal(caller, undefined)).toBe(caller);
|
||||
expect(composeAbortSignal(undefined, timeout)).toBe(timeout);
|
||||
});
|
||||
|
||||
it("aborts when either input aborts", { timeout: 5000 }, () => {
|
||||
const callerCtrl = new AbortController();
|
||||
const timeoutCtrl = new AbortController();
|
||||
|
||||
const composed = composeAbortSignal(
|
||||
callerCtrl.signal,
|
||||
timeoutCtrl.signal
|
||||
);
|
||||
|
||||
expect(composed).toBeDefined();
|
||||
expect(composed?.aborted).toBe(false);
|
||||
|
||||
callerCtrl.abort();
|
||||
expect(composed?.aborted).toBe(true);
|
||||
|
||||
// Independent composition — timeout aborts the second signal too
|
||||
const callerCtrl2 = new AbortController();
|
||||
const timeoutCtrl2 = new AbortController();
|
||||
const composed2 = composeAbortSignal(
|
||||
callerCtrl2.signal,
|
||||
timeoutCtrl2.signal
|
||||
);
|
||||
expect(composed2?.aborted).toBe(false);
|
||||
timeoutCtrl2.abort();
|
||||
expect(composed2?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
80
packages/money/finance/currency-providers/src/http/http.ts
Normal file
80
packages/money/finance/currency-providers/src/http/http.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Input contract for {@link httpRequest}. Sibling provider packages call
|
||||
* `httpRequest({ ... })` with their resolved URL, injected `FetchLike`,
|
||||
* stable `providerName`, and any caller-supplied `signal` / `timeoutMs` /
|
||||
* `init`.
|
||||
*/
|
||||
export interface HttpRequestInput {
|
||||
/** Fully-resolved request URL (query string already appended). */
|
||||
readonly url: string;
|
||||
/** Injected `FetchLike` (default `globalThis.fetch` at the caller). */
|
||||
readonly fetch: FetchLike;
|
||||
/** Stable provider name carried on the raised `CurrencyProviderError`. */
|
||||
readonly providerName: string;
|
||||
/** Optional WHATWG `RequestInit` (method, headers, body). */
|
||||
readonly init?: RequestInit;
|
||||
/** Optional caller `AbortSignal` (e.g. from `RateFetchOptions.signal`). */
|
||||
readonly signal?: AbortSignal;
|
||||
/**
|
||||
* Optional request timeout in milliseconds. When set, derives an
|
||||
* `AbortSignal.timeout(ms)` and composes it with the caller's signal.
|
||||
*/
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue an HTTP request through the injected `FetchLike`, compose any
|
||||
* caller signal with the optional timeout signal, and raise
|
||||
* `CurrencyProviderError(HTTP_ERROR)` on a non-2xx response. Returns the
|
||||
* raw `Response` so callers handle `.json()` / `.text()` / `.arrayBuffer()`
|
||||
* according to their own payload shape.
|
||||
*/
|
||||
export async function httpRequest(input: HttpRequestInput): Promise<Response> {
|
||||
const timeoutSignal =
|
||||
input.timeoutMs !== undefined
|
||||
? AbortSignal.timeout(input.timeoutMs)
|
||||
: undefined;
|
||||
const composedSignal = composeAbortSignal(input.signal, timeoutSignal);
|
||||
|
||||
const init: RequestInit = { ...(input.init ?? {}) };
|
||||
if (composedSignal !== undefined) {
|
||||
init.signal = composedSignal;
|
||||
}
|
||||
|
||||
const response = await input.fetch(input.url, init);
|
||||
if (!response.ok) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.HTTP_ERROR,
|
||||
`${input.providerName} HTTP error: ${response.status} ${response.statusText}`,
|
||||
{
|
||||
providerName: input.providerName,
|
||||
status: response.status,
|
||||
}
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose two optional `AbortSignal`s into one.
|
||||
*
|
||||
* - `undefined` when both inputs are `undefined`
|
||||
* - The single input when only one is provided (no wrapping cost)
|
||||
* - `AbortSignal.any([caller, timeout])` when both are present — aborts
|
||||
* when either input aborts
|
||||
*/
|
||||
export function composeAbortSignal(
|
||||
caller: AbortSignal | undefined,
|
||||
timeout: AbortSignal | undefined
|
||||
): AbortSignal | undefined {
|
||||
if (caller === undefined && timeout === undefined) return undefined;
|
||||
if (caller === undefined) return timeout;
|
||||
if (timeout === undefined) return caller;
|
||||
return AbortSignal.any([caller, timeout]);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { HttpRequestInput } from "./http.js";
|
||||
export {
|
||||
composeAbortSignal,
|
||||
httpRequest,
|
||||
} from "./http.js";
|
||||
22
packages/money/finance/currency-providers/src/index.ts
Normal file
22
packages/money/finance/currency-providers/src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
type CurrencyProviderErrorCode,
|
||||
type CurrencyProviderErrorOptions,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export {
|
||||
type DefineCurrencyProviderInput,
|
||||
defineCurrencyProvider,
|
||||
} from "./factory/factory.js";
|
||||
export {
|
||||
composeAbortSignal,
|
||||
type HttpRequestInput,
|
||||
httpRequest,
|
||||
} from "./http/http.js";
|
||||
export { CurrencyProviderRegistry } from "./registry/registry.js";
|
||||
export type {
|
||||
ApiKeyConfig,
|
||||
HttpFetchOptions,
|
||||
NoKeyConfig,
|
||||
ProviderConfigBase,
|
||||
} from "./types/configs.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { CurrencyProviderRegistry } from "./registry.js";
|
||||
@@ -0,0 +1,128 @@
|
||||
# `registry` — Name-keyed lookup of CurrencyProvider instances
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines `CurrencyProviderRegistry`, the in-memory registry that sibling
|
||||
`@suaveplan/currency-provider-<name>` packages register their providers
|
||||
with and that consumers (the `CurrencyConverter` wiring, future
|
||||
`AggregatorProvider` / `RateLimitedProvider` wrappers, admin UIs that toggle
|
||||
providers on and off) use to resolve providers by name without tracking
|
||||
concrete classes themselves.
|
||||
|
||||
The registry is **opt-in convenience**, not a required indirection:
|
||||
`new CurrencyConverter({ provider: new EcbProvider() })` continues to work
|
||||
without a registry. The registry exists for callers that wire many providers
|
||||
from configuration, that need a single canonical instance of each provider,
|
||||
or that want a uniform "look up by name" surface to feed into wrappers.
|
||||
|
||||
## Features
|
||||
|
||||
- Five-method API — `register`, `get`, `has`, `list`, `unregister`
|
||||
- Typed `CurrencyProviderError` on every failure mode (no plain `Error`s)
|
||||
- `register` throws `PROVIDER_ALREADY_REGISTERED` on duplicate names —
|
||||
explicit `unregister` + `register` required to replace
|
||||
- `get` throws `PROVIDER_NOT_REGISTERED` on unknown names; `has` is a
|
||||
non-throwing probe for the same condition
|
||||
- `list()` returns a **sorted snapshot** so equal sets of providers
|
||||
produce identical listings regardless of insertion order — keeps
|
||||
downstream golden-file tests deterministic
|
||||
- `unregister` is idempotent — returns `true` when something was removed,
|
||||
`false` otherwise, never throws
|
||||
- Providers stored by reference (no cloning / freezing / defensive copy)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
|
||||
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
|
||||
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko";
|
||||
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(new EcbProvider());
|
||||
registry.register(new CoinGeckoProvider());
|
||||
|
||||
const converter = new CurrencyConverter({
|
||||
provider: registry.get("ecb"),
|
||||
fallbackProviders: [registry.get("coingecko")],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire providers from a config file, swap them at runtime, and enumerate them
|
||||
deterministically for a status dashboard:
|
||||
|
||||
```ts
|
||||
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
|
||||
import type { CurrencyProvider } from "@suaveplan/currency";
|
||||
|
||||
interface ProviderModule {
|
||||
readonly create: () => CurrencyProvider;
|
||||
}
|
||||
|
||||
function wire(
|
||||
modules: Readonly<Record<string, ProviderModule>>,
|
||||
enabled: readonly string[]
|
||||
): CurrencyProviderRegistry {
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
for (const key of enabled) {
|
||||
const mod = modules[key];
|
||||
if (mod !== undefined) registry.register(mod.create());
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
// Swap "ecb" for a different ECB instance with a custom fetch
|
||||
const registry = wire(modules, ["ecb", "coingecko", "kraken"]);
|
||||
registry.unregister("ecb");
|
||||
registry.register(new EcbProvider({ fetch: instrumentedFetch }));
|
||||
|
||||
// Dashboard: always shows providers alphabetically
|
||||
for (const name of registry.list()) {
|
||||
console.log(`provider: ${name}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class CurrencyProviderRegistry` — exported class. Holds a `Map<string,
|
||||
CurrencyProvider>` internally.
|
||||
- `register(provider: CurrencyProvider): void` — store the provider under
|
||||
`provider.name`. Throws `CurrencyProviderError` with
|
||||
`code === "CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED"` and
|
||||
`providerName === provider.name` when the name is already in use.
|
||||
- `get(name: string): CurrencyProvider` — return the registered provider.
|
||||
Throws `CurrencyProviderError` with
|
||||
`code === "CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED"` and
|
||||
`providerName === name` when nothing is registered under `name`.
|
||||
- `has(name: string): boolean` — non-throwing probe.
|
||||
- `list(): readonly string[]` — sorted snapshot of currently registered
|
||||
names.
|
||||
- `unregister(name: string): boolean` — `true` when something was
|
||||
removed, `false` otherwise. Never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is **single-tenant**: there is no global / singleton
|
||||
instance. Callers construct their own registry. Multiple registries can
|
||||
coexist (e.g. one per tenant in a multi-tenant SaaS), and a provider
|
||||
instance may be registered with more than one registry simultaneously.
|
||||
- `register` is idempotent only at the identity level: registering the same
|
||||
instance under the same name twice still throws
|
||||
`PROVIDER_ALREADY_REGISTERED`, exactly as registering two different
|
||||
instances under the same name throws. Idempotent "replace if newer"
|
||||
semantics belong in a wrapper, not the core, so the contract here stays
|
||||
unambiguous.
|
||||
- `list()` returns a fresh `string[]` on every call (the sort happens
|
||||
in-place on a copy). Callers may treat the result as immutable, but the
|
||||
registry does not freeze it — a `readonly string[]` type hint conveys the
|
||||
intent at the TypeScript boundary.
|
||||
- Both `PROVIDER_NOT_REGISTERED` and `PROVIDER_ALREADY_REGISTERED` carry
|
||||
`providerName` on the thrown `CurrencyProviderError`, so callers writing
|
||||
cross-cutting error handlers (audit logs, telemetry) can structure on the
|
||||
discriminant rather than parsing the message string.
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
CurrencyProvider,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
} from "@suaveplan/currency";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { ApiKeyConfig, NoKeyConfig } from "../types/configs.js";
|
||||
import { CurrencyProviderRegistry } from "./registry.js";
|
||||
|
||||
function fakeProvider(
|
||||
name: string,
|
||||
overrides?: Partial<CurrencyProvider>
|
||||
): CurrencyProvider {
|
||||
return {
|
||||
name,
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
async fetchRates(
|
||||
_options?: RateFetchOptions
|
||||
): Promise<RateFetchResult> {
|
||||
return { base: "USD", rates: {}, timestamp: 0 };
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CurrencyProviderRegistry", () => {
|
||||
it("registers and retrieves a provider", { timeout: 5000 }, () => {
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
const p = fakeProvider("fake-fx");
|
||||
|
||||
registry.register(p);
|
||||
|
||||
expect(registry.get("fake-fx")).toBe(p);
|
||||
expect(registry.has("fake-fx")).toBe(true);
|
||||
});
|
||||
|
||||
it("raises PROVIDER_NOT_REGISTERED when get is called on an unknown name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
|
||||
try {
|
||||
registry.get("never-registered");
|
||||
expect.fail("get() should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(
|
||||
CURRENCY_PROVIDER_CODES.PROVIDER_NOT_REGISTERED
|
||||
);
|
||||
expect(err.providerName).toBe("never-registered");
|
||||
}
|
||||
});
|
||||
|
||||
it("raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(fakeProvider("dup"));
|
||||
|
||||
try {
|
||||
registry.register(fakeProvider("dup"));
|
||||
expect.fail("register() should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(
|
||||
CURRENCY_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED
|
||||
);
|
||||
expect(err.providerName).toBe("dup");
|
||||
}
|
||||
});
|
||||
|
||||
it("has returns false for unregistered names", { timeout: 5000 }, () => {
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
expect(registry.has("nope")).toBe(false);
|
||||
registry.register(fakeProvider("yes"));
|
||||
expect(registry.has("yes")).toBe(true);
|
||||
expect(registry.has("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("list returns a sorted snapshot", { timeout: 5000 }, () => {
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(fakeProvider("zeta"));
|
||||
registry.register(fakeProvider("alpha"));
|
||||
registry.register(fakeProvider("mu"));
|
||||
|
||||
expect(registry.list()).toEqual(["alpha", "mu", "zeta"]);
|
||||
});
|
||||
|
||||
it("list returns an empty array for a fresh registry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
expect(registry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("unregister removes and reports correctly", { timeout: 5000 }, () => {
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(fakeProvider("alpha"));
|
||||
|
||||
expect(registry.unregister("alpha")).toBe(true);
|
||||
expect(registry.unregister("alpha")).toBe(false);
|
||||
expect(registry.has("alpha")).toBe(false);
|
||||
|
||||
try {
|
||||
registry.get("alpha");
|
||||
expect.fail("get() should have thrown after unregister");
|
||||
} catch (error) {
|
||||
expect((error as CurrencyProviderError).code).toBe(
|
||||
CURRENCY_PROVIDER_CODES.PROVIDER_NOT_REGISTERED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("unregister of an unknown name returns false without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
expect(registry.unregister("never-registered")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a defineCurrencyProvider-produced provider via ApiKeyConfig contract", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Type-only sanity check: a sibling package using ApiKeyConfig
|
||||
// for its constructor should produce a provider that the registry
|
||||
// accepts. We verify the constructor wiring compiles + the
|
||||
// registry-stored shape carries the expected name.
|
||||
function buildKeyedProvider(config: ApiKeyConfig): CurrencyProvider {
|
||||
const _fetch: FetchLike =
|
||||
config.fetch ?? (async () => new Response("{}"));
|
||||
return fakeProvider("keyed", {
|
||||
supportsCrypto: true,
|
||||
supportsFiat: false,
|
||||
});
|
||||
}
|
||||
function buildKeylessProvider(config: NoKeyConfig): CurrencyProvider {
|
||||
const _fetch: FetchLike =
|
||||
config.fetch ?? (async () => new Response("{}"));
|
||||
return fakeProvider("keyless");
|
||||
}
|
||||
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(buildKeyedProvider({ apiKey: "abc" }));
|
||||
registry.register(buildKeylessProvider({}));
|
||||
|
||||
expect(registry.list()).toEqual(["keyed", "keyless"]);
|
||||
expect(registry.get("keyed").supportsCrypto).toBe(true);
|
||||
expect(registry.get("keyless").supportsFiat).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { CurrencyProvider } from "@suaveplan/currency";
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* In-memory registry of `CurrencyProvider` instances keyed by `provider.name`.
|
||||
*
|
||||
* Sibling `@suaveplan/currency-provider-<name>` packages register their
|
||||
* provider with this registry; consumers (e.g. `CurrencyConverter` wiring,
|
||||
* future `AggregatorProvider` / `RateLimitedProvider` wrappers) resolve
|
||||
* providers by name without having to track concrete classes themselves.
|
||||
*
|
||||
* The registry stores providers by reference — no cloning, freezing, or
|
||||
* defensive copy. Providers are expected to be immutable shells around their
|
||||
* config.
|
||||
*/
|
||||
export class CurrencyProviderRegistry {
|
||||
private readonly entries: Map<string, CurrencyProvider> = new Map();
|
||||
|
||||
/**
|
||||
* Register a provider under its own `provider.name`. Throws
|
||||
* `CurrencyProviderError(PROVIDER_ALREADY_REGISTERED)` when the name is
|
||||
* already in use. Explicit replacement requires `unregister(name)` first.
|
||||
*/
|
||||
public register(provider: CurrencyProvider): void {
|
||||
const name = provider.name;
|
||||
if (this.entries.has(name)) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED,
|
||||
`Provider "${name}" is already registered`,
|
||||
{ providerName: name }
|
||||
);
|
||||
}
|
||||
this.entries.set(name, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a registered provider by name. Throws
|
||||
* `CurrencyProviderError(PROVIDER_NOT_REGISTERED)` when no provider has
|
||||
* been registered under `name`.
|
||||
*/
|
||||
public get(name: string): CurrencyProvider {
|
||||
const provider = this.entries.get(name);
|
||||
if (provider === undefined) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PROVIDER_NOT_REGISTERED,
|
||||
`No provider registered under name "${name}"`,
|
||||
{ providerName: name }
|
||||
);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/** Non-throwing probe — `true` when {@link get} would succeed. */
|
||||
public has(name: string): boolean {
|
||||
return this.entries.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted snapshot of currently registered provider names. Equal sets of
|
||||
* providers list identically regardless of insertion order, which keeps
|
||||
* downstream golden-file tests deterministic.
|
||||
*/
|
||||
public list(): readonly string[] {
|
||||
return [...this.entries.keys()].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a registration. Returns `true` when something was removed,
|
||||
* `false` when no provider was registered under `name`. Never throws.
|
||||
*/
|
||||
public unregister(name: string): boolean {
|
||||
return this.entries.delete(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# `configs` — Provider configuration types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the three configuration interfaces every concrete provider in
|
||||
`@suaveplan/currency-providers` consumes: `ApiKeyConfig` for providers that
|
||||
require an API key (Fixer, Open Exchange Rates, CoinMarketCap),
|
||||
`NoKeyConfig` for providers that talk to a public endpoint (ECB, Binance,
|
||||
CoinGecko, CryptoCompare, Kraken), and `HttpFetchOptions` for the per-call
|
||||
HTTP knobs callers can set on top. All three are named interfaces — never
|
||||
inline object literals in provider constructor signatures — so consumers
|
||||
can import the type, spread it into their own config bag, and let
|
||||
TypeScript narrow on the discriminating fields.
|
||||
|
||||
Every config inherits from `ProviderConfigBase`, which adds the two
|
||||
universal knobs every provider honours: an optional `baseUrl` (for proxy /
|
||||
staging / fixture endpoints) and an optional `fetch: FetchLike` (the
|
||||
injectable WHATWG-shape closure that lets tests pass a controlled stub
|
||||
without `vi.stubGlobal('fetch', ...)`). The `FetchLike` type comes from
|
||||
`@suaveplan/types` — `globalThis.fetch` is directly assignable to it.
|
||||
|
||||
## Features
|
||||
|
||||
- `ApiKeyConfig` — `apiKey: string` (required) + `baseUrl?` + `fetch?`
|
||||
- `NoKeyConfig` — `baseUrl?` + `fetch?` (no API key field)
|
||||
- `HttpFetchOptions` — `timeoutMs?` + `headers?` per-call knobs (reserved for
|
||||
the retry / timeout wrapper landing under `## Out of scope`)
|
||||
- All interfaces use `readonly` fields so config bags are immutable
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FixerProvider } from "@suaveplan/currency-providers";
|
||||
import type { ApiKeyConfig } from "@suaveplan/currency-providers";
|
||||
|
||||
const config: ApiKeyConfig = { apiKey: process.env.FIXER_KEY! };
|
||||
const fixer = new FixerProvider(config);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Inject a custom `fetch` for hermetic tests:
|
||||
|
||||
```ts
|
||||
import { EcbProvider } from "@suaveplan/currency-providers";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
const fakeFetch: FetchLike = async (input) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.endsWith("/eurofxref-daily.xml")) {
|
||||
return new Response(
|
||||
`<?xml version="1.0"?><Cube><Cube currency='USD' rate='1.087'/></Cube>`,
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
const ecb = new EcbProvider({
|
||||
baseUrl: "https://staging.example.com/eurofxref-daily.xml",
|
||||
fetch: fakeFetch,
|
||||
});
|
||||
const result = await ecb.fetchRates({ base: "EUR" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ProviderConfigBase` — `baseUrl?: string`, `fetch?: FetchLike`.
|
||||
Internal base shape every concrete config extends.
|
||||
- `interface ApiKeyConfig extends ProviderConfigBase` — adds
|
||||
`apiKey: string`. Providers that consume it raise
|
||||
`CURRENCY_PROVIDER.MISSING_CONFIG` if `apiKey` is empty.
|
||||
- `interface NoKeyConfig extends ProviderConfigBase` — empty extension; type
|
||||
alias name signals "no key required". Allows future per-no-key fields
|
||||
without touching the base.
|
||||
- `interface HttpFetchOptions` — `timeoutMs?: number`,
|
||||
`headers?: Readonly<Record<string, string>>`. Reserved for the retry /
|
||||
timeout wrapper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The base interface intentionally accepts `FetchLike` (structural,
|
||||
three-argument or two-argument doesn't matter) instead of the global
|
||||
`typeof fetch`. This lets callers pass tighter stubs — e.g. a closure
|
||||
that only handles a single URL pattern.
|
||||
- `HttpFetchOptions` exists in the public surface today even though no
|
||||
provider consumes it yet, because it is exported by the upstream
|
||||
`@suaveplan/currency-providers` package and consumer code that imported
|
||||
the type would break if it disappeared. Once `@suaveplan/retry` is wired
|
||||
in, every provider's `fetchRates` will accept it as a second argument.
|
||||
- Empty-extension `interface NoKeyConfig extends ProviderConfigBase {}` is
|
||||
intentional and not a TypeScript anti-pattern: it gives the no-key
|
||||
providers a distinct, importable type name so consumers can spread it
|
||||
into their own configs without coupling to the base name.
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Provider configuration shared by every provider — both the API-keyed and
|
||||
* the no-key variants extend it to add an optional injectable `fetch`. Tests
|
||||
* pass a controlled closure here; production passes nothing (the provider
|
||||
* falls back to `globalThis.fetch`).
|
||||
*/
|
||||
export interface ProviderConfigBase {
|
||||
/** Optional custom base URL override (proxying, staging, fixtures). */
|
||||
readonly baseUrl?: string;
|
||||
/**
|
||||
* Optional injectable `FetchLike`. Defaults to `globalThis.fetch`. The
|
||||
* structural `FetchLike` contract from `@suaveplan/types` matches WHATWG
|
||||
* `fetch` exactly so `globalThis.fetch` is directly assignable.
|
||||
*/
|
||||
readonly fetch?: FetchLike;
|
||||
}
|
||||
|
||||
/** Configuration for API-key-based providers (Fixer, OXR, CoinMarketCap). */
|
||||
export interface ApiKeyConfig extends ProviderConfigBase {
|
||||
/** API key for the provider. Required — empty string raises
|
||||
* `CURRENCY_PROVIDER.MISSING_CONFIG`. */
|
||||
readonly apiKey: string;
|
||||
}
|
||||
|
||||
/** Configuration for providers that do not require an API key (ECB,
|
||||
* Binance, CoinGecko, CryptoCompare, Kraken). */
|
||||
export interface NoKeyConfig extends ProviderConfigBase {}
|
||||
|
||||
/**
|
||||
* Common options for HTTP-based rate fetching. Currently informational —
|
||||
* timeouts and per-call headers will be honoured once `@suaveplan/retry`
|
||||
* integration lands (see proposal `## Out of scope`).
|
||||
*/
|
||||
export interface HttpFetchOptions {
|
||||
/** Request timeout in milliseconds (default: 10000). */
|
||||
readonly timeoutMs?: number;
|
||||
/** Custom headers to include on every request. */
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
}
|
||||
Reference in New Issue
Block a user