Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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.

View File

@@ -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);
}
});
});

View File

@@ -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;
}
}
}

View 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`.

View File

@@ -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");
});
});

View File

@@ -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);
}

View File

@@ -0,0 +1,2 @@
export type { DefineCurrencyProviderInput } from "./factory.js";
export { defineCurrencyProvider } from "./factory.js";

View 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.

View 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);
});
});

View 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]);
}

View File

@@ -0,0 +1,5 @@
export type { HttpRequestInput } from "./http.js";
export {
composeAbortSignal,
httpRequest,
} from "./http.js";

View 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";

View File

@@ -0,0 +1 @@
export { CurrencyProviderRegistry } from "./registry.js";

View 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.

View File

@@ -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);
});
});

View File

@@ -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);
}
}

View 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.

View File

@@ -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>>;
}