117 lines
5.3 KiB
Markdown
117 lines
5.3 KiB
Markdown
# Design — @suaveplan/currency-provider-ecb
|
|
|
|
## File layout
|
|
|
|
```text
|
|
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:
|
|
|
|
```ts
|
|
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
|
|
|
|
1. Resolve `baseUrl` → default `"https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"`.
|
|
2. Resolve `fetch` → default `globalThis.fetch`.
|
|
3. Call `httpRequest({ url, fetch, providerName: "ecb", signal })` — non-2xx
|
|
raises `CurrencyProviderError(HTTP_ERROR)` automatically.
|
|
4. Read response body via `response.text()`.
|
|
5. 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).
|
|
6. Determine requested base (`options?.base ?? "EUR"`).
|
|
7. If requested base is `"EUR"`, run `filterTargets()` and return.
|
|
8. Otherwise, look up the requested base in the EUR-quoted map. If absent,
|
|
raise `UNSUPPORTED_BASE` (replaces upstream's plain `Error("ECB does not
|
|
provide rate for X")`).
|
|
9. Convert: `converted.EUR = 1 / baseRate`, and for every other code
|
|
`code !== requestedBase`, `converted[code] = rate / baseRate`. Run
|
|
`filterTargets()` and return.
|
|
10. `filterTargets(result, targets)` — if `targets` is provided, intersect.
|
|
When the resulting `rates` map is empty AND `targets` was non-empty AND at
|
|
least one target was requested, raise `UNKNOWN_CURRENCY` so 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 a `FetchLike` closure
|
|
via a `recordingFetch(handler)` helper, then inject it through
|
|
`new EcbProvider({ fetch })`. This matches the slim core's own
|
|
`http.test.ts` and 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 `AbortController` and a fetch closure that
|
|
resolves on signal-abort, mirroring the slim core's `http.test.ts` pattern.
|
|
- 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`, `URL` are universally available (Node 18+,
|
|
Bun, browsers, Web Workers).
|
|
- `Number.parseFloat`, `Object.entries`, `RegExp` are ECMAScript intrinsics.
|
|
|
|
No polyfills required; `tests/setup/polyfills.ts` is an empty marker matching
|
|
the slim-core pattern.
|