Files
suaveplan/packages/money/finance/currency-provider-coingecko/src/coingecko/coingecko.ts
2026-05-27 23:47:53 +01:00

182 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
import type { FetchLike } from "@suaveplan/types";
import { COIN_IDS, getCoinGeckoId } from "../coin-ids/coin-ids.js";
const PROVIDER_NAME = "coingecko";
const DEFAULT_BASE_URL = "https://api.coingecko.com/api/v3";
const PRO_API_KEY_HEADER = "x-cg-pro-api-key";
const ALL_IDS = Object.values(COIN_IDS).join(",");
/**
* Configuration accepted by {@link CoinGeckoProvider}. Extends the shared
* `NoKeyConfig` (optional `baseUrl` + injectable `fetch`) and adds the
* optional Pro-tier `apiKey`. Both the free public tier and the Pro tier
* use the same endpoint shape, so the key is genuinely optional rather
* than mutually exclusive — omitting it issues a keyless request,
* supplying it attaches the `x-cg-pro-api-key` header.
*/
export interface CoinGeckoConfig extends NoKeyConfig {
/**
* Optional CoinGecko Pro API key. When supplied, every outbound
* request carries `x-cg-pro-api-key: <key>`. Absent → free public
* tier (10–30 calls/min, no key).
*/
readonly apiKey?: string;
}
type CoinGeckoResponseShape = Record<string, Record<string, number>>;
function isResponseShape(value: unknown): value is CoinGeckoResponseShape {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
for (const inner of Object.values(value)) {
if (
typeof inner !== "object" ||
inner === null ||
Array.isArray(inner)
) {
return false;
}
}
return true;
}
function assertTargetsKnown(targets: readonly string[] | undefined): void {
if (!targets) return;
for (const target of targets) {
if (getCoinGeckoId(target) === undefined) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
`${PROVIDER_NAME}: no CoinGecko id for target symbol "${target}"`,
{ providerName: PROVIDER_NAME }
);
}
}
}
async function parseJsonBody(response: Response): Promise<unknown> {
try {
return await response.json();
} catch (cause) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
`${PROVIDER_NAME}: failed to parse response body as JSON`,
{
providerName: PROVIDER_NAME,
cause: cause instanceof Error ? cause : undefined,
}
);
}
}
function buildRates(
data: CoinGeckoResponseShape,
vsCurrency: string,
targets: readonly string[] | undefined
): Record<string, number> {
const idToSymbol = new Map<string, string>();
for (const [symbol, id] of Object.entries(COIN_IDS)) {
idToSymbol.set(id, symbol);
}
const allRates: Record<string, number> = {};
for (const [id, prices] of Object.entries(data)) {
const symbol = idToSymbol.get(id);
const price = prices[vsCurrency];
if (symbol !== undefined && typeof price === "number" && price > 0) {
allRates[symbol] = 1 / price;
}
}
if (!targets) return allRates;
const filtered: Record<string, number> = {};
for (const target of targets) {
const value = allRates[target];
if (value !== undefined) {
filtered[target] = value;
}
}
return filtered;
}
/**
* CoinGecko crypto-rate provider. Implements the `CurrencyProvider`
* contract from `@suaveplan/currency` against the public `/simple/price`
* endpoint, routes every request through the shared `httpRequest`
* helper from `@suaveplan/currency-providers`, and raises typed
* `CurrencyProviderError` on every failure path.
*
* Both free and Pro tiers share the same URL shape; passing `apiKey`
* attaches the `x-cg-pro-api-key` header, omitting it issues a keyless
* public-tier request.
*/
export class CoinGeckoProvider implements CurrencyProvider {
public readonly name = PROVIDER_NAME;
public readonly supportsCrypto = true;
public readonly supportsFiat = true;
private readonly baseUrl: string;
private readonly fetchImpl: FetchLike;
private readonly headers: Readonly<Record<string, string>> | undefined;
public constructor(config: CoinGeckoConfig = {}) {
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
this.fetchImpl = config.fetch ?? (globalThis.fetch as FetchLike);
this.headers =
config.apiKey !== undefined
? Object.freeze({ [PRO_API_KEY_HEADER]: config.apiKey })
: undefined;
}
public async fetchRates(
options?: RateFetchOptions
): Promise<RateFetchResult> {
const base = options?.base ?? "USD";
const vsCurrency = base.toLowerCase();
assertTargetsKnown(options?.targets);
const url = `${this.baseUrl}/simple/price?ids=${ALL_IDS}&vs_currencies=${vsCurrency}`;
const init: RequestInit | undefined =
this.headers !== undefined
? { headers: { ...this.headers } }
: undefined;
const response = await httpRequest({
url,
fetch: this.fetchImpl,
providerName: PROVIDER_NAME,
...(init !== undefined ? { init } : {}),
...(options?.signal !== undefined
? { signal: options.signal }
: {}),
});
const body = await parseJsonBody(response);
if (!isResponseShape(body)) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
`${PROVIDER_NAME}: response body did not match Record<string, Record<string, number>>`,
{ providerName: PROVIDER_NAME }
);
}
return {
base,
rates: buildRates(body, vsCurrency, options?.targets),
timestamp: Date.now(),
};
}
}