Initial Commit
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user