167 lines
7.4 KiB
Markdown
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.
|