Initial Commit
This commit is contained in:
189
openspec/specs/currency-provider-open-exchange-rates/spec.md
Normal file
189
openspec/specs/currency-provider-open-exchange-rates/spec.md
Normal file
@@ -0,0 +1,189 @@
|
||||
<!-- 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 -->
|
||||
Reference in New Issue
Block a user