Initial Commit
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
# Add @suaveplan/currency-provider-cryptocompare sibling provider
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-cryptocompare`
|
||||
**Status**: ARCHIVED
|
||||
**Type**: new package (sibling of slimmed `@suaveplan/currency-providers`)
|
||||
|
||||
- **Spec delta**: ./specs/currency-provider-cryptocompare/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
The 2026-05-17 `slim-currency-providers` refactor moved the nine concrete
|
||||
provider classes out of `@suaveplan/currency-providers` and committed each
|
||||
one to its own publishable sibling package. This change lands the
|
||||
`CryptoCompare` sibling — a crypto rate source backed by the
|
||||
`min-api.cryptocompare.com/data/pricemulti` REST endpoint.
|
||||
|
||||
CryptoCompare distinguishes itself from the keyed siblings (Fixer, OXR,
|
||||
CoinMarketCap) by being usable **fully keyless** — the free tier covers
|
||||
~100,000 calls per month without any auth. Callers that want the higher
|
||||
rate limit can supply an `apiKey` in the constructor config; the provider
|
||||
attaches it on every request via the `authorization: Apikey <key>` header
|
||||
per CryptoCompare's documented Apikey scheme.
|
||||
|
||||
## What changes
|
||||
|
||||
### ADDED
|
||||
|
||||
- `@suaveplan/currency-provider-cryptocompare` package at
|
||||
`packages/finance/currency-provider-cryptocompare/`, version `0.1.0`,
|
||||
universal-tier (`sideEffects: false`).
|
||||
- `CryptoCompareProvider` class implementing `CurrencyProvider` from
|
||||
`@suaveplan/currency`. Constructed from `CryptoCompareConfig` (extends
|
||||
`NoKeyConfig`: `apiKey?` + `baseUrl?` + injectable `FetchLike?`).
|
||||
- `supportsCrypto: true`, `supportsFiat: false`, no `supportsVirtual`.
|
||||
CryptoCompare is a crypto-rates source — fiat rates are out of scope
|
||||
for this sibling (consumers wanting fiat install `currency-provider-ecb`
|
||||
/ `currency-provider-fixer` / `currency-provider-open-exchange-rates`).
|
||||
- `fetchRates(options?)` calls
|
||||
`GET <baseUrl>/pricemulti?fsyms=<targets>&tsyms=<base>` via the shared
|
||||
`httpRequest()` helper from `@suaveplan/currency-providers`. The
|
||||
`authorization: Apikey <key>` header is attached **only when**
|
||||
`config.apiKey` is provided and non-whitespace.
|
||||
- 15 default crypto symbols (BTC, ETH, SOL, USDT, USDC, BNB, XRP, ADA,
|
||||
DOGE, DOT, AVAX, LINK, UNI, ATOM, LTC) when callers omit `targets` or
|
||||
pass an empty array. Default base currency: `"USD"`.
|
||||
- Response parsing raises `CurrencyProviderError(PARSE_ERROR)` when the
|
||||
payload is not valid JSON or is not a JSON object.
|
||||
- Response parsing raises `CurrencyProviderError(UPSTREAM_ERROR)` when
|
||||
the payload carries `Response: "Error"`. The `Message` field (or
|
||||
`"unknown upstream error"` when absent) is copied to
|
||||
`error.upstreamMessage`.
|
||||
- `fetchRates()` raises `CurrencyProviderError(UNKNOWN_CURRENCY)` when
|
||||
no requested symbol produces a finite positive quote in the configured
|
||||
base currency.
|
||||
- Non-2xx HTTP responses surface as `CurrencyProviderError(HTTP_ERROR)`
|
||||
(raised by the shared `httpRequest()` helper, with `status` populated).
|
||||
- README, sibling `.md` per implementation file, and subpath export for
|
||||
`./cryptocompare/cryptocompare`.
|
||||
|
||||
### KEPT (from slim core, consumed unchanged)
|
||||
|
||||
- `CurrencyProviderError` + `CURRENCY_PROVIDER_CODES` registry
|
||||
(`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`).
|
||||
- `NoKeyConfig` shared config type (extended with the optional `apiKey`).
|
||||
- `httpRequest()` shared HTTP helper.
|
||||
- `FetchLike` from `@suaveplan/types`.
|
||||
|
||||
## Source mapping
|
||||
|
||||
| Module | Source |
|
||||
|---|---|
|
||||
| `CryptoCompareProvider` | `../suaveplan/packages/finance/currency-providers/src/crypto/cryptocompare.ts` L1-L69 (upstream class) |
|
||||
| Error mapping (`PARSE_ERROR` / `UPSTREAM_ERROR` / `UNKNOWN_CURRENCY`) | genesis-original — upstream threw raw `Error`; the slim core's typed `CurrencyProviderError` registry replaces that |
|
||||
| `httpRequest` / `composeAbortSignal` plumbing | `@suaveplan/currency-providers/src/http/http.ts` (slim core, genesis-original) |
|
||||
| Optional API-key header (`authorization: Apikey <key>`) | CryptoCompare public documentation — Apikey auth scheme; not present in the upstream class (which ignored the key entirely) |
|
||||
| Inverted-price + zero-skip semantics | upstream behaviour preserved (`rates[symbol] = 1 / prices[base]` when `prices[base] > 0`) |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/currency-provider-cryptocompare`
|
||||
to enumerate.
|
||||
|
||||
- No current dependents (new sibling package; verified by
|
||||
`bunx turbo ls --filter=...@suaveplan/currency-provider-cryptocompare`).
|
||||
|
||||
The package's only upstream dependencies are
|
||||
`@suaveplan/currency-providers` (slim core, `httpRequest` / error registry
|
||||
/ `NoKeyConfig`), `@suaveplan/currency` (the `CurrencyProvider` contract),
|
||||
`@suaveplan/types` (`FetchLike`), `@suaveplan/error` (transitive), and
|
||||
`tslib`. None of those packages need to be touched by this change — the
|
||||
sibling consumes their public surfaces unchanged.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Historical-rate endpoints (`/v2/histoday`, `/v2/histohour`,
|
||||
`/v2/histominute`), OHLCV, WebSocket streaming. Same deferral as every
|
||||
other sibling provider — interface extension lands once
|
||||
`@suaveplan/currency`'s `CurrencyProvider` contract grows the slots.
|
||||
- Rate-limit and retry orchestration. Deferred to `@suaveplan/rate-limiter`
|
||||
and `@suaveplan/retry` integrations once those wrappers land — every
|
||||
sibling provider gains them uniformly through a single composition.
|
||||
- Multi-tsym ("multi-base") queries. `pricemulti` accepts a CSV `tsyms`
|
||||
list but the `CurrencyProvider` contract returns a single-base map;
|
||||
multi-base aggregation belongs in a future wrapper.
|
||||
- CryptoCompare-specific metadata endpoints (`coin/general`, social,
|
||||
news). Out of scope for a rates provider; a future
|
||||
`@suaveplan/cryptocompare` package can expose those if needed.
|
||||
- Fiat rates. CryptoCompare supports fiat tsyms but the sibling stays
|
||||
scoped to crypto (`supportsFiat: false`) per the slim-core sibling
|
||||
pattern; consumers needing fiat install one of the dedicated fiat
|
||||
siblings.
|
||||
@@ -0,0 +1,153 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: currency-provider-cryptocompare (currency-provider-cryptocompare)
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-cryptocompare`
|
||||
**Status**: ARCHIVED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: CryptoCompareProvider — public pricemulti rate fetch
|
||||
|
||||
The package SHALL export `CryptoCompareProvider` implementing
|
||||
`CurrencyProvider` from `@suaveplan/currency` with `name === "cryptocompare"`,
|
||||
`supportsCrypto === true`, `supportsFiat === false`. `fetchRates(options?)`
|
||||
SHALL call
|
||||
`GET <baseUrl>/pricemulti?fsyms=<targets>&tsyms=<base>` via the shared
|
||||
`httpRequest` helper from `@suaveplan/currency-providers`, defaulting
|
||||
`base` to `"USD"` and `targets` to the 15-symbol built-in list when
|
||||
`options.targets` is omitted or empty. Returned `rates` SHALL be the
|
||||
inverted spot price (`1 / price`) for every symbol whose quote in the
|
||||
requested `base` is a finite positive number; non-finite and non-positive
|
||||
quotes SHALL be skipped.
|
||||
|
||||
#### Scenario: defaults base to USD and inverts pricemulti payload
|
||||
|
||||
- **WHEN** `fetchRates()` is called against
|
||||
`{ BTC: { USD: 67000 }, ETH: { USD: 3500 }, FAKE: { USD: 0 } }`
|
||||
- **THEN** `result.base === "USD"`, `result.rates.BTC` ≈ `1 / 67000`,
|
||||
`result.rates.ETH` ≈ `1 / 3500`, and `result.rates.FAKE` is absent.
|
||||
|
||||
#### Scenario: honours custom baseUrl and explicit targets
|
||||
|
||||
- **WHEN** `fetchRates({ base: "EUR", targets: ["BTC", "ETH"] })` is called
|
||||
with `baseUrl: "https://staging.example.com/data"`
|
||||
- **THEN** the request URL is exactly
|
||||
`https://staging.example.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR`.
|
||||
|
||||
#### Scenario: empty targets array falls back to built-in defaults
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD", targets: [] })` is called
|
||||
- **THEN** the request URL `fsyms` parameter starts with
|
||||
`BTC,ETH,SOL,USDT`.
|
||||
|
||||
**Verified by:** `src/cryptocompare/cryptocompare.test.ts::"reports stable metadata identifying the provider"` AND `src/cryptocompare/cryptocompare.test.ts::"inverts pricemulti payload, skips zero prices, and defaults base to USD"` AND `src/cryptocompare/cryptocompare.test.ts::"honours custom baseUrl and explicit targets list"` AND `src/cryptocompare/cryptocompare.test.ts::"falls back to defaults when targets array is empty"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Optional API key attached via Apikey authorization header
|
||||
|
||||
The package SHALL accept an **optional** `apiKey` field on the constructor
|
||||
config. When `apiKey` is provided and non-whitespace, every outbound
|
||||
request SHALL carry an `authorization: Apikey <key>` header per
|
||||
CryptoCompare's documented Apikey auth scheme. When `apiKey` is omitted,
|
||||
`undefined`, or whitespace-only, the request SHALL be issued with no
|
||||
authorization header (keyless free-tier mode).
|
||||
|
||||
#### Scenario: keyless construction issues no authorization header
|
||||
|
||||
- **WHEN** `new CryptoCompareProvider({ fetch }).fetchRates(...)` runs
|
||||
- **THEN** the injected fetch sees `init?.headers === undefined`.
|
||||
|
||||
#### Scenario: keyed construction issues authorization: Apikey <key>
|
||||
|
||||
- **WHEN** `new CryptoCompareProvider({ fetch, apiKey: "secret-key" }).fetchRates(...)`
|
||||
runs
|
||||
- **THEN** the injected fetch sees
|
||||
`init.headers.authorization === "Apikey secret-key"`.
|
||||
|
||||
#### Scenario: whitespace-only apiKey is treated as absent
|
||||
|
||||
- **WHEN** `new CryptoCompareProvider({ fetch, apiKey: " " }).fetchRates(...)`
|
||||
runs
|
||||
- **THEN** the injected fetch sees `init?.headers === undefined`.
|
||||
|
||||
**Verified by:** `src/cryptocompare/cryptocompare.test.ts::"sends authorization header only when an apiKey is provided"` AND `src/cryptocompare/cryptocompare.test.ts::"treats whitespace-only apiKey as absent and omits the header"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Typed-error mapping for HTTP, parse, upstream, and unknown-currency failures
|
||||
|
||||
The package SHALL surface every failure as `CurrencyProviderError` from
|
||||
`@suaveplan/currency-providers` with `providerName === "cryptocompare"`.
|
||||
Non-2xx responses SHALL raise `HTTP_ERROR` (via the shared `httpRequest`
|
||||
helper, populating `status`). Invalid JSON bodies and non-object parsed
|
||||
payloads SHALL raise `PARSE_ERROR`. Payloads carrying `Response: "Error"`
|
||||
SHALL raise `UPSTREAM_ERROR` with `upstreamMessage` set to the upstream
|
||||
`Message` field, or to `"unknown upstream error"` when `Message` is
|
||||
absent. `fetchRates()` SHALL raise `UNKNOWN_CURRENCY` when no requested
|
||||
symbol resolves to a finite positive quote in the configured `base`.
|
||||
|
||||
#### Scenario: non-2xx response raises HTTP_ERROR
|
||||
|
||||
- **WHEN** the injected fetch returns HTTP 502
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`
|
||||
and `status === 502`.
|
||||
|
||||
#### Scenario: invalid JSON body raises PARSE_ERROR
|
||||
|
||||
- **WHEN** the body is `"<<not json>>"` with `content-type: application/json`
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.PARSE_ERROR"`.
|
||||
|
||||
#### Scenario: non-object payload (e.g. JSON null) raises PARSE_ERROR
|
||||
|
||||
- **WHEN** the body is `"null"` parsed as JSON
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.PARSE_ERROR"`.
|
||||
|
||||
#### Scenario: upstream error payload raises UPSTREAM_ERROR
|
||||
|
||||
- **WHEN** the payload is
|
||||
`{ Response: "Error", Message: "tsym XYZ is not available" }`
|
||||
- **THEN** the call rejects with
|
||||
`code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"` and `upstreamMessage`
|
||||
contains `"tsym XYZ"`.
|
||||
|
||||
#### Scenario: upstream error payload without Message uses default text
|
||||
|
||||
- **WHEN** the payload is `{ Response: "Error" }`
|
||||
- **THEN** the call rejects with
|
||||
`code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"` and
|
||||
`upstreamMessage === "unknown upstream error"`.
|
||||
|
||||
#### Scenario: empty result map raises UNKNOWN_CURRENCY
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD", targets: ["BTC"] })` is called and
|
||||
the payload is `{}`
|
||||
- **THEN** the call rejects with
|
||||
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`.
|
||||
|
||||
#### Scenario: every quote non-finite or non-positive raises UNKNOWN_CURRENCY
|
||||
|
||||
- **WHEN** the payload contains only `0`, `NaN`, and negative quotes
|
||||
- **THEN** the call rejects with
|
||||
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`.
|
||||
|
||||
**Verified by:** `src/cryptocompare/cryptocompare.test.ts::"raises HTTP_ERROR on non-2xx response"` AND `src/cryptocompare/cryptocompare.test.ts::"raises PARSE_ERROR when the response body is not valid JSON"` AND `src/cryptocompare/cryptocompare.test.ts::"raises PARSE_ERROR when the parsed payload is not an object"` AND `src/cryptocompare/cryptocompare.test.ts::"raises UPSTREAM_ERROR when payload carries Response='Error' with Message"` AND `src/cryptocompare/cryptocompare.test.ts::"raises UPSTREAM_ERROR with default message when Message field is absent"` AND `src/cryptocompare/cryptocompare.test.ts::"raises UNKNOWN_CURRENCY when no symbols resolve to a quote"` AND `src/cryptocompare/cryptocompare.test.ts::"raises UNKNOWN_CURRENCY when every quote is non-finite or non-positive"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Caller AbortSignal forwarded into shared httpRequest
|
||||
|
||||
The package SHALL forward `RateFetchOptions.signal` (when provided)
|
||||
through the shared `httpRequest` helper so caller-initiated aborts
|
||||
propagate to the underlying `FetchLike` call.
|
||||
|
||||
#### Scenario: caller signal reaches the injected fetch
|
||||
|
||||
- **WHEN** `fetchRates({ signal: controller.signal, ... })` is called with
|
||||
a recording `FetchLike`
|
||||
- **THEN** the recorded `init.signal` is exactly `controller.signal`.
|
||||
|
||||
**Verified by:** `src/cryptocompare/cryptocompare.test.ts::"forwards a caller AbortSignal through httpRequest"`
|
||||
@@ -0,0 +1,88 @@
|
||||
# Tasks — Add @suaveplan/currency-provider-cryptocompare
|
||||
|
||||
## Spec-completeness preflight (AGENTS.md §12a)
|
||||
|
||||
- [x] Numbered checklist of every `### Requirement` and `#### Scenario`
|
||||
drafted in `specs/currency-provider-cryptocompare/spec.md`
|
||||
(4 requirements, 11 scenarios).
|
||||
|
||||
## Package scaffolding
|
||||
|
||||
- [x] Create `packages/finance/currency-provider-cryptocompare/` with
|
||||
`package.json` (`0.1.0`, sibling deps on slim core / currency /
|
||||
types / error / tslib), `tsconfig.json`, `tsconfig.lib.json`,
|
||||
`vite.config.ts`, `vitest.config.ts`.
|
||||
- [x] Create `tests/setup/polyfills.ts` no-op shim mirroring sibling
|
||||
packages.
|
||||
|
||||
## Implementation
|
||||
|
||||
- [x] `src/cryptocompare/cryptocompare.ts` — `CryptoCompareConfig`
|
||||
(extends `NoKeyConfig` with optional `apiKey`), `CryptoCompareProvider`
|
||||
class implementing `CurrencyProvider`. Defaults: `name =
|
||||
"cryptocompare"`, `supportsCrypto = true`, `supportsFiat = false`,
|
||||
base `"USD"`, 15-symbol default `targets`.
|
||||
- [x] Optional `apiKey` attached via `authorization: Apikey <key>` header
|
||||
on every request when present and non-whitespace.
|
||||
- [x] Error mapping: `HTTP_ERROR` (shared `httpRequest`), `PARSE_ERROR`
|
||||
(invalid JSON / non-object payload), `UPSTREAM_ERROR`
|
||||
(`Response === "Error"`), `UNKNOWN_CURRENCY` (no positive quotes).
|
||||
- [x] `src/cryptocompare/index.ts` explicit-named barrel.
|
||||
- [x] `src/index.ts` explicit-named root barrel.
|
||||
|
||||
## Tests
|
||||
|
||||
- [x] `src/cryptocompare/cryptocompare.test.ts` — runner imports from
|
||||
`@suaveplan/testing/runner`, recording `FetchLike`, 14 cases
|
||||
covering: metadata, default invert + zero-skip + USD default,
|
||||
keyless vs keyed header, whitespace-only key treated as absent,
|
||||
custom `baseUrl` + explicit targets, empty `targets` falls back to
|
||||
defaults, `HTTP_ERROR`, `PARSE_ERROR` (invalid JSON), `PARSE_ERROR`
|
||||
(non-object payload), `UPSTREAM_ERROR` (with `Message`),
|
||||
`UPSTREAM_ERROR` (no `Message` → default message),
|
||||
`UNKNOWN_CURRENCY` (empty map), `UNKNOWN_CURRENCY` (every quote
|
||||
non-finite or non-positive), `AbortSignal` forwarding. Every test
|
||||
carries `{ timeout: 5000 }`.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [x] `src/cryptocompare/cryptocompare.md` sibling doc (≥200 words,
|
||||
Purpose / Features / Usage Basic / Usage Advanced / API Reference /
|
||||
Implementation Notes).
|
||||
- [x] Root `README.md` (install, quick start, modules link, error table).
|
||||
|
||||
## Pre-archive gates (AGENTS.md §13)
|
||||
|
||||
- [x] Step 0 — spec reconciliation: every `### Requirement` and
|
||||
`**Verified by:**` resolves to a real file + verbatim test name.
|
||||
- [x] Step 1 — `bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-cryptocompare`
|
||||
green.
|
||||
- [x] Step 2 — `bunx turbo test:coverage --filter=@suaveplan/currency-provider-cryptocompare`
|
||||
reports 100/100/100/100 via istanbul.
|
||||
- [x] Step 3 — `bunx turbo build --filter=@suaveplan/currency-provider-cryptocompare`
|
||||
green.
|
||||
- [x] Step 4 — banned-pattern grep returns zero.
|
||||
- [x] Step 4a — barrel-shape grep returns zero (`src/index.ts` exempt).
|
||||
- [x] Step 4b — test-runner-import grep returns zero.
|
||||
- [x] Step 5 — missing-`.md` grep returns zero.
|
||||
- [x] Step 6 — README present.
|
||||
- [x] Step 7 — bare-version grep returns zero.
|
||||
- [x] Step 8 — subpath-export count = 1 (`./cryptocompare/cryptocompare`).
|
||||
- [x] Step 9 — security-scan grep returns zero.
|
||||
- [x] Step 10 — no dependents (new package).
|
||||
|
||||
## Root-doc refresh (AGENTS.md §13b)
|
||||
|
||||
- [x] Re-run `bun scripts/folder-reorg/generate-manifest.ts` to refresh
|
||||
`MANIFEST.md`.
|
||||
- [x] Re-run `bun scripts/generate-missing.ts` to refresh `MISSING.yml`.
|
||||
- [x] Update `STATUS.md` row for `@suaveplan/currency-provider-cryptocompare`.
|
||||
- [x] Update `SUMMARY.md` row likewise.
|
||||
- [x] `bunx markdownlint-cli2 --fix` across touched root + change files.
|
||||
|
||||
## Archive (AGENTS.md §14)
|
||||
|
||||
- [x] Fold `specs/currency-provider-cryptocompare/spec.md` into
|
||||
`openspec/specs/currency-provider-cryptocompare/spec.md`.
|
||||
- [x] Move change folder to
|
||||
`openspec/archive/2026-05-17-add-currency-provider-cryptocompare/`.
|
||||
Reference in New Issue
Block a user