Initial Commit
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# Add @suaveplan/currency-provider-coinmarketcap sibling provider
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-coinmarketcap`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new package (sibling of slimmed `@suaveplan/currency-providers`)
|
||||
|
||||
- **Spec delta**: ./specs/currency-provider-coinmarketcap/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
|
||||
`CoinMarketCap` sibling — a crypto rate source backed by the
|
||||
`pro-api.coinmarketcap.com` REST API and authenticated through the
|
||||
`X-CMC_PRO_API_KEY` HTTP header.
|
||||
|
||||
Consumers that already register sibling providers via
|
||||
`CurrencyProviderRegistry` (or instantiate them directly into
|
||||
`CurrencyConverter`) gain a CoinMarketCap option without installing eight
|
||||
other provider packages they do not wire in.
|
||||
|
||||
## What changes
|
||||
|
||||
### ADDED
|
||||
|
||||
- `@suaveplan/currency-provider-coinmarketcap` package at
|
||||
`packages/finance/currency-provider-coinmarketcap/`, version `0.1.0`,
|
||||
universal-tier (`sideEffects: false`).
|
||||
- `CoinMarketCapProvider` class implementing `CurrencyProvider` from
|
||||
`@suaveplan/currency`. Constructed from `ApiKeyConfig`
|
||||
(`apiKey`, optional `baseUrl`, optional injectable `FetchLike` from
|
||||
`@suaveplan/types`).
|
||||
- `supportsCrypto: true`, `supportsFiat: false`, no `supportsVirtual`.
|
||||
CoinMarketCap 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>/cryptocurrency/quotes/latest?symbol=<targets>&convert=<base>`
|
||||
via the shared `httpRequest()` helper from `@suaveplan/currency-providers`.
|
||||
The `X-CMC_PRO_API_KEY` header carries `config.apiKey`; the `Accept`
|
||||
header is `application/json`.
|
||||
- Constructor raises `CurrencyProviderError(MISSING_CONFIG)` when
|
||||
`config.apiKey` is empty or whitespace-only (consistent with the
|
||||
`ApiKeyConfig` contract documented in the slim core).
|
||||
- Response parsing raises `CurrencyProviderError(PARSE_ERROR)` when the
|
||||
payload is not valid JSON or the `data` map is missing / wrong shape.
|
||||
- Response parsing raises `CurrencyProviderError(UPSTREAM_ERROR)` when
|
||||
the upstream payload contains a non-null `status.error_code` field.
|
||||
- `fetchRates()` raises `CurrencyProviderError(UNKNOWN_CURRENCY)` when
|
||||
none of the requested target symbols resolve to a 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 exports for
|
||||
`./coinmarketcap/coinmarketcap`.
|
||||
|
||||
### KEPT (from slim core, consumed unchanged)
|
||||
|
||||
- `CurrencyProviderError` + `CURRENCY_PROVIDER_CODES` registry
|
||||
(`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `MISSING_CONFIG`,
|
||||
`UNKNOWN_CURRENCY`).
|
||||
- `ApiKeyConfig` shared config type.
|
||||
- `httpRequest()` shared HTTP helper.
|
||||
- `FetchLike` from `@suaveplan/types`.
|
||||
|
||||
## Source mapping
|
||||
|
||||
| Module | Source |
|
||||
|---|---|
|
||||
| `CoinMarketCapProvider` | `../suaveplan/packages/finance/currency-providers/src/crypto/coinmarketcap.ts` L1-L61 (upstream class) |
|
||||
| Error mapping (`MISSING_CONFIG` / `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) |
|
||||
| Symbol-based crypto query shape | upstream behaviour preserved (`symbol=<csv>&convert=<base>` against `/v1/cryptocurrency/quotes/latest`) |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/currency-provider-coinmarketcap`
|
||||
to enumerate.
|
||||
|
||||
- No current dependents (new sibling package; verified by
|
||||
`bunx turbo ls --filter=...@suaveplan/currency-provider-coinmarketcap`).
|
||||
|
||||
The package's only upstream dependencies are
|
||||
`@suaveplan/currency-providers` (slim core, registry / factory / HTTP
|
||||
helper / error registry / configs), `@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
|
||||
|
||||
- Fiat conversion endpoints. CoinMarketCap exposes
|
||||
`/v1/fiat/map` and `/v2/tools/price-conversion` but this sibling stays
|
||||
scoped to the crypto quotes endpoint (`supportsFiat: false`). A fiat
|
||||
follow-up can land separately if a real consumer needs it.
|
||||
- Historical-rate endpoints, OHLCV, WebSocket streams. 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.
|
||||
- CoinMarketCap-specific helper utilities (rank, market-cap weighting,
|
||||
metadata). Out of scope for a rates provider; a future
|
||||
`@suaveplan/coinmarketcap` package can expose those if needed.
|
||||
@@ -0,0 +1,222 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: currency-provider-coinmarketcap (add-currency-provider-coinmarketcap)
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-coinmarketcap`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
> No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: CoinMarketCapProvider — class implementing CurrencyProvider
|
||||
|
||||
The package SHALL export `CoinMarketCapProvider`, a class implementing
|
||||
`CurrencyProvider` from `@suaveplan/currency` with stable identity fields
|
||||
`name = "coinmarketcap"`, `supportsCrypto = true`, and `supportsFiat = false`.
|
||||
The constructor SHALL accept an `ApiKeyConfig` (from
|
||||
`@suaveplan/currency-providers`) carrying a non-empty `apiKey`, an optional
|
||||
`baseUrl` (default `https://pro-api.coinmarketcap.com/v1`), and an optional
|
||||
injectable `FetchLike` (default `globalThis.fetch`).
|
||||
|
||||
#### Scenario: Identity fields are populated
|
||||
|
||||
- **WHEN** a `CoinMarketCapProvider` is constructed with a valid `apiKey`
|
||||
- **THEN** `provider.name === "coinmarketcap"`, `provider.supportsCrypto === true`,
|
||||
`provider.supportsFiat === false`, and `provider.supportsVirtual` is undefined.
|
||||
|
||||
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"exposes stable identity fields"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Constructor rejects missing or empty API key
|
||||
|
||||
The package SHALL raise `CurrencyProviderError(MISSING_CONFIG)` from the
|
||||
constructor when `config.apiKey` is the empty string or contains only
|
||||
whitespace. The error SHALL carry `providerName: "coinmarketcap"`.
|
||||
|
||||
#### Scenario: Empty apiKey is rejected at construction time
|
||||
|
||||
- **WHEN** `new CoinMarketCapProvider({ apiKey: "" })` is called
|
||||
- **THEN** a `CurrencyProviderError` is thrown synchronously with
|
||||
`code === "CURRENCY_PROVIDER.MISSING_CONFIG"` and
|
||||
`providerName === "coinmarketcap"`.
|
||||
|
||||
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises MISSING_CONFIG when apiKey is empty"` AND `src/coinmarketcap/coinmarketcap.test.ts::"raises MISSING_CONFIG when apiKey is whitespace"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: fetchRates issues an authenticated quotes/latest request
|
||||
|
||||
`fetchRates(options?)` SHALL call
|
||||
`GET <baseUrl>/cryptocurrency/quotes/latest?symbol=<targets-csv>&convert=<base>`
|
||||
through the shared `httpRequest()` helper from
|
||||
`@suaveplan/currency-providers`. The request SHALL carry the headers
|
||||
`X-CMC_PRO_API_KEY: <apiKey>` and `Accept: application/json`. When
|
||||
`options.targets` is omitted, the request SHALL query a default symbol set
|
||||
covering the top crypto-by-market-cap (`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`).
|
||||
When `options.base` is omitted, the request SHALL convert into `USD`.
|
||||
`options.signal` SHALL be forwarded to `httpRequest()` so caller cancellation
|
||||
propagates to the underlying fetch.
|
||||
|
||||
#### Scenario: Builds the canonical URL and headers
|
||||
|
||||
- **WHEN** `fetchRates({ base: "EUR", targets: ["BTC", "ETH"] })` is called
|
||||
- **THEN** the injected fetch receives a `GET` to
|
||||
`https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC,ETH&convert=EUR`
|
||||
with `X-CMC_PRO_API_KEY` and `Accept: application/json` headers.
|
||||
|
||||
#### Scenario: Defaults targets to the top-10 symbol set and base to USD
|
||||
|
||||
- **WHEN** `fetchRates()` is called with no options
|
||||
- **THEN** the request URL contains `symbol=BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`
|
||||
and `convert=USD`.
|
||||
|
||||
#### Scenario: Forwards caller AbortSignal
|
||||
|
||||
- **WHEN** the caller aborts the signal passed via `options.signal`
|
||||
- **THEN** the underlying fetch rejects with the abort reason and
|
||||
`fetchRates` rejects.
|
||||
|
||||
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"issues an authenticated GET to /cryptocurrency/quotes/latest"` AND `src/coinmarketcap/coinmarketcap.test.ts::"defaults to USD + top-10 symbols when no options are given"` AND `src/coinmarketcap/coinmarketcap.test.ts::"forwards the caller AbortSignal to the injected fetch"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Non-2xx HTTP responses surface as HTTP_ERROR
|
||||
|
||||
The package SHALL surface non-2xx responses as
|
||||
`CurrencyProviderError(HTTP_ERROR)` (raised by the shared `httpRequest()`
|
||||
helper, with `status` populated and `providerName: "coinmarketcap"`).
|
||||
|
||||
#### Scenario: 503 maps to HTTP_ERROR with status
|
||||
|
||||
- **WHEN** the upstream returns `503 Service Unavailable`
|
||||
- **THEN** `fetchRates()` rejects with `CurrencyProviderError` whose
|
||||
`code === "CURRENCY_PROVIDER.HTTP_ERROR"`, `status === 503`, and
|
||||
`providerName === "coinmarketcap"`.
|
||||
|
||||
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"surfaces non-2xx responses as HTTP_ERROR"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Malformed JSON or missing data map raises PARSE_ERROR
|
||||
|
||||
The package SHALL raise `CurrencyProviderError(PARSE_ERROR)` when the
|
||||
upstream payload is not valid JSON, when the parsed value is not an object,
|
||||
or when the `data` field is missing / not an object. The error SHALL carry
|
||||
`providerName: "coinmarketcap"`.
|
||||
|
||||
#### Scenario: Invalid JSON body raises PARSE_ERROR
|
||||
|
||||
- **WHEN** the upstream returns a 200 with body `"not-json"`
|
||||
- **THEN** `fetchRates()` rejects with `CurrencyProviderError`,
|
||||
`code === "CURRENCY_PROVIDER.PARSE_ERROR"`.
|
||||
|
||||
#### Scenario: Missing data field raises PARSE_ERROR
|
||||
|
||||
- **WHEN** the upstream returns `{ "status": { "error_code": null } }`
|
||||
- **THEN** `fetchRates()` rejects with `CurrencyProviderError`,
|
||||
`code === "CURRENCY_PROVIDER.PARSE_ERROR"`.
|
||||
|
||||
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises PARSE_ERROR when the body is not valid JSON"` AND `src/coinmarketcap/coinmarketcap.test.ts::"raises PARSE_ERROR when the data field is missing"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Upstream-reported errors surface as UPSTREAM_ERROR
|
||||
|
||||
When the upstream payload carries a non-null, non-zero `status.error_code`,
|
||||
the package SHALL raise `CurrencyProviderError(UPSTREAM_ERROR)` carrying the
|
||||
upstream `status.error_message` (when present) on the `upstreamMessage`
|
||||
discriminant and `providerName: "coinmarketcap"`.
|
||||
|
||||
#### Scenario: Upstream error_code populated
|
||||
|
||||
- **WHEN** the upstream returns
|
||||
`{ "status": { "error_code": 1006, "error_message": "Insufficient credits" } }`
|
||||
- **THEN** `fetchRates()` rejects with `CurrencyProviderError`,
|
||||
`code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`, and
|
||||
`upstreamMessage === "Insufficient credits"`.
|
||||
|
||||
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises UPSTREAM_ERROR when status.error_code is populated"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: No resolved symbols raises UNKNOWN_CURRENCY
|
||||
|
||||
When the upstream payload contains a valid `data` map but none of the
|
||||
requested target symbols resolve to a quote in the configured base
|
||||
currency, the package SHALL raise
|
||||
`CurrencyProviderError(UNKNOWN_CURRENCY)` carrying
|
||||
`providerName: "coinmarketcap"`.
|
||||
|
||||
#### Scenario: Data map empty for requested symbols
|
||||
|
||||
- **WHEN** the upstream returns `{ "data": {} }` for the requested symbols
|
||||
- **THEN** `fetchRates()` rejects with `CurrencyProviderError`,
|
||||
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`.
|
||||
|
||||
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"raises UNKNOWN_CURRENCY when no requested symbol resolved"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Resolved rates are inverted to base-per-unit form
|
||||
|
||||
CoinMarketCap returns `1 crypto = X fiat`; the package SHALL invert each
|
||||
returned price into `1 fiat = (1 / price) crypto` and build the
|
||||
`RateFetchResult.rates` map keyed by symbol. The result's `base` SHALL
|
||||
match the requested `base`; `timestamp` SHALL be set to `Date.now()` at
|
||||
parse time.
|
||||
|
||||
#### Scenario: Returned price is inverted
|
||||
|
||||
- **WHEN** the upstream returns
|
||||
`{ "data": { "BTC": { "quote": { "USD": { "price": 50000 } } } } }`
|
||||
for `fetchRates({ base: "USD", targets: ["BTC"] })`
|
||||
- **THEN** the result has `base === "USD"`, `rates.BTC === 1 / 50000`,
|
||||
and a numeric `timestamp`.
|
||||
|
||||
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"inverts price into base-per-unit rate"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Custom baseUrl and injected fetch are honoured
|
||||
|
||||
The package SHALL honour the optional `baseUrl` and `fetch` fields of
|
||||
`ApiKeyConfig`. When `baseUrl` is set, the request URL SHALL use it as
|
||||
prefix. When `fetch` is set, that closure SHALL be used in place of
|
||||
`globalThis.fetch`. When `fetch` is omitted, `globalThis.fetch` SHALL be
|
||||
the fallback.
|
||||
|
||||
#### Scenario: Custom baseUrl prefixes the request
|
||||
|
||||
- **WHEN** the provider is constructed with `baseUrl: "https://sandbox-api.coinmarketcap.com/v1"`
|
||||
- **THEN** the request URL starts with `https://sandbox-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest`.
|
||||
|
||||
#### Scenario: Omitted fetch falls back to globalThis.fetch
|
||||
|
||||
- **WHEN** the provider is constructed without a `fetch` field and
|
||||
`globalThis.fetch` is temporarily replaced with a recording stub
|
||||
- **THEN** the recording stub receives the call.
|
||||
|
||||
**Verified by:** `src/coinmarketcap/coinmarketcap.test.ts::"honours a custom baseUrl"` AND `src/coinmarketcap/coinmarketcap.test.ts::"falls back to globalThis.fetch when fetch is not injected"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Universal-tier purity and packaging
|
||||
|
||||
The package SHALL be universal-tier with `sideEffects: false`, no
|
||||
`node:` / `bun:` / DOM imports in production source, explicit named
|
||||
barrels (no `export *` in non-root `index.ts`), test files importing
|
||||
runner primitives only from `@suaveplan/testing/runner`, and 100/100/100/100
|
||||
istanbul coverage thresholds. Public subpath exports SHALL include `.`
|
||||
and `./coinmarketcap/coinmarketcap`.
|
||||
|
||||
#### Scenario: Subpath exports tree-shake the provider class
|
||||
|
||||
- **WHEN** a consumer imports
|
||||
`from "@suaveplan/currency-provider-coinmarketcap/coinmarketcap/coinmarketcap"`
|
||||
- **THEN** the import resolves to the provider module and the rest of
|
||||
the package tree-shakes away.
|
||||
|
||||
**Verified by:** `package.json::exports` AND the §13 pre-ship gate (`bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-coinmarketcap` + `bunx turbo test:coverage`).
|
||||
@@ -0,0 +1,15 @@
|
||||
# Tasks — add-currency-provider-coinmarketcap
|
||||
|
||||
- [x] Scaffold `packages/finance/currency-provider-coinmarketcap/` (package.json `0.1.0`, tsconfig, vite, vitest, README, polyfills setup).
|
||||
- [x] Implement `src/coinmarketcap/coinmarketcap.ts` — `CoinMarketCapProvider` class implementing `CurrencyProvider`, constructor accepts `ApiKeyConfig`, validates `apiKey` non-empty (`MISSING_CONFIG`), exposes `name = "coinmarketcap"`, `supportsCrypto = true`, `supportsFiat = false`.
|
||||
- [x] Wire `fetchRates(options?)` through the shared `httpRequest()` helper with `X-CMC_PRO_API_KEY` + `Accept: application/json` headers; forward caller `signal`.
|
||||
- [x] Parse upstream JSON, invert prices (1/price) into the rate map, raise `PARSE_ERROR` on malformed shape, raise `UPSTREAM_ERROR` on `status.error_code`, raise `UNKNOWN_CURRENCY` when no requested symbol resolved.
|
||||
- [x] Sibling `.md` files for every implementation `.ts` (Purpose / Features / Basic / Advanced / API Reference / Implementation Notes — ≥200 words excluding code).
|
||||
- [x] Tests in `src/coinmarketcap/coinmarketcap.test.ts` covering: happy path, default target list, custom base + targets, missing apiKey, HTTP error mapping, parse-error (invalid JSON, non-object data), upstream-error (status.error_code populated), unknown-currency (no symbol resolved), abort-signal forwarding, default `baseUrl`, default `globalThis.fetch` fallback. All `it()` blocks carry `{ timeout: 5000 }`.
|
||||
- [x] Runner imports from `@suaveplan/testing/runner` only (zero `from "vitest"` / `from "bun:test"`).
|
||||
- [x] Subpath exports for `.` and `./coinmarketcap/coinmarketcap`.
|
||||
- [x] Pre-publish gate green: `bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-coinmarketcap` AND `bunx turbo test:coverage --filter=@suaveplan/currency-provider-coinmarketcap` at 100/100/100/100.
|
||||
- [x] Banned-pattern scan zero; barrel-shape scan zero; runner-import scan zero; missing-`.md` scan zero.
|
||||
- [x] Refresh `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts`), `MISSING.yml` (`bun scripts/generate-missing.ts`), `STATUS.md`, `SUMMARY.md`.
|
||||
- [x] Run `bunx markdownlint-cli2 --fix`; verify clean.
|
||||
- [x] Fold `specs/currency-provider-coinmarketcap/spec.md` into `openspec/specs/currency-provider-coinmarketcap/spec.md` and archive change folder.
|
||||
Reference in New Issue
Block a user