182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
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(),
|
||
};
|
||
}
|
||
}
|