Initial Commit
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user