5.3 KiB
5.3 KiB
Design — @suaveplan/currency-provider-ecb
File layout
packages/finance/currency-provider-ecb/
package.json
README.md
tsconfig.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
tests/setup/polyfills.ts
src/
index.ts # explicit named barrel — exports EcbProvider
ecb/
index.ts # explicit named barrel
ecb.ts # EcbProvider class — ~200 lines
ecb.test.ts # vitest tests, runner-neutral imports
ecb.md # sibling docs
Module contract
EcbProvider consumes the slim core directly:
import { httpRequest, CurrencyProviderError, CURRENCY_PROVIDER_CODES }
from "@suaveplan/currency-providers";
import type { NoKeyConfig } from "@suaveplan/currency-providers";
import type {
CurrencyProvider, RateFetchOptions, RateFetchResult,
} from "@suaveplan/currency";
import type { FetchLike } from "@suaveplan/types";
FetchLike is imported from @suaveplan/types (not re-exported from the slim
core) per the user instruction and slim-core convention — the slim core itself
imports FetchLike from @suaveplan/types, so doing the same here keeps the
dep graph shallow and avoids a re-export hop.
Algorithm
- Resolve
baseUrl→ default"https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml". - Resolve
fetch→ defaultglobalThis.fetch. - Call
httpRequest({ url, fetch, providerName: "ecb", signal })— non-2xx raisesCurrencyProviderError(HTTP_ERROR)automatically. - Read response body via
response.text(). - Parse via the same regex upstream uses:
/currency='([A-Z]+)'\s+rate='([\d.]+)'/g.- If the parsed map is empty, raise
PARSE_ERROR(upstream silently returns an empty map; this sibling treats that as a parse failure to protect downstream caches).
- If the parsed map is empty, raise
- Determine requested base (
options?.base ?? "EUR"). - If requested base is
"EUR", runfilterTargets()and return. - Otherwise, look up the requested base in the EUR-quoted map. If absent,
raise
UNSUPPORTED_BASE(replaces upstream's plainError("ECB does not provide rate for X")). - Convert:
converted.EUR = 1 / baseRate, and for every other codecode !== requestedBase,converted[code] = rate / baseRate. RunfilterTargets()and return. filterTargets(result, targets)— iftargetsis provided, intersect. When the resultingratesmap is empty ANDtargetswas non-empty AND at least one target was requested, raiseUNKNOWN_CURRENCYso callers discover the misconfiguration instead of getting an empty success.
Error matrix
| Code | When raised | Test |
|---|---|---|
HTTP_ERROR |
non-2xx upstream response | ecb.test.ts::"raises HTTP_ERROR on a 5xx upstream response" |
PARSE_ERROR |
response body contains zero matching currency tags | ecb.test.ts::"raises PARSE_ERROR when the upstream payload has no currency tags" |
UNSUPPORTED_BASE |
requested non-EUR base is absent from the EUR-quoted feed | ecb.test.ts::"raises UNSUPPORTED_BASE when the requested base is missing from the feed" |
UNKNOWN_CURRENCY |
every entry in options.targets is absent from the resolved rate map |
ecb.test.ts::"raises UNKNOWN_CURRENCY when every requested target is absent" |
UPSTREAM_ERROR |
reserved — raised when the upstream is reachable, returns 2xx, but the body indicates a logical error (kept in the registry for future use; not exercised by the ECB feed which has no such error envelope). Code stays declared in the slim core as committed there; this package does not delete it. | — |
Rule §2c (never delete spec elements to satisfy a gate) applies — the ECB
feed format is stable and does not surface logical errors inside a 2xx,
so UPSTREAM_ERROR is not raised by this sibling. It remains in the slim
core's registry for other siblings.
Test strategy
- No
vi.mock/vi.stubGlobal. Tests construct aFetchLikeclosure via arecordingFetch(handler)helper, then inject it throughnew EcbProvider({ fetch }). This matches the slim core's ownhttp.test.tsand obeys CLAUDE rule 2 ("no mocks — real services only"). - Fixture XML mirrors the upstream test fixture verbatim (USD, GBP, JPY, CHF rates from 2026-02-27) so the parse + conversion paths are pinned to known-good numerical outputs.
- AbortSignal test uses a real
AbortControllerand a fetch closure that resolves on signal-abort, mirroring the slim core'shttp.test.tspattern. - Coverage target: 100/100/100/100 Istanbul (every branch — EUR base / non-EUR base / target filter / each error path / custom baseUrl / injected fetch / AbortSignal forward).
Subpath exports
| Export | Purpose |
|---|---|
. |
barrel — re-exports EcbProvider |
./ecb/ecb |
direct deep import for tree-shaking-strict consumers |
./package.json |
tooling |
Subpath count matches the single feature module (ecb) — no inflation.
Universal-tier purity
- No
node:imports. - No browser-only globals (
window,document). globalThis.fetch,Response,URLare universally available (Node 18+, Bun, browsers, Web Workers).Number.parseFloat,Object.entries,RegExpare ECMAScript intrinsics.
No polyfills required; tests/setup/polyfills.ts is an empty marker matching
the slim-core pattern.