Initial Commit
This commit is contained in:
299
openspec/specs/currency-provider-fixer/spec.md
Normal file
299
openspec/specs/currency-provider-fixer/spec.md
Normal file
@@ -0,0 +1,299 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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`,
|
||||
and `provider.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 `CurrencyProviderError` is thrown synchronously with
|
||||
`code === CURRENCY_PROVIDER.MISSING_CONFIG` and `providerName === "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 `fetch` URL starts with `https://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 `fetch` URL starts with `https://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=k` and `base=EUR` and does
|
||||
not contain `symbols`
|
||||
|
||||
**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=USD` and `symbols=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 `FetchLike` is passed via `config.fetch` and
|
||||
`fetchRates()` 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** `fetchRates` resolves 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 with
|
||||
`code === CURRENCY_PROVIDER.UNSUPPORTED_BASE` and
|
||||
`upstreamMessage === "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 with
|
||||
`code === CURRENCY_PROVIDER.UNKNOWN_CURRENCY` and
|
||||
`upstreamMessage === "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 with
|
||||
`code === CURRENCY_PROVIDER.UNKNOWN_CURRENCY` and
|
||||
`upstreamMessage === "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 with
|
||||
`code === CURRENCY_PROVIDER.UPSTREAM_ERROR` and
|
||||
`upstreamMessage === "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 no `error` field
|
||||
- **THEN** `fetchRates()` rejects with
|
||||
`code === CURRENCY_PROVIDER.UPSTREAM_ERROR` and
|
||||
`upstreamMessage === "<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 with `code === 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 with `code === 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 }` with `base` / `rates` /
|
||||
`timestamp` absent
|
||||
- **THEN** `fetchRates()` rejects with `code === 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 with
|
||||
`code === CURRENCY_PROVIDER.HTTP_ERROR`, `status === 503`, and
|
||||
`providerName === "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 `CurrencyProviderRegistry` instance is constructed or
|
||||
mutated by the module, and the only public surface is the `FixerProvider`
|
||||
class
|
||||
|
||||
**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"`
|
||||
Reference in New Issue
Block a user