Files
suaveplan/openspec/specs/currency-provider-open-exchange-rates/spec.md
2026-05-27 23:29:53 +01:00

190 lines
8.8 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: currency-provider-open-exchange-rates (add-currency-provider-open-exchange-rates)
**Package**: `@suaveplan/currency-provider-open-exchange-rates`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: OXR-001 — Implements CurrencyProvider with stable identity
The package SHALL export `OpenExchangeRatesProvider`, a class that satisfies
the `CurrencyProvider` contract from `@suaveplan/currency` with the static
identity fields `name === "open-exchange-rates"`, `supportsFiat === true`,
`supportsCrypto === false`, and `supportsVirtual` left unset (no virtual
support).
#### Scenario: identity fields are set after construction
- **WHEN** a caller constructs `new OpenExchangeRatesProvider({ apiKey: "k" })`
- **THEN** `.name === "open-exchange-rates"`, `.supportsFiat === true`, `.supportsCrypto === false`
**Verified by:** `src/provider/provider.test.ts::"exposes stable identity fields"`
---
### Requirement: OXR-002 — Routes /latest.json through slim-core httpRequest
The package SHALL fetch from `${baseUrl}/latest.json?app_id=<apiKey>&base=<base>`
via the `httpRequest()` helper exported by `@suaveplan/currency-providers`,
passing through any caller `signal` from `RateFetchOptions`.
#### Scenario: builds the canonical URL and forwards signal
- **WHEN** `fetchRates({ base: "USD", signal })` is called with an injected `FetchLike`
- **THEN** the injected fetch is invoked exactly once with URL `https://openexchangerates.org/api/latest.json?app_id=<key>&base=USD` and an `init.signal` derived from the caller signal
**Verified by:** `src/provider/provider.test.ts::"calls /latest.json with app_id and base, forwarding caller signal"`
---
### Requirement: OXR-003 — Maps upstream payload to RateFetchResult with ms timestamp
The package SHALL parse the OXR JSON payload `{ base, rates, timestamp }` and
return a `RateFetchResult` where `result.base === payload.base`,
`result.rates === payload.rates` (or its filtered subset per OXR-004), and
`result.timestamp === payload.timestamp * 1000` (OXR returns Unix seconds;
genesis exposes ms).
#### Scenario: timestamp is converted to milliseconds
- **WHEN** OXR returns `{ base: "USD", rates: { EUR: 0.91 }, timestamp: 1_700_000_000 }`
- **THEN** the returned `RateFetchResult` has `timestamp === 1_700_000_000_000` and `rates.EUR === 0.91`
**Verified by:** `src/provider/provider.test.ts::"converts OXR Unix-seconds timestamp to milliseconds"`
---
### Requirement: OXR-004 — Filters rates when options.targets is supplied
When `RateFetchOptions.targets` is supplied, the package SHALL return only
the rate entries whose currency code is in `targets`; codes absent from the
upstream payload raise `UNKNOWN_CURRENCY` (see OXR-009).
#### Scenario: only requested targets are returned
- **WHEN** `fetchRates({ targets: ["EUR", "GBP"] })` is called and upstream returns `{ EUR: 0.91, GBP: 0.79, JPY: 150 }`
- **THEN** the returned `rates` map has exactly the keys `EUR` and `GBP`
**Verified by:** `src/provider/provider.test.ts::"filters rates down to options.targets"`
---
### Requirement: OXR-005 — Rejects non-USD base on free tier with UNSUPPORTED_BASE
The package SHALL accept the free-tier constraint (USD base only) by default.
When `RateFetchOptions.base` is supplied AND it is not `"USD"` AND
`config.allowNonUsdBase` is not set to `true`, the package SHALL raise
`CurrencyProviderError(UNSUPPORTED_BASE)` before issuing the request. Paid
plans enable any base by passing `allowNonUsdBase: true` to the constructor.
#### Scenario: free-tier rejects non-USD base
- **WHEN** a provider constructed with `{ apiKey: "k" }` (allowNonUsdBase omitted) is called as `fetchRates({ base: "EUR" })`
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.UNSUPPORTED_BASE"`, `providerName === "open-exchange-rates"`, and no network request is issued
#### Scenario: paid-tier accepts non-USD base
- **WHEN** a provider constructed with `{ apiKey: "k", allowNonUsdBase: true }` is called as `fetchRates({ base: "EUR" })`
- **THEN** the injected fetch is invoked with `base=EUR` in the query string
**Verified by:** `src/provider/provider.test.ts::"raises UNSUPPORTED_BASE when free-tier caller requests non-USD base"` AND `src/provider/provider.test.ts::"honours allowNonUsdBase=true and forwards base to /latest.json"`
---
### Requirement: OXR-006 — Rejects empty apiKey with MISSING_CONFIG
The package SHALL raise `CurrencyProviderError(MISSING_CONFIG)` at construction
time when `config.apiKey` is the empty string. A non-empty string is the only
valid input.
#### Scenario: empty apiKey is rejected synchronously
- **WHEN** a caller invokes `new OpenExchangeRatesProvider({ apiKey: "" })`
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.MISSING_CONFIG"` and `providerName === "open-exchange-rates"`
**Verified by:** `src/provider/provider.test.ts::"raises MISSING_CONFIG when apiKey is empty"`
---
### Requirement: OXR-007 — Raises PARSE_ERROR on malformed upstream payload
The package SHALL validate the upstream payload shape; if `base`, `rates`, or
`timestamp` is missing or of the wrong runtime type, it SHALL raise
`CurrencyProviderError(PARSE_ERROR)` with `providerName` populated.
#### Scenario: payload missing required field
- **WHEN** the injected fetch returns `Response(JSON.stringify({ base: "USD" }), 200)` (no `rates`, no `timestamp`)
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.PARSE_ERROR"`
**Verified by:** `src/provider/provider.test.ts::"raises PARSE_ERROR when upstream payload is missing required fields"`
---
### Requirement: OXR-008 — Raises UPSTREAM_ERROR on 2xx error payload
The package SHALL detect the OXR error-payload shape (`{ error: true, status, message, description? }`)
returned with a 2xx HTTP status and raise `CurrencyProviderError(UPSTREAM_ERROR)`
with `upstreamMessage` set to the upstream `message` field and `status` set to
the upstream `status` field.
#### Scenario: 2xx error payload surfaces UPSTREAM_ERROR
- **WHEN** the injected fetch returns `Response(JSON.stringify({ error: true, status: 401, message: "invalid_app_id", description: "..." }), 200)`
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`, `upstreamMessage === "invalid_app_id"`, `status === 401`
**Verified by:** `src/provider/provider.test.ts::"raises UPSTREAM_ERROR when OXR returns 2xx error payload"`
---
### Requirement: OXR-009 — Raises UNKNOWN_CURRENCY when target absent from payload
When `RateFetchOptions.targets` includes a code that is NOT present in the
upstream `rates` map, the package SHALL raise
`CurrencyProviderError(UNKNOWN_CURRENCY)` rather than silently dropping the
target. The error message names the missing code.
#### Scenario: missing target raises UNKNOWN_CURRENCY
- **WHEN** `fetchRates({ targets: ["EUR", "ZZZ"] })` is called and upstream returns `{ EUR: 0.91 }`
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"` and message containing `"ZZZ"`
**Verified by:** `src/provider/provider.test.ts::"raises UNKNOWN_CURRENCY when options.targets contains a code absent from upstream payload"`
---
### Requirement: OXR-010 — Surfaces HTTP_ERROR for non-2xx via slim core
The package SHALL let `httpRequest()` surface `CurrencyProviderError(HTTP_ERROR)`
for non-2xx upstream responses. The provider does not re-wrap the error;
`providerName` is supplied to `httpRequest()` so the raised error carries it.
#### Scenario: non-2xx surfaces HTTP_ERROR with providerName
- **WHEN** the injected fetch returns `Response("", 503)`
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`, `status === 503`, `providerName === "open-exchange-rates"`
**Verified by:** `src/provider/provider.test.ts::"surfaces HTTP_ERROR with providerName on non-2xx upstream response"`
---
### Requirement: OXR-011 — Universal-tier purity and dependency invariants
The package SHALL be universal-tier — no Node-only or DOM-only globals are
referenced. Runtime dependencies are limited to `@suaveplan/currency`,
`@suaveplan/currency-providers`, `@suaveplan/error`, `@suaveplan/types`, and
`tslib`. The `package.json` declares `"sideEffects": false`.
#### Scenario: dep-graph invariant holds
- **WHEN** `tests/dep-graph.test.ts` is executed
- **THEN** the parsed `package.json` `dependencies` map contains exactly the four `@suaveplan/*` packages above plus `tslib`, no DOM-only globals are imported from `src/`, and `sideEffects === false`
**Verified by:** `tests/dep-graph.test.ts::"declares the expected runtime dependencies"` AND `tests/dep-graph.test.ts::"declares sideEffects: false"`
<!-- markdownlint-enable MD024 -->