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,79 @@
# @suaveplan/currency-provider-coingecko - Universal CoinGecko sibling for @suaveplan/currency-providers. Exposes CoinGeckoProvider (free public tier + optional Pro-tier x-cg-pro-api-key) and the getCoinGeckoId symbol lookup; routes every request through the shared httpRequest helper and raises typed CurrencyProviderError on HTTP_ERROR / PARSE_ERROR / UPSTREAM_ERROR / UNKNOWN_CURRENCY.
Universal CoinGecko sibling for
[`@suaveplan/currency-providers`](../currency-providers/README.md). Implements
the `CurrencyProvider` contract from `@suaveplan/currency` against the
public `/simple/price` endpoint, with optional Pro-tier `apiKey` support
attached as `x-cg-pro-api-key` on every outbound request. Both free and
Pro tiers share the same URL shape, so upgrading is a single-field
config change.
<!-- begin:auto-docs -->
## Documentation tree
- [coin-ids](./src/coin-ids/coin-ids.md) — Owns the curated mapping from uppercase ticker symbol (`BTC`, `ETH`, …) to the opaque CoinGecko coin id (`bitcoin`, `et…
- [coin-ids](./src/coin-ids/coin-ids.md) — Owns the curated mapping from uppercase ticker symbol (`BTC`, `ETH`, …) to the opaque CoinGecko coin id (`bitcoin`, `et…
- [coingecko](./src/coingecko/coingecko.md) — CoinGecko sibling for `@suaveplan/currency-providers`. Implements the `CurrencyProvider` contract from `@suaveplan/curr…
- [coingecko](./src/coingecko/coingecko.md) — CoinGecko sibling for `@suaveplan/currency-providers`. Implements the `CurrencyProvider` contract from `@suaveplan/curr…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-coingecko
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko";
// Free public tier — no API key required.
const free = new CoinGeckoProvider();
// Pro tier — pass apiKey; the same provider attaches x-cg-pro-api-key.
const pro = new CoinGeckoProvider({
apiKey: process.env.COINGECKO_PRO_KEY,
baseUrl: "https://pro-api.coingecko.com/api/v3",
});
const registry = new CurrencyProviderRegistry();
registry.register(pro);
const converter = new CurrencyConverter({
provider: registry.get("coingecko"),
staleDurationMs: 60_000,
});
const rates = await registry
.get("coingecko")
.fetchRates({ base: "USD", targets: ["BTC", "ETH"] });
```
## Features
- `CoinGeckoProvider implements CurrencyProvider` — drops directly into
any `@suaveplan/currency` converter or `CurrencyProviderRegistry`.
- Truly optional `apiKey` — free tier requires no config; Pro tier
attaches `x-cg-pro-api-key` automatically.
- 16 curated cryptocurrencies (BTC, ETH, SOL, USDT, USDC, BNB, XRP, ADA,
DOGE, DOT, AVAX, MATIC, LINK, UNI, ATOM, LTC).
- Routes every request through the shared `httpRequest()` helper from
`@suaveplan/currency-providers` for abort-signal composition,
per-call timeouts, and typed HTTP failures.
- Proactive `UNKNOWN_CURRENCY` gate — unknown `options.targets` reject
before the HTTP call is issued.
- Typed `CurrencyProviderError` on every failure path: `HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`.
- Universal — zero browser / Node-only APIs. Works in Node 18+, Bun,
modern browsers, and Web Workers.
## Modules
- [`src/coingecko/coingecko.md`](./src/coingecko/coingecko.md) —
`CoinGeckoProvider` class + `CoinGeckoConfig` type.
- [`src/coin-ids/coin-ids.md`](./src/coin-ids/coin-ids.md) — `COIN_IDS`
curated map + `getCoinGeckoId(symbol)` lookup.

View File

@@ -0,0 +1,74 @@
{
"name": "@suaveplan/currency-provider-coingecko",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal CoinGecko sibling for @suaveplan/currency-providers. Exposes CoinGeckoProvider (free public tier + optional Pro-tier x-cg-pro-api-key) and the getCoinGeckoId symbol lookup; routes every request through the shared httpRequest helper and raises typed CurrencyProviderError on HTTP_ERROR / PARSE_ERROR / UPSTREAM_ERROR / UNKNOWN_CURRENCY.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./coingecko/coingecko": {
"import": {
"types": "./dist/coingecko/coingecko.d.ts",
"default": "./dist/coingecko/coingecko.js"
}
},
"./coin-ids/coin-ids": {
"import": {
"types": "./dist/coin-ids/coin-ids.d.ts",
"default": "./dist/coin-ids/coin-ids.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/currency": "workspace:^",
"@suaveplan/currency-providers": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,88 @@
# coin-ids
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the curated mapping from uppercase ticker symbol (`BTC`, `ETH`, …) to
the opaque CoinGecko coin id (`bitcoin`, `ethereum`, …) that the
`/simple/price` endpoint requires. Without this translation step the
provider would have to either fall back to CoinGecko's heavyweight
`/coins/list` discovery endpoint (~10k entries, defeats tree-shaking) or
ask every caller to learn CoinGecko's id convention — neither is
acceptable for a slim sibling.
## Features
- Frozen `COIN_IDS` map covering the top 16 cryptos by market cap at
landing (BTC, ETH, SOL, USDT, USDC, BNB, XRP, ADA, DOGE, DOT, AVAX,
MATIC, LINK, UNI, ATOM, LTC).
- Case-insensitive `getCoinGeckoId(symbol)` lookup returning `string | undefined`.
- Zero runtime dependencies; pure data + a one-line lookup function.
## Usage: Basic
```ts
import { getCoinGeckoId } from "@suaveplan/currency-provider-coingecko/coin-ids/coin-ids";
getCoinGeckoId("BTC"); // "bitcoin"
getCoinGeckoId("eth"); // "ethereum" — case-insensitive
getCoinGeckoId("ZZZ"); // undefined — unknown symbol
```
## Usage: Advanced
The provider uses `getCoinGeckoId` as the proactive `UNKNOWN_CURRENCY`
gate. Consumers can mirror the same pattern when composing their own
filters before hitting the registry:
```ts
import {
COIN_IDS,
getCoinGeckoId,
} from "@suaveplan/currency-provider-coingecko/coin-ids/coin-ids";
function partitionSupported(symbols: readonly string[]): {
supported: string[];
unknown: string[];
} {
const supported: string[] = [];
const unknown: string[] = [];
for (const symbol of symbols) {
if (getCoinGeckoId(symbol) !== undefined) {
supported.push(symbol);
} else {
unknown.push(symbol);
}
}
return { supported, unknown };
}
// Iterate the full map when generating a UI dropdown:
const everyKnown = Object.keys(COIN_IDS);
```
## API Reference
### `COIN_IDS: Readonly<Record<string, string>>`
Frozen mapping from uppercase ticker (key) to CoinGecko coin id (value).
Keys are always uppercase; mutation throws because the object is frozen.
### `getCoinGeckoId(symbol: string): string | undefined`
Returns the CoinGecko coin id for the given symbol. The input is
upper-cased before the lookup, so `"btc"` and `"BTC"` both resolve to
`"bitcoin"`. Returns `undefined` for any symbol not present in
`COIN_IDS`. Does not throw.
## Implementation Notes
The map is intentionally hand-curated rather than fetched at runtime: a
sibling that issues a discovery call before every batch would (a) add a
network round trip every consumer pays for once, (b) bloat the
in-memory cache, and (c) couple this package's reliability to a second
upstream endpoint whose response shape can drift. Adding a new symbol is
a one-line PR; the trade-off is heavily skewed toward "type it in".

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { COIN_IDS, getCoinGeckoId } from "./coin-ids.js";
describe("getCoinGeckoId", () => {
it("returns the CoinGecko id for known symbols regardless of case", {
timeout: 5000,
}, () => {
expect(getCoinGeckoId("BTC")).toBe("bitcoin");
expect(getCoinGeckoId("btc")).toBe("bitcoin");
expect(getCoinGeckoId("Eth")).toBe("ethereum");
expect(getCoinGeckoId("SOL")).toBe("solana");
expect(getCoinGeckoId("matic")).toBe("matic-network");
});
it("returns undefined for unknown symbols", { timeout: 5000 }, () => {
expect(getCoinGeckoId("ZZZ")).toBeUndefined();
expect(getCoinGeckoId("")).toBeUndefined();
expect(getCoinGeckoId("not-a-symbol")).toBeUndefined();
});
});
describe("COIN_IDS", () => {
it("covers the top-16 curated set with uppercase keys", {
timeout: 5000,
}, () => {
const expectedKeys = [
"BTC",
"ETH",
"SOL",
"USDT",
"USDC",
"BNB",
"XRP",
"ADA",
"DOGE",
"DOT",
"AVAX",
"MATIC",
"LINK",
"UNI",
"ATOM",
"LTC",
];
expect(Object.keys(COIN_IDS).sort()).toEqual(expectedKeys.sort());
for (const key of Object.keys(COIN_IDS)) {
expect(key).toBe(key.toUpperCase());
}
});
it("is frozen — runtime mutation is rejected", { timeout: 5000 }, () => {
expect(Object.isFrozen(COIN_IDS)).toBe(true);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Curated mapping from uppercase crypto symbol to the CoinGecko coin id
* required by the `/simple/price` endpoint. CoinGecko's API is keyed by an
* opaque coin id (`bitcoin`, `ethereum`, …) rather than the ticker symbol,
* so every request the provider issues SHALL pass values from this map.
*
* The set covers the top 16 cryptocurrencies by market cap at landing time.
* Consumers needing additional symbols register them via their own wrapper
* provider; we deliberately do not auto-fetch CoinGecko's `/coins/list`
* because that endpoint is heavy (~10k entries) and the resulting bundle
* would defeat the purpose of having a slim sibling.
*/
export const COIN_IDS: Readonly<Record<string, string>> = Object.freeze({
BTC: "bitcoin",
ETH: "ethereum",
SOL: "solana",
USDT: "tether",
USDC: "usd-coin",
BNB: "binancecoin",
XRP: "ripple",
ADA: "cardano",
DOGE: "dogecoin",
DOT: "polkadot",
AVAX: "avalanche-2",
MATIC: "matic-network",
LINK: "chainlink",
UNI: "uniswap",
ATOM: "cosmos",
LTC: "litecoin",
});
/**
* Case-insensitive lookup of the CoinGecko coin id for a ticker symbol.
* Returns `undefined` when the symbol is not present in the curated map;
* callers that issue requests SHOULD treat that result as an
* `UNKNOWN_CURRENCY` condition rather than passing the raw symbol upstream.
*/
export function getCoinGeckoId(symbol: string): string | undefined {
return COIN_IDS[symbol.toUpperCase()];
}

View File

@@ -0,0 +1,4 @@
export {
COIN_IDS,
getCoinGeckoId,
} from "./coin-ids.js";

View File

@@ -0,0 +1,114 @@
# CoinGeckoProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
CoinGecko sibling for `@suaveplan/currency-providers`. Implements the
`CurrencyProvider` contract from `@suaveplan/currency` against the
public `/simple/price` endpoint, with optional Pro-tier `apiKey`
support that attaches the `x-cg-pro-api-key` header to every outbound
request. The free public tier (no key, 10–30 calls/min) and the Pro
tier share the same URL shape, so upgrading from free to Pro is a
single-field config change with no other wiring impact.
## Features
- `CoinGeckoProvider implements CurrencyProvider` — `name = "coingecko"`,
`supportsCrypto = true`, `supportsFiat = true`.
- Optional `apiKey` config (`x-cg-pro-api-key` header attachment).
- Optional `baseUrl` and `fetch` config (default
`https://api.coingecko.com/api/v3` and `globalThis.fetch`).
- Caller `AbortSignal` propagation via the shared `httpRequest()` helper
(composes with timeouts from the helper).
- Proactive `UNKNOWN_CURRENCY` gate — unknown `options.targets` reject
before the HTTP call is issued.
- Typed `CurrencyProviderError` on every failure path: `HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`.
## Usage: Basic
```ts
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko/coingecko/coingecko";
const provider = new CoinGeckoProvider();
const result = await provider.fetchRates({ base: "USD" });
// result.rates.BTC = 0.0000149… (1 USD ≈ 0.0000149 BTC at ~$67k/BTC)
```
## Usage: Advanced
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko/coingecko/coingecko";
const registry = new CurrencyProviderRegistry();
registry.register(
new CoinGeckoProvider({
apiKey: process.env.COINGECKO_PRO_KEY,
baseUrl: "https://pro-api.coingecko.com/api/v3",
}),
);
const converter = new CurrencyConverter({
provider: registry.get("coingecko"),
staleDurationMs: 60_000, // 1 minute — crypto rates move fast
});
// Targets are validated against the curated COIN_IDS map BEFORE the
// HTTP call — passing an unknown target raises UNKNOWN_CURRENCY
// without making a network round trip.
const controller = new AbortController();
const rates = await registry.get("coingecko").fetchRates({
base: "EUR",
targets: ["BTC", "ETH"],
signal: controller.signal,
});
```
## API Reference
### `class CoinGeckoProvider implements CurrencyProvider`
- `constructor(config?: CoinGeckoConfig)` — config is fully optional.
- `readonly name: "coingecko"`
- `readonly supportsCrypto: true`
- `readonly supportsFiat: true`
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
- Issues `GET ${baseUrl}/simple/price?ids=<curated-ids>&vs_currencies=<base.toLowerCase()>`.
- Forwards `options.signal` to the shared `httpRequest()` helper.
- Pre-validates `options.targets` against `COIN_IDS`; an unknown entry
raises `CurrencyProviderError(UNKNOWN_CURRENCY)` before any network
activity.
- Inverts upstream "1 crypto = X fiat" prices into the converter's
"1 base = X crypto" convention.
- Throws `CurrencyProviderError` with `code` ∈ {`HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`} carrying
`providerName === "coingecko"`.
### `interface CoinGeckoConfig extends NoKeyConfig`
- `readonly baseUrl?: string` — overrides `https://api.coingecko.com/api/v3`.
- `readonly fetch?: FetchLike` — overrides `globalThis.fetch`.
- `readonly apiKey?: string` — Pro-tier key, attached as
`x-cg-pro-api-key` header when present.
## Implementation Notes
- Pre-validating `options.targets` against `COIN_IDS` (not against the
upstream response) means the rejection message names the bad symbol
rather than swallowing it with a missing-key in the result map. This
is the difference between "your `ZZZ` symbol is not in our curated
set" (actionable) and "your rate result has no `ZZZ` field" (silent
defect).
- The shape guard (`isResponseShape`) accepts any value matching
`Record<string, Record<string, number>>` rather than asserting the
exact `bitcoin`/`ethereum` keys CoinGecko currently returns. The
upstream may add new coins or rename old ones, and a strict assertion
would treat that as a wire-format break instead of a benign superset.
- Headers are constructed once at instantiation time and frozen so the
per-request `init` spread is the only mutable copy — defends against
accidental key leakage into a downstream debugger.

