Files
2026-05-27 23:29:53 +01:00

167 lines
7.4 KiB
Markdown

# Design: @suaveplan/currency-provider-fixer
> Non-normative. The normative requirements live in
> `./specs/currency-provider-fixer/spec.md`. Cross-cutting architecture for
> the foundation-with-sibling-plugins family lives once, in
> `openspec/archive/2026-05-17-slim-currency-providers/design.md` — this
> document references it rather than duplicating it.
## Shape
```text
@suaveplan/currency-provider-fixer (this package)
└── FixerProvider — class implementing CurrencyProvider from @suaveplan/currency
├── construct: ApiKeyConfig from @suaveplan/currency-providers/types/configs
│ ├── apiKey: string (non-empty — raises MISSING_CONFIG otherwise)
│ ├── baseUrl?: string (default "https://data.fixer.io/api")
│ └── fetch?: FetchLike (default globalThis.fetch — from @suaveplan/types)
└── fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>
├── httpRequest({ ... }) from @suaveplan/currency-providers/http/http
│ non-2xx → CurrencyProviderError(HTTP_ERROR, status, providerName)
├── JSON.parse the response → typed envelope check
│ malformed → CurrencyProviderError(PARSE_ERROR)
└── upstream envelope dispatch:
{ success: true, base, rates, timestamp } → return result
{ success: false, error: { code: 105 } } → UNSUPPORTED_BASE
{ success: false, error: { code: 201|202 } } → UNKNOWN_CURRENCY
{ success: false, error: { code: any } } → UPSTREAM_ERROR
```
The package is a leaf — no `@suaveplan/*` runtime dep outside the slim core
and the `CurrencyProvider` contract that the slim core itself depends on.
## File layout
```text
src/
├── fixer/
│ ├── fixer.ts # FixerProvider class + upstream envelope types
│ ├── fixer.md # sibling docs (Purpose / Features / Usage / API / Notes)
│ ├── fixer.test.ts # behavior tests with recording FetchLike
│ └── index.ts # explicit named barrel
└── index.ts # package root barrel — explicit named exports
```
Single feature module — the package's whole contract is one class. The
folder structure mirrors every other sibling-package shape and keeps the
subpath-export count equal to the feature-module count (1 subpath → 1
feature). The slim-core `index.ts` is re-imported, never re-exported.
## Upstream envelope
Fixer.io always returns HTTP 200 — error states are signalled in the JSON
body via `{ success: false, error: { code, type, info } }`. The provider
must therefore:
1. Let `httpRequest` raise `HTTP_ERROR` on the rare genuine non-2xx (network
layer, gateway, CDN-level outage).
2. Otherwise `await response.json()` inside a `try` / `catch` and translate
any parse failure to `PARSE_ERROR`.
3. Narrow the parsed value: it must be an object with a boolean `success`
field; anything else is `PARSE_ERROR` again (defends against an unexpected
HTML redirect or an HTTP/2-pushed error page slipping through as 200).
4. On `success: true`, project to `{ base, rates, timestamp: data.timestamp * 1000 }`.
5. On `success: false`, branch on `error.code` per the table above. `error`
may be missing on malformed responses — treat absent `error` as
`UPSTREAM_ERROR` with `upstreamMessage = "<unknown>"`.
The `data.timestamp` field is Unix seconds per Fixer.io documentation; the
universal `RateFetchResult.timestamp` is milliseconds — multiply by 1000.
## Why upstream `error.code` and not `error.type`
Fixer.io's `error.type` is a kebab/snake-case string (`base_currency_access_restricted`,
`invalid_base_currency`, …) and is documented as stable across the v1 API.
`error.code` is the integer the dashboard uses (`105`, `201`, `202`, …). Both
are stable; the integer is one comparison and avoids string allocation. We
match on the integer and put the `error.info` human-readable message into
`upstreamMessage` so consumers debugging in production still see the upstream
narrative.
## Why validate `apiKey` at construction
Two reasons:
1. **Fast failure.** A test suite or boot wiring that misses the env var
should fail before any network call lands; throwing `MISSING_CONFIG` at
`new FixerProvider({ apiKey: "" })` surfaces the misconfiguration with a
discriminated error code that consumer fallbacks can match on.
2. **No wasted upstream call.** Fixer.io's free tier counts every request
against the monthly quota — including ones that fail authentication. A
construction-time guard means a missing key never burns quota.
The upstream skeleton allowed an empty `apiKey` because the constructor used
`config.apiKey` directly; this package preserves the exact same constructor
signature but adds the guard.
## Why `httpRequest` over a bare `fetch`
The slim core's `httpRequest` already:
- composes the caller's `AbortSignal` (from `RateFetchOptions.signal`) with
a timeout signal,
- raises `CurrencyProviderError(HTTP_ERROR)` with `providerName` /
`status` populated,
- forwards `init.headers` so callers can attach diagnostic headers.
Recreating any of that in this package would duplicate slim-core behaviour
and risk drift. Every sibling provider in the follow-up wave uses the same
helper.
## Tier purity
Universal-tier. Only `URLSearchParams`, `JSON.parse`, `Number.parseInt`,
`Object.freeze`, and `globalThis.fetch` (via `FetchLike` injection); no
`node:` / `bun:` / DOM imports. Runs unchanged under Node 18+, Bun, modern
browsers, and Web Workers.
## Test strategy
`src/fixer/fixer.test.ts` uses a recording `FetchLike` closure (no
`vi.mock`, no `vi.stubGlobal`) and exercises every requirement:
- Identity: `name`, `supportsFiat`, `supportsCrypto` flags.
- Construction: empty `apiKey` raises `MISSING_CONFIG`.
- Query construction: `access_key`, default `base=EUR`, custom `base`,
`symbols` list when `targets` provided.
- Default and custom `baseUrl`.
- Custom `fetch` is honoured.
- `success: true` path returns the canonical `RateFetchResult` with
`timestamp` in ms.
- `success: false` with `error.code === 105` → `UNSUPPORTED_BASE`.
- `success: false` with `error.code === 201` → `UNKNOWN_CURRENCY`.
- `success: false` with `error.code === 202` → `UNKNOWN_CURRENCY`.
- `success: false` with `error.code === 999` → `UPSTREAM_ERROR`.
- `success: false` with missing `error` → `UPSTREAM_ERROR`.
- Malformed JSON → `PARSE_ERROR`.
- Non-object JSON (`null`, `"string"`, `42`) → `PARSE_ERROR`.
- Non-2xx HTTP → `HTTP_ERROR` (via slim-core `httpRequest`).
- Caller `AbortSignal` is forwarded.
All tests import from `@suaveplan/testing/runner`. Every `it(...)` carries
`{ timeout: 5000 }`.
## Coverage / docs
100/100/100/100 istanbul thresholds set from day one in
`vitest.config.ts`. Every implementation `.ts` has a sibling `.md` with the
six mandatory sections. `docs:check` runs as part of the pre-publish gate.
## Migration narrative
Consumers that previously imported `FixerProvider` from
`@suaveplan/currency-providers` (i.e. before the 2026-05-17 slim) should
rewrite the import:
```ts
// Before
import { FixerProvider } from "@suaveplan/currency-providers";
// After
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
```
The constructor signature is identical (`new FixerProvider({ apiKey })`),
so no other call-site change is required. The slim-core's
`CurrencyProviderRegistry` accepts the new instance unchanged.