8.8 KiB
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. (Seeopenspec/AGENTS.mdSection 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 injectedFetchLike - THEN the injected fetch is invoked exactly once with URL
https://openexchangerates.org/api/latest.json?app_id=<key>&base=USDand aninit.signalderived 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
RateFetchResulthastimestamp === 1_700_000_000_000andrates.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
ratesmap has exactly the keysEURandGBP
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 asfetchRates({ base: "EUR" }) - THEN a
CurrencyProviderErroris raised withcode === "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 asfetchRates({ base: "EUR" }) - THEN the injected fetch is invoked with
base=EURin 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
CurrencyProviderErroris raised withcode === "CURRENCY_PROVIDER.MISSING_CONFIG"andproviderName === "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)(norates, notimestamp) - THEN a
CurrencyProviderErroris raised withcode === "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
CurrencyProviderErroris raised withcode === "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
CurrencyProviderErroris raised withcode === "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
CurrencyProviderErroris raised withcode === "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.tsis executed - THEN the parsed
package.jsondependenciesmap contains exactly the four@suaveplan/*packages above plustslib, no DOM-only globals are imported fromsrc/, andsideEffects === false
Verified by: tests/dep-graph.test.ts::"declares the expected runtime dependencies" AND tests/dep-graph.test.ts::"declares sideEffects: false"