first 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.
|
||||
132
docs/money/finance/currency-providers/src/factory/factory.md
Normal file
132
docs/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`.
|
||||
155
docs/money/finance/currency-providers/src/http/http.md
Normal file
155
docs/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.
|
||||
128
docs/money/finance/currency-providers/src/registry/registry.md
Normal file
128
docs/money/finance/currency-providers/src/registry/registry.md
Normal file
@@ -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.
|
||||
98
docs/money/finance/currency-providers/src/types/configs.md
Normal file
98
docs/money/finance/currency-providers/src/types/configs.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user