Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
# `CoinMarketCapProvider` — crypto-rates sibling provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CoinMarketCapProvider` is the
`@suaveplan/currency-providers` sibling that fetches cryptocurrency
spot prices from the CoinMarketCap professional REST API
(`pro-api.coinmarketcap.com`). It implements `CurrencyProvider` from
`@suaveplan/currency` so it slots directly into `CurrencyConverter`
either by direct instantiation (`new CurrencyConverter({ provider: new
CoinMarketCapProvider(...) })`) or by registration with
`CurrencyProviderRegistry`. The class exists so consumers that need
CoinMarketCap as a primary or fallback rate source install exactly one
provider package instead of pulling in the eight other sibling
providers they do not wire in.
The class is crypto-only — `supportsCrypto = true`, `supportsFiat =
false` — because the upstream `/cryptocurrency/quotes/latest` endpoint
is a crypto quotes feed; the request still accepts any fiat conversion
currency via the `convert` query parameter, so a USD-base, EUR-base, or
GBP-base call is equally valid. Consumers that need fiat rates should
register a fiat sibling alongside (`currency-provider-ecb`,
`currency-provider-fixer`, `currency-provider-open-exchange-rates`).
## Features
- Implements `CurrencyProvider` with stable identity (`name =
"coinmarketcap"`, `supportsCrypto = true`, `supportsFiat = false`).
- Constructor accepts `ApiKeyConfig` (`apiKey`, optional `baseUrl`,
optional injectable `FetchLike`); empty / whitespace `apiKey`
immediately raises `CurrencyProviderError(MISSING_CONFIG)`.
- `X-CMC_PRO_API_KEY` + `Accept: application/json` headers carried on
every request through the shared `httpRequest()` helper, so abort
signals, timeouts, and HTTP error mapping are uniform with every
other sibling provider.
- Default target list covers the top 10 cryptocurrencies by market cap
(`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`); callers can override
via `options.targets` to query any supported symbol set.
- Default `base` is `USD`; any fiat conversion currency supported by
CoinMarketCap is accepted via `options.base`.
- Prices are inverted from CoinMarketCap's `1 crypto = X fiat` form to
the converter's expected `1 fiat = (1 / X) crypto` form before being
written into `RateFetchResult.rates`.
- Typed error mapping — `HTTP_ERROR` (non-2xx), `PARSE_ERROR` (invalid
JSON / missing `data` field / non-object data), `UPSTREAM_ERROR`
(`status.error_code` populated), `UNKNOWN_CURRENCY` (no requested
symbol resolved), `MISSING_CONFIG` (empty `apiKey`) — every error
carries `providerName: "coinmarketcap"`.
- Universal — no `node:` / `bun:` / DOM imports; runs unchanged
under Node 18+, Bun, modern browsers, and Web Workers via the
injectable `FetchLike` seam.
## Usage: Basic
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
const provider = new CoinMarketCapProvider({
apiKey: process.env.CMC_API_KEY ?? "",
});
const converter = new CurrencyConverter({ provider });
const rate = await converter.getRate("BTC", "USD");
```
## Usage: Advanced
Inject a `FetchLike` for testing, point at the CoinMarketCap sandbox,
restrict the target list, and forward an `AbortSignal` for cancellation:
```ts
import type { FetchLike } from "@suaveplan/types";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
const recordingFetch: FetchLike = async (url, init) => {
console.log("→", url);
return await fetch(url, init);
};
const provider = new CoinMarketCapProvider({
apiKey: process.env.CMC_SANDBOX_KEY ?? "",
baseUrl: "https://sandbox-api.coinmarketcap.com/v1",
fetch: recordingFetch,
});
const registry = new CurrencyProviderRegistry();
registry.register(provider);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2_000);
try {
const result = await registry.get("coinmarketcap").fetchRates({
base: "EUR",
targets: ["BTC", "ETH", "SOL"],
signal: controller.signal,
});
console.log(result.rates);
} finally {
clearTimeout(timer);
}
```
## API Reference
### `class CoinMarketCapProvider implements CurrencyProvider`
- `readonly name: "coinmarketcap"`
- `readonly supportsCrypto: true`
- `readonly supportsFiat: false`
- `constructor(config: ApiKeyConfig)` — `config.apiKey` is required;
whitespace-only and empty strings raise
`CurrencyProviderError(MISSING_CONFIG)`. `config.baseUrl` defaults to
`https://pro-api.coinmarketcap.com/v1`. `config.fetch` defaults to
`globalThis.fetch`.
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
issues `GET <baseUrl>/cryptocurrency/quotes/latest?symbol=<csv>&convert=<base>`
through `@suaveplan/currency-providers`'s `httpRequest()`. Returns
`{ base, rates, timestamp }` with rates inverted into base-per-unit
form. Forwards `options.signal` to the underlying fetch.
### Error codes (all from `CURRENCY_PROVIDER_CODES`)
- `MISSING_CONFIG` — empty / whitespace `apiKey` at construction.
- `HTTP_ERROR` — non-2xx HTTP response (raised by `httpRequest()`).
- `PARSE_ERROR` — invalid JSON, non-object payload, or missing `data`.
- `UPSTREAM_ERROR` — payload `status.error_code` populated and non-zero.
- `UNKNOWN_CURRENCY` — none of the requested symbols resolved.
## Implementation Notes
- The default target list (`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`)
mirrors the upstream `../suaveplan/...coinmarketcap.ts` source —
these are the top ten cryptocurrencies by market cap and are the
same set every consumer of the upstream package received by default.
- Price inversion (`1 / price`) is applied per-symbol so a symbol with
a missing, non-numeric, or non-positive `price` is silently dropped
from the result. If the upstream returns `{ data: {} }` or every
symbol fails the numeric guard, the whole call raises
`UNKNOWN_CURRENCY` — the converter then falls back to its next
registered provider rather than caching a zero-entry result.
- `status.error_code` is treated as "present and non-zero" — many
CoinMarketCap success responses carry `status.error_code: 0`, which
is not a real error. The dedicated `assertNoUpstreamError` guard
preserves that distinction.
- The `parsePayload` step is intentionally lenient about the order of
validation: when both `status.error_code` and a missing `data` field
are present, the upstream error wins so consumers see the actionable
`UPSTREAM_ERROR` (and `upstreamMessage`) rather than a generic
`PARSE_ERROR`.
- All HTTP plumbing — abort-signal composition, timeout derivation,
non-2xx mapping to `HTTP_ERROR` — is delegated to
`@suaveplan/currency-providers`'s `httpRequest()`. The provider
never calls the injected `FetchLike` directly, so timeout / abort
behaviour is identical to every other sibling.