View File

@@ -0,0 +1,364 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import { CoinGeckoProvider } from "./coingecko.js";
interface FetchCall {
url: string;
init: RequestInit | undefined;
}
function recordingFetch(
handler: (call: FetchCall) => Response | Promise<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 };
}
const USD_BODY = {
bitcoin: { usd: 67000 },
ethereum: { usd: 3500 },
solana: { usd: 150 },
tether: { usd: 1 },
"usd-coin": { usd: 1 },
};
function ok(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
describe("CoinGeckoProvider", () => {
it("exposes stable name and supports flags", { timeout: 5000 }, () => {
const provider = new CoinGeckoProvider();
expect(provider.name).toBe("coingecko");
expect(provider.supportsCrypto).toBe(true);
expect(provider.supportsFiat).toBe(true);
});
it("issues request to default endpoint with vs_currencies from base", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ fetch });
await provider.fetchRates({ base: "USD" });
const url = calls[0]?.url ?? "";
expect(
url.startsWith("https://api.coingecko.com/api/v3/simple/price?ids=")
).toBe(true);
expect(url.endsWith("&vs_currencies=usd")).toBe(true);
});
it("honours custom baseUrl override", { timeout: 5000 }, async () => {
const { fetch, calls } = recordingFetch(() =>
ok({ bitcoin: { eur: 62000 } })
);
const provider = new CoinGeckoProvider({
baseUrl: "https://custom.example/v3",
fetch,
});
await provider.fetchRates({ base: "EUR" });
expect(
calls[0]?.url.startsWith("https://custom.example/v3/simple/price")
).toBe(true);
});
it("attaches x-cg-pro-api-key header when apiKey is provided", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ apiKey: "secret", fetch });
await provider.fetchRates();
const headers = calls[0]?.init?.headers as
| Record<string, string>
| undefined;
expect(headers).toBeDefined();
expect(headers?.["x-cg-pro-api-key"]).toBe("secret");
});
it("omits x-cg-pro-api-key header when apiKey is absent", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ fetch });
await provider.fetchRates();
// init.headers should not exist at all on the keyless branch
expect(calls[0]?.init?.headers).toBeUndefined();
});
it("inverts upstream prices into base→crypto rates", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ fetch });
const result = await provider.fetchRates({ base: "USD" });
expect(result.base).toBe("USD");
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 12);
expect(result.rates.SOL).toBeCloseTo(1 / 150, 12);
expect(result.timestamp).toBeTypeOf("number");
});
it("skips known targets whose upstream price is absent", {
timeout: 5000,
}, async () => {
// ATOM is a known symbol (passes UNKNOWN_CURRENCY gate) but
// upstream returned only bitcoin — the filter branch where
// allRates[target] is undefined must be exercised.
const { fetch } = recordingFetch(() => ok({ bitcoin: { usd: 67000 } }));
const provider = new CoinGeckoProvider({ fetch });
const result = await provider.fetchRates({
base: "USD",
targets: ["BTC", "ATOM"],
});
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ATOM).toBeUndefined();
expect(Object.keys(result.rates)).toEqual(["BTC"]);
});
it("filters rates by options.targets", { timeout: 5000 }, async () => {
const { fetch } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ fetch });
const result = await provider.fetchRates({
base: "USD",
targets: ["BTC", "ETH"],
});
expect(Object.keys(result.rates).sort()).toEqual(["BTC", "ETH"]);
});
it("omits symbols whose upstream price is missing or non-positive", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
ok({
bitcoin: { usd: 67000 },
ethereum: { usd: 0 }, // non-positive — skip
solana: {}, // missing vs_currency — skip
"not-a-known-id": { usd: 5 }, // unknown id — skip
})
);
const provider = new CoinGeckoProvider({ fetch });
const result = await provider.fetchRates({ base: "USD" });
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ETH).toBeUndefined();
expect(result.rates.SOL).toBeUndefined();
});
it("raises HTTP_ERROR on non-2xx response", { timeout: 5000 }, async () => {
const { fetch } = recordingFetch(
() =>
new Response("", {
status: 429,
statusText: "Too Many Requests",
})
);
const provider = new CoinGeckoProvider({ fetch });
try {
await provider.fetchRates();
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.providerName).toBe("coingecko");
expect(err.status).toBe(429);
}
});
it("raises PARSE_ERROR when body is not valid JSON", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(
() => new Response("not json at all", { status: 200 })
);
const provider = new CoinGeckoProvider({ fetch });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
expect(err.providerName).toBe("coingecko");
expect(err.cause).toBeInstanceOf(Error);
}
});
it("raises PARSE_ERROR even when JSON parser throws a non-Error", {
timeout: 5000,
}, async () => {
const fetchImpl: FetchLike = async (): Promise<Response> => {
// Build a Response-like whose .json() rejects with a non-Error throwable.
const fake = {
ok: true,
status: 200,
statusText: "OK",
json: async (): Promise<unknown> => {
const literal: unknown = "string-throwable";
throw literal;
},
};
return fake as unknown as Response;
};
const provider = new CoinGeckoProvider({ fetch: fetchImpl });
try {
await provider.fetchRates();
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
// cause is only attached when the thrown value was an Error
expect(err.cause).toBeUndefined();
}
});
it("raises UPSTREAM_ERROR when body is the wrong shape (array)", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => ok([1, 2, 3]));
const provider = new CoinGeckoProvider({ fetch });
try {
await provider.fetchRates();
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.providerName).toBe("coingecko");
}
});
it("raises UPSTREAM_ERROR when body inner value is not an object", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() =>
ok({ bitcoin: "not-an-object" })
);
const provider = new CoinGeckoProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UPSTREAM_ERROR when body inner value is null", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => ok({ bitcoin: null }));
const provider = new CoinGeckoProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UPSTREAM_ERROR when body is null", {
timeout: 5000,
}, async () => {
const { fetch } = recordingFetch(() => ok(null));
const provider = new CoinGeckoProvider({ fetch });
await expect(provider.fetchRates()).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
});
});
it("raises UNKNOWN_CURRENCY when a target has no CoinGecko mapping", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() => ok(USD_BODY));
const provider = new CoinGeckoProvider({ fetch });
try {
await provider.fetchRates({ base: "USD", targets: ["BTC", "ZZZ"] });
expect.fail("fetchRates should have rejected");
} catch (error) {
expect(error).toBeInstanceOf(CurrencyProviderError);
const err = error as CurrencyProviderError;
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
expect(err.providerName).toBe("coingecko");
expect(err.message).toContain("ZZZ");
}
// No HTTP call was issued — the gate fires before httpRequest.
expect(calls.length).toBe(0);
});
it("forwards options.signal so caller can abort in flight", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const fetchImpl: FetchLike = async (
_url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(
init?.signal?.reason ??
new DOMException("aborted", "AbortError")
);
});
});
};
const provider = new CoinGeckoProvider({ fetch: fetchImpl });
const promise = provider.fetchRates({ signal: controller.signal });
controller.abort(new Error("caller cancelled"));
await expect(promise).rejects.toThrow(/caller cancelled/);
});
it("defaults to USD base and globalThis.fetch when no options/config supplied", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
const calls: FetchCall[] = [];
globalThis.fetch = (async (
url: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
calls.push({ url: String(url), init });
return ok(USD_BODY);
}) as typeof globalThis.fetch;
try {
const provider = new CoinGeckoProvider();
const result = await provider.fetchRates();
expect(result.base).toBe("USD");
expect(calls[0]?.url.endsWith("&vs_currencies=usd")).toBe(true);
} finally {
globalThis.fetch = original;
}
});
});

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

View File

@@ -0,0 +1,2 @@
export type { CoinGeckoConfig } from "./coingecko.js";
export { CoinGeckoProvider } from "./coingecko.js";

View File

@@ -0,0 +1,5 @@
export { COIN_IDS, getCoinGeckoId } from "./coin-ids/coin-ids.js";
export {
type CoinGeckoConfig,
CoinGeckoProvider,
} from "./coingecko/coingecko.js";

View File

@@ -0,0 +1,7 @@
// Polyfills for test environment — none required. The package is universal
// and uses only `URLSearchParams`, `Response`, `Date.now`, `Map`, `Object.entries`,
// and the injected `FetchLike` (default `globalThis.fetch`) — all available in
// every supported runtime (Node 18+, Bun, browsers, Web Workers). This file
// exists to give vitest a stable setup file path mirroring the sibling-package
// layout.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/currency-provider-coingecko",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/currency",
"@suaveplan/currency-providers",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});