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,181 @@
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(),
};
}
}