View File

@@ -0,0 +1,533 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import { CoinMarketCapProvider } from "./coinmarketcap.js";
interface FetchCall {
url: string;
init: RequestInit | undefined;
}
function recordingFetch(
handler: (call: FetchCall) => Promise<Response> | Response
): { fetch: FetchLike; calls: FetchCall[] } {
const calls: FetchCall[] = [];
const fetchImpl: FetchLike = async (
url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
const call: FetchCall = { url: String(url), init };
calls.push(call);
return await handler(call);
};
return { fetch: fetchImpl, calls };
}
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
const API_KEY = "test-key";
describe("CoinMarketCapProvider — identity", () => {
it("exposes stable identity fields", { timeout: 5000 }, () => {
const provider = new CoinMarketCapProvider({ apiKey: API_KEY });
expect(provider.name).toBe("coinmarketcap");
expect(provider.supportsCrypto).toBe(true);
expect(provider.supportsFiat).toBe(false);
expect(
(provider as CoinMarketCapProvider & { supportsVirtual?: boolean })
.supportsVirtual
).toBeUndefined();
});
});
describe("CoinMarketCapProvider — constructor validation", () => {
it("raises MISSING_CONFIG when apiKey is empty", { timeout: 5000 }, () => {
try {
new CoinMarketCapProvider({ apiKey: "" });
expect.fail("constructor should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.MISSING_CONFIG);
expect(err.providerName).toBe("coinmarketcap");
}
});
it("raises MISSING_CONFIG when apiKey is whitespace", {
timeout: 5000,
}, () => {
expect(() => new CoinMarketCapProvider({ apiKey: " " })).toThrow(
CurrencyProviderError
);
});
});
describe("CoinMarketCapProvider — fetchRates request shape", () => {
it("issues an authenticated GET to /cryptocurrency/quotes/latest", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({
data: {
BTC: { quote: { EUR: { price: 40000 } } },
ETH: { quote: { EUR: { price: 2000 } } },
},
status: { error_code: 0 },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
const result = await provider.fetchRates({
base: "EUR",
targets: ["BTC", "ETH"],
});
expect(result.base).toBe("EUR");
expect(result.rates.BTC).toBeCloseTo(1 / 40000);
expect(result.rates.ETH).toBeCloseTo(1 / 2000);
expect(typeof result.timestamp).toBe("number");
expect(calls.length).toBe(1);
const call = calls[0];
expect(call?.url).toBe(
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC,ETH&convert=EUR"
);
expect(call?.init?.method).toBe("GET");
const headers = call?.init?.headers as
| Record<string, string>
| undefined;
expect(headers?.["X-CMC_PRO_API_KEY"]).toBe(API_KEY);
expect(headers?.Accept).toBe("application/json");
});
it("defaults to USD + top-10 symbols when no options are given", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({
data: { BTC: { quote: { USD: { price: 50000 } } } },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await provider.fetchRates();
expect(calls[0]?.url).toBe(
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT&convert=USD"
);
});
it("defaults to top-10 symbols when targets is an empty array", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({
data: { BTC: { quote: { USD: { price: 50000 } } } },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await provider.fetchRates({ targets: [] });
expect(calls[0]?.url).toContain(
"symbol=BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT"
);
});
it("forwards the caller AbortSignal to the injected fetch", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const fetchImpl: FetchLike = async (
_url: string | URL | Request,
init?: RequestInit
): Promise<Response> =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(
init?.signal?.reason ??
new DOMException("aborted", "AbortError")
);
});
});
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch: fetchImpl,
});
const promise = provider.fetchRates({
signal: controller.signal,
});
controller.abort(new Error("caller cancelled"));
await expect(promise).rejects.toThrow(/caller cancelled/);
});
it("honours a custom baseUrl", { timeout: 5000 }, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({
data: { BTC: { quote: { USD: { price: 50000 } } } },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
baseUrl: "https://sandbox-api.coinmarketcap.com/v1",
fetch,
});
await provider.fetchRates({ targets: ["BTC"] });
expect(calls[0]?.url).toBe(
"https://sandbox-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC&convert=USD"
);
});
});
describe("CoinMarketCapProvider — globalThis.fetch fallback", () => {
const originalFetch = globalThis.fetch;
let stubCalled: { url: string } | undefined;
beforeEach(() => {
stubCalled = undefined;
globalThis.fetch = (async (
url: string | URL | Request,
_init?: RequestInit
): Promise<Response> => {
stubCalled = { url: String(url) };
return jsonResponse({
data: { BTC: { quote: { USD: { price: 50000 } } } },
});
}) as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("falls back to globalThis.fetch when fetch is not injected", {
timeout: 5000,
}, async () => {
const provider = new CoinMarketCapProvider({ apiKey: API_KEY });
const result = await provider.fetchRates({ targets: ["BTC"] });
expect(stubCalled?.url).toContain(
"/cryptocurrency/quotes/latest?symbol=BTC&convert=USD"
);
expect(result.rates.BTC).toBeCloseTo(1 / 50000);
});
});
describe("CoinMarketCapProvider — error mapping", () => {
it("surfaces non-2xx responses as HTTP_ERROR", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() =>
new Response("nope", {
status: 503,
statusText: "Down",
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
try {
await provider.fetchRates({ targets: ["BTC"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
expect(err.status).toBe(503);
expect(err.providerName).toBe("coinmarketcap");
}
});
it("raises PARSE_ERROR when the body is not valid JSON", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() =>
new Response("not-json", {
status: 200,
headers: { "content-type": "application/json" },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
try {
await provider.fetchRates({ targets: ["BTC"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.PARSE_ERROR
);
}
});
it("raises PARSE_ERROR when the body is JSON null", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() =>
new Response("null", {
status: 200,
headers: { "content-type": "application/json" },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await expect(
provider.fetchRates({ targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
});
});
it("raises PARSE_ERROR when the data field is missing", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({ status: { error_code: null } })
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await expect(
provider.fetchRates({ targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
});
});
it("raises PARSE_ERROR when the data field is JSON null", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse({ data: null }));
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await expect(
provider.fetchRates({ targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
});
});
it("raises PARSE_ERROR when the data field is not an object", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({ data: "not-an-object" })
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await expect(
provider.fetchRates({ targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
});
});
it("raises UPSTREAM_ERROR when status.error_code is populated", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
data: {},
status: {
error_code: 1006,
error_message: "Insufficient credits",
},
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
try {
await provider.fetchRates({ targets: ["BTC"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
expect(err.upstreamMessage).toBe("Insufficient credits");
expect(err.providerName).toBe("coinmarketcap");
}
});
it("raises UPSTREAM_ERROR over PARSE_ERROR when data is missing AND status has an error_code", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
status: { error_code: 401 },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
await expect(
provider.fetchRates({ targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UPSTREAM_ERROR without an upstreamMessage when error_message is missing", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
data: {},
status: { error_code: 500 },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
try {
await provider.fetchRates({ targets: ["BTC"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
expect(err.upstreamMessage).toBeUndefined();
}
});
it("treats status.error_code === 0 as success", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
data: { BTC: { quote: { USD: { price: 50000 } } } },
status: { error_code: 0, error_message: null },
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
const result = await provider.fetchRates({ targets: ["BTC"] });
expect(result.rates.BTC).toBeCloseTo(1 / 50000);
});
it("raises UNKNOWN_CURRENCY when no requested symbol resolved", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => jsonResponse({ data: {} }));
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
try {
await provider.fetchRates({ targets: ["DOESNOTEXIST"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY
);
}
});
});
describe("CoinMarketCapProvider — rate filtering", () => {
it("skips entries with missing, non-numeric, NaN, or non-positive prices", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
data: {
BTC: { quote: { USD: { price: 50000 } } },
ETH: { quote: { USD: { price: "2000" } } }, // string — dropped
SOL: { quote: { USD: { price: 0 } } }, // zero — dropped
XRP: { quote: { USD: { price: -1 } } }, // negative — dropped
ADA: { quote: { USD: { price: Number.NaN } } }, // NaN — dropped
DOT: { quote: { EUR: { price: 7 } } }, // wrong base — dropped
DOGE: { quote: {} }, // missing base — dropped
BNB: undefined, // undefined entry — dropped
USDT: { quote: { USD: { price: 1 } } },
},
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
const result = await provider.fetchRates({
base: "USD",
targets: [
"BTC",
"ETH",
"SOL",
"XRP",
"ADA",
"DOT",
"DOGE",
"BNB",
"USDT",
],
});
expect(Object.keys(result.rates).sort()).toEqual(["BTC", "USDT"]);
expect(result.rates.BTC).toBeCloseTo(1 / 50000);
expect(result.rates.USDT).toBe(1);
});
it("skips entries whose quote field is missing entirely", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
jsonResponse({
data: {
BTC: { quote: { USD: { price: 50000 } } },
LTC: {}, // entry exists but no quote at all
},
})
);
const provider = new CoinMarketCapProvider({
apiKey: API_KEY,
fetch,
});
const result = await provider.fetchRates({
base: "USD",
targets: ["BTC", "LTC"],
});
expect(Object.keys(result.rates)).toEqual(["BTC"]);
});
});

View File

@@ -0,0 +1,198 @@
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import {
type ApiKeyConfig,
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
} from "@suaveplan/currency-providers";
import type { FetchLike } from "@suaveplan/types";
const PROVIDER_NAME = "coinmarketcap";
const DEFAULT_BASE_URL = "https://pro-api.coinmarketcap.com/v1";
const DEFAULT_BASE = "USD";
const DEFAULT_TARGETS: readonly string[] = [
"BTC",
"ETH",
"SOL",
"USDT",
"USDC",
"BNB",
"XRP",
"ADA",
"DOGE",
"DOT",
];
interface CoinMarketCapQuote {
readonly price?: unknown;
}
interface CoinMarketCapEntry {
readonly quote?: Record<string, CoinMarketCapQuote | undefined>;
}
interface CoinMarketCapPayload {
readonly data?: Record<string, CoinMarketCapEntry | undefined>;
readonly status?: CoinMarketCapStatus;
}
interface CoinMarketCapStatus {
readonly error_code?: unknown;
readonly error_message?: unknown;
}
/**
* CoinMarketCap crypto-rates provider for `@suaveplan/currency`.
*
* Sibling of `@suaveplan/currency-providers`. Implements `CurrencyProvider`
* by issuing an authenticated `GET /cryptocurrency/quotes/latest` against
* `pro-api.coinmarketcap.com`. The `X-CMC_PRO_API_KEY` header carries
* `config.apiKey`. Identity flags are crypto-only (`supportsCrypto: true`,
* `supportsFiat: false`).
*/
export class CoinMarketCapProvider implements CurrencyProvider {
public readonly name = PROVIDER_NAME;
public readonly supportsCrypto = true;
public readonly supportsFiat = false;
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly fetchImpl: FetchLike | undefined;
public constructor(config: ApiKeyConfig) {
if (config.apiKey.trim() === "") {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.MISSING_CONFIG,
"coinmarketcap: apiKey is required",
{ providerName: PROVIDER_NAME }
);
}
this.apiKey = config.apiKey;
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
this.fetchImpl = config.fetch;
}
public async fetchRates(
options?: RateFetchOptions
): Promise<RateFetchResult> {
const base = options?.base ?? DEFAULT_BASE;
const targets =
options?.targets && options.targets.length > 0
? options.targets
: DEFAULT_TARGETS;
const symbolList = targets.join(",");
const url = `${this.baseUrl}/cryptocurrency/quotes/latest?symbol=${symbolList}&convert=${base}`;
const fetchImpl: FetchLike = this.fetchImpl ?? globalThis.fetch;
const response = await httpRequest({
url,
fetch: fetchImpl,
providerName: PROVIDER_NAME,
init: {
method: "GET",
headers: {
"X-CMC_PRO_API_KEY": this.apiKey,
Accept: "application/json",
},
},
...(options?.signal !== undefined
? { signal: options.signal }
: {}),
});
const payload = await parsePayload(response);
assertNoUpstreamError(payload.status);
const rates = collectRates(payload, base);
if (Object.keys(rates).length === 0) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
`coinmarketcap: no rates resolved for base ${base}`,
{ providerName: PROVIDER_NAME }
);
}
return { base, rates, timestamp: Date.now() };
}
}
async function parsePayload(response: Response): Promise<CoinMarketCapPayload> {
let raw: unknown;
try {
raw = await response.json();
} catch (cause) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
"coinmarketcap: response body is not valid JSON",
{ providerName: PROVIDER_NAME, cause }
);
}
if (raw === null || typeof raw !== "object") {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
"coinmarketcap: response body is not a JSON object",
{ providerName: PROVIDER_NAME }
);
}
const payload = raw as CoinMarketCapPayload;
if (!isValidDataMap(payload.data)) {
// Allow the upstream status check to raise UPSTREAM_ERROR first;
// only raise PARSE_ERROR when there is no usable upstream signal.
if (!hasUpstreamErrorCode(payload.status)) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
"coinmarketcap: response missing or malformed data field",
{ providerName: PROVIDER_NAME }
);
}
}
return payload;
}
function isValidDataMap(data: unknown): data is Record<string, unknown> {
return typeof data === "object" && data !== null;
}
function hasUpstreamErrorCode(
status: CoinMarketCapStatus | undefined
): boolean {
const code = status?.error_code;
return typeof code === "number" && code !== 0;
}
function assertNoUpstreamError(status: CoinMarketCapStatus | undefined): void {
if (!hasUpstreamErrorCode(status)) return;
const upstreamMessage =
typeof status?.error_message === "string"
? status.error_message
: undefined;
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
`coinmarketcap: upstream error_code ${String(status?.error_code)}`,
{
providerName: PROVIDER_NAME,
...(upstreamMessage !== undefined ? { upstreamMessage } : {}),
}
);
}
function collectRates(
payload: CoinMarketCapPayload,
base: string
): Record<string, number> {
const rates: Record<string, number> = {};
// By the time we reach here, `parsePayload` + `assertNoUpstreamError`
// have guaranteed `payload.data` is a non-null object — payloads with a
// missing `data` raise either `PARSE_ERROR` (no upstream signal) or
// `UPSTREAM_ERROR` (upstream signalled the failure).
const data = payload.data as Record<string, CoinMarketCapEntry | undefined>;
for (const [symbol, entry] of Object.entries(data)) {
const price = entry?.quote?.[base]?.price;
if (typeof price === "number" && Number.isFinite(price) && price > 0) {
rates[symbol] = 1 / price;
}
}
return rates;
}

View File

@@ -0,0 +1 @@
export { CoinMarketCapProvider } from "./coinmarketcap.js";

View File

@@ -0,0 +1 @@
export { CoinMarketCapProvider } from "./coinmarketcap/coinmarketcap.js";