12 KiB
Capability Spec Delta: currency-provider-fixer (add-currency-provider-fixer)
Package: @suaveplan/currency-provider-fixer
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Provider identity flags
The package SHALL export a FixerProvider class that implements the
CurrencyProvider contract from @suaveplan/currency and exposes the stable
identity flags name = "fixer", supportsFiat = true, and
supportsCrypto = false.
Scenario: identity flags are stable
- WHEN a caller constructs
new FixerProvider({ apiKey: "x" }) - THEN
provider.name === "fixer",provider.supportsFiat === true, andprovider.supportsCrypto === false
Verified by: src/fixer/fixer.test.ts::"exposes the stable identity flags name/supportsFiat/supportsCrypto"
Requirement: API key required at construction
The constructor SHALL raise CurrencyProviderError with code
CURRENCY_PROVIDER.MISSING_CONFIG when config.apiKey is the empty string,
before any network request is issued.
Scenario: empty api key is rejected at construction
- WHEN a caller invokes
new FixerProvider({ apiKey: "" }) - THEN a
CurrencyProviderErroris thrown synchronously withcode === CURRENCY_PROVIDER.MISSING_CONFIGandproviderName === "fixer"
Verified by: src/fixer/fixer.test.ts::"raises MISSING_CONFIG when apiKey is empty at construction"
Requirement: Default base URL
The provider SHALL default the request base URL to
https://data.fixer.io/api and SHALL allow the caller to override it via
config.baseUrl.
Scenario: default base URL used when none provided
- WHEN
new FixerProvider({ apiKey: "k", fetch }).fetchRates()is called - THEN the recorded
fetchURL starts withhttps://data.fixer.io/api/latest?
Verified by: src/fixer/fixer.test.ts::"defaults the request base URL to data.fixer.io when no override is provided"
Scenario: custom base URL is honoured
- WHEN
new FixerProvider({ apiKey: "k", baseUrl: "https://proxy/api", fetch }).fetchRates()is called - THEN the recorded
fetchURL starts withhttps://proxy/api/latest?
Verified by: src/fixer/fixer.test.ts::"honours a custom baseUrl override"
Requirement: Query construction
fetchRates(options?) SHALL build the request query with access_key set
to the configured api key, base defaulting to EUR when
options.base is unset, and symbols set to a comma-joined list when
options.targets is a non-empty array. When options.targets is omitted or
empty, no symbols parameter SHALL be added.
Scenario: default base EUR and access_key are forwarded
- WHEN
fetchRates()is called without options - THEN the URL query contains
access_key=kandbase=EURand does not containsymbols
Verified by: src/fixer/fixer.test.ts::"defaults base to EUR and forwards access_key, omits symbols when targets is unset"
Scenario: targets list is comma-joined into symbols
- WHEN
fetchRates({ base: "USD", targets: ["EUR","GBP"] })is called - THEN the URL query contains
base=USDandsymbols=EUR%2CGBP
Verified by: src/fixer/fixer.test.ts::"forwards a custom base and comma-joins targets into the symbols param"
Requirement: Injectable FetchLike
The provider SHALL honour the optional config.fetch (FetchLike from
@suaveplan/types) and use it for every outbound request. When omitted, the
provider SHALL fall back to globalThis.fetch.
Scenario: custom fetch is invoked
- WHEN a recording
FetchLikeis passed viaconfig.fetchandfetchRates()is called - THEN the recorded fetch closure receives exactly one call for that invocation
Verified by: src/fixer/fixer.test.ts::"invokes the injected FetchLike instead of globalThis.fetch"
Requirement: Successful envelope projection
The provider SHALL resolve fetchRates with { base, rates, timestamp: data.timestamp * 1000 } when the upstream responds with
{ success: true, base, rates, timestamp }. The upstream timestamp is
Unix seconds; the returned timestamp SHALL be milliseconds since epoch.
Scenario: successful envelope is returned with millisecond timestamp
- WHEN the upstream returns
{ success: true, base: "EUR", rates: { GBP: 0.85 }, timestamp: 1_700_000_000 }with HTTP 200 - THEN
fetchRatesresolves to{ base: "EUR", rates: { GBP: 0.85 }, timestamp: 1_700_000_000_000 }
Verified by: src/fixer/fixer.test.ts::"returns the canonical RateFetchResult with the timestamp converted from seconds to milliseconds"
Requirement: UNSUPPORTED_BASE mapping
The provider SHALL raise CurrencyProviderError with code
CURRENCY_PROVIDER.UNSUPPORTED_BASE, providerName === "fixer", and the
upstream error.info carried on upstreamMessage whenever the upstream
responds with { success: false, error: { code: 105 } }
(base_currency_access_restricted — free tier rejecting a non-EUR base).
Scenario: free-tier base restriction maps to UNSUPPORTED_BASE
- WHEN the upstream returns
{ success: false, error: { code: 105, type: "base_currency_access_restricted", info: "Access Restricted" } } - THEN
fetchRates({ base: "USD" })rejects withcode === CURRENCY_PROVIDER.UNSUPPORTED_BASEandupstreamMessage === "Access Restricted"
Verified by: src/fixer/fixer.test.ts::"maps upstream error.code 105 to UNSUPPORTED_BASE"
Requirement: UNKNOWN_CURRENCY mapping
The provider SHALL raise CurrencyProviderError with code
CURRENCY_PROVIDER.UNKNOWN_CURRENCY and propagate the upstream
error.info on upstreamMessage whenever the upstream responds with
{ success: false, error: { code: 201 } } (invalid_base_currency) or
{ ..., error: { code: 202 } } (invalid_currency_codes).
Scenario: invalid base currency maps to UNKNOWN_CURRENCY
- WHEN the upstream returns
{ success: false, error: { code: 201, info: "invalid base" } } - THEN
fetchRates({ base: "ZZZ" })rejects withcode === CURRENCY_PROVIDER.UNKNOWN_CURRENCYandupstreamMessage === "invalid base"
Verified by: src/fixer/fixer.test.ts::"maps upstream error.code 201 to UNKNOWN_CURRENCY"
Scenario: invalid symbol codes maps to UNKNOWN_CURRENCY
- WHEN the upstream returns
{ success: false, error: { code: 202, info: "invalid symbols" } } - THEN
fetchRates({ targets: ["ZZZ"] })rejects withcode === CURRENCY_PROVIDER.UNKNOWN_CURRENCYandupstreamMessage === "invalid symbols"
Verified by: src/fixer/fixer.test.ts::"maps upstream error.code 202 to UNKNOWN_CURRENCY"
Requirement: UPSTREAM_ERROR fallback
The provider SHALL raise CurrencyProviderError with code
CURRENCY_PROVIDER.UPSTREAM_ERROR, providerName === "fixer", and
upstreamMessage populated with the upstream error.info (or the string
"<unknown>" when absent) whenever the upstream responds with
{ success: false } and any code that is not one of the discriminated
105 / 201 / 202 codes — including when the error field is missing
entirely.
Scenario: unknown upstream error code maps to UPSTREAM_ERROR
- WHEN the upstream returns
{ success: false, error: { code: 999, info: "rate limit exceeded" } } - THEN
fetchRates()rejects withcode === CURRENCY_PROVIDER.UPSTREAM_ERRORandupstreamMessage === "rate limit exceeded"
Verified by: src/fixer/fixer.test.ts::"maps any other success:false code to UPSTREAM_ERROR with upstreamMessage"
Scenario: missing error field still maps to UPSTREAM_ERROR
- WHEN the upstream returns
{ success: false }with noerrorfield - THEN
fetchRates()rejects withcode === CURRENCY_PROVIDER.UPSTREAM_ERRORandupstreamMessage === "<unknown>"
Verified by: src/fixer/fixer.test.ts::"maps success:false with no error field to UPSTREAM_ERROR with <unknown>"
Requirement: PARSE_ERROR on malformed JSON
The provider SHALL raise CurrencyProviderError with code
CURRENCY_PROVIDER.PARSE_ERROR and providerName === "fixer" whenever
the upstream body cannot be parsed as JSON, when the parsed value is not
a non-null object (for example null, a string, a number, or an array),
or when a success: true envelope is missing any required field.
Scenario: invalid JSON body raises PARSE_ERROR
- WHEN the upstream returns the literal body
"<html>"with HTTP 200 - THEN
fetchRates()rejects withcode === CURRENCY_PROVIDER.PARSE_ERROR
Verified by: src/fixer/fixer.test.ts::"raises PARSE_ERROR when the response body is not valid JSON"
Scenario: non-object JSON raises PARSE_ERROR
- WHEN the upstream returns the literal body
"null"with HTTP 200 - THEN
fetchRates()rejects withcode === CURRENCY_PROVIDER.PARSE_ERROR
Verified by: src/fixer/fixer.test.ts::"raises PARSE_ERROR when the parsed JSON is null or a non-object"
Scenario: success:true envelope with missing required fields raises PARSE_ERROR
- WHEN the upstream returns
{ success: true }withbase/rates/timestampabsent - THEN
fetchRates()rejects withcode === CURRENCY_PROVIDER.PARSE_ERROR
Verified by: src/fixer/fixer.test.ts::"raises PARSE_ERROR when a success envelope is missing required fields"
Requirement: HTTP_ERROR via slim-core httpRequest
The provider SHALL delegate HTTP transport to the slim core's httpRequest
helper so that non-2xx responses raise CurrencyProviderError with code
CURRENCY_PROVIDER.HTTP_ERROR, providerName === "fixer", and status
populated to the upstream HTTP status code.
Scenario: 5xx upstream raises HTTP_ERROR
- WHEN the upstream returns HTTP 503 with body
"Service Unavailable" - THEN
fetchRates()rejects withcode === CURRENCY_PROVIDER.HTTP_ERROR,status === 503, andproviderName === "fixer"
Verified by: src/fixer/fixer.test.ts::"raises HTTP_ERROR with status and providerName on a non-2xx response"
Requirement: Caller AbortSignal propagation
When options.signal is supplied to fetchRates, the provider SHALL
forward it through httpRequest so the underlying fetch is cancelled when
the caller aborts.
Scenario: aborting the caller signal rejects the fetchRates promise
- WHEN
fetchRates({ signal })is called with a signal whose controller is aborted before the upstream resolves - THEN the returned promise rejects with the caller's abort reason
Verified by: src/fixer/fixer.test.ts::"forwards the caller AbortSignal to the underlying fetch"
Requirement: No auto-register top-level side effect
The package SHALL NOT register an instance of FixerProvider with any
global registry at import time. Importing the package SHALL be free of
side effects and SHALL leave "sideEffects": false honoured for
tree-shaking.
Scenario: import has no observable side effect
- WHEN the package is imported via
await import("@suaveplan/currency-provider-fixer") - THEN no
CurrencyProviderRegistryinstance is constructed or mutated by the module, and the only public surface is theFixerProviderclass
Verified by: src/fixer/fixer.test.ts::"has no side effect at import time and exposes only FixerProvider"
Requirement: Universal-tier purity
The package SHALL be universal-tier — it SHALL NOT import any
node: / bun: / DOM-only module, and SHALL run unchanged under Node 18+,
Bun, modern browsers, and Web Workers.
Scenario: no banned imports in source
- WHEN the source tree under
src/is scanned - THEN zero matches are found for
from "node:,from "bun:,from "fs",from "path", or any DOM-only global
Verified by: src/fixer/fixer.test.ts::"imports only universal-tier modules — no node:, bun:, fs, or DOM-only globals"