Initial Commit
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# @suaveplan/currency-provider-cryptocompare - Universal CryptoCompare crypto-rates provider for @suaveplan/currency. Sibling provider package on the slim @suaveplan/currency-providers core (httpRequest, defineCurrencyProvider, CurrencyProviderError, NoKeyConfig). Optional API key — free tier works keyless; key boosts the rate limit and is sent via `authorization: Apikey <key>` header.
|
||||
|
||||
Universal CryptoCompare crypto-rates provider for `@suaveplan/currency`.
|
||||
Sibling provider package on top of the slim `@suaveplan/currency-providers`
|
||||
core (`httpRequest`, `CurrencyProviderError`, `NoKeyConfig`). Optional API
|
||||
key — CryptoCompare's free tier works fully keyless; supplying a key boosts
|
||||
the upstream rate limit and is sent on every request via the
|
||||
`authorization: Apikey <key>` header.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [cryptocompare](./src/cryptocompare/cryptocompare.md) — `CryptoCompareProvider` implements the `CurrencyProvider` contract from `@suaveplan/currency` on top of CryptoCompare's…
|
||||
- [cryptocompare](./src/cryptocompare/cryptocompare.md) — `CryptoCompareProvider` implements the `CurrencyProvider` contract from `@suaveplan/currency` on top of CryptoCompare's…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/currency-provider-cryptocompare \
|
||||
@suaveplan/currency \
|
||||
@suaveplan/currency-providers
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
|
||||
|
||||
const provider = new CryptoCompareProvider({
|
||||
apiKey: process.env.CRYPTOCOMPARE_API_KEY, // optional
|
||||
});
|
||||
|
||||
const converter = new CurrencyConverter({
|
||||
provider,
|
||||
staleDurationMs: 60_000,
|
||||
});
|
||||
|
||||
const conversion = await converter.convert(amount, "BTC");
|
||||
```
|
||||
|
||||
Or register through the slim-core registry:
|
||||
|
||||
```ts
|
||||
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
|
||||
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
|
||||
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(new CryptoCompareProvider());
|
||||
const provider = registry.get("cryptocompare");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`cryptocompare/cryptocompare`](./src/cryptocompare/cryptocompare.md) —
|
||||
`CryptoCompareProvider` class, `CryptoCompareConfig` interface, error
|
||||
mapping for `HTTP_ERROR` / `PARSE_ERROR` / `UPSTREAM_ERROR` /
|
||||
`UNKNOWN_CURRENCY`.
|
||||
|
||||
## Errors
|
||||
|
||||
Every failure path raises `CurrencyProviderError` from the slim core:
|
||||
|
||||
| Code | Raised when |
|
||||
|---|---|
|
||||
| `CURRENCY_PROVIDER.HTTP_ERROR` | Non-2xx HTTP response (shared `httpRequest`) |
|
||||
| `CURRENCY_PROVIDER.PARSE_ERROR` | Body not valid JSON, or payload not a JSON object |
|
||||
| `CURRENCY_PROVIDER.UPSTREAM_ERROR` | Payload carries `Response: "Error"` |
|
||||
| `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` | No requested symbol produced a finite positive quote |
|
||||
|
||||
Dispatch on `error.code`, not the message string. The instance also
|
||||
carries `providerName: "cryptocompare"`, optional `status` (for HTTP_ERROR),
|
||||
and optional `upstreamMessage` (for UPSTREAM_ERROR).
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@suaveplan/currency-provider-cryptocompare",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal CryptoCompare crypto-rates provider for @suaveplan/currency. Sibling provider package on the slim @suaveplan/currency-providers core (httpRequest, defineCurrencyProvider, CurrencyProviderError, NoKeyConfig). Optional API key — free tier works keyless; key boosts the rate limit and is sent via `authorization: Apikey <key>` header.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./cryptocompare/cryptocompare": {
|
||||
"import": {
|
||||
"types": "./dist/cryptocompare/cryptocompare.d.ts",
|
||||
"default": "./dist/cryptocompare/cryptocompare.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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
# `cryptocompare` — CryptoCompare crypto-rates provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`CryptoCompareProvider` implements the `CurrencyProvider` contract from
|
||||
`@suaveplan/currency` on top of CryptoCompare's public
|
||||
`min-api.cryptocompare.com/data/pricemulti` endpoint. It returns inverted
|
||||
spot prices (`1 / price`) for a configurable set of crypto symbols against
|
||||
a fiat or crypto base currency, suitable for direct registration with
|
||||
`CurrencyProviderRegistry` or instantiation into `CurrencyConverter`.
|
||||
|
||||
The provider is sibling to the slim `@suaveplan/currency-providers` core:
|
||||
all HTTP plumbing (timeout / abort composition / typed-error mapping) lives
|
||||
in the shared `httpRequest` helper, the typed error class and code registry
|
||||
come from `CurrencyProviderError` + `CURRENCY_PROVIDER_CODES`, and the
|
||||
config shape extends `NoKeyConfig` with one extra optional field. The
|
||||
provider is universal — no Node, DOM, or framework dependencies — so
|
||||
it runs untouched in browsers, Workers, Bun, and Node 18+.
|
||||
|
||||
## Features
|
||||
|
||||
- Sibling crypto-rates provider — `name: "cryptocompare"`,
|
||||
`supportsCrypto: true`, `supportsFiat: false`
|
||||
- Optional `apiKey` — free tier works fully keyless; key boosts the
|
||||
upstream rate limit and is sent via `authorization: Apikey <key>` header
|
||||
- 15 sensible default crypto symbols (BTC, ETH, SOL, USDT, USDC, BNB, XRP,
|
||||
ADA, DOGE, DOT, AVAX, LINK, UNI, ATOM, LTC) when callers omit `targets`
|
||||
- Injectable `FetchLike` from `@suaveplan/types` — tests pass a controlled
|
||||
closure without `vi.stubGlobal('fetch', ...)`
|
||||
- Typed errors via the slim core registry: `HTTP_ERROR`, `PARSE_ERROR`,
|
||||
`UPSTREAM_ERROR`, `UNKNOWN_CURRENCY` — every failure path carries
|
||||
`providerName: "cryptocompare"` for log dispatch
|
||||
- Caller `AbortSignal` from `RateFetchOptions.signal` is composed through
|
||||
`httpRequest`; whitespace-only `apiKey` strings are treated as absent
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
|
||||
|
||||
const provider = new CryptoCompareProvider();
|
||||
const result = await provider.fetchRates({ base: "USD" });
|
||||
console.log(result.rates.BTC); // 1 / spot price of BTC in USD
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Register the provider with `CurrencyProviderRegistry`, use the keyed
|
||||
upstream for higher rate limits, and dispatch on typed errors:
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
CurrencyProviderRegistry,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
|
||||
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(
|
||||
new CryptoCompareProvider({
|
||||
apiKey: process.env.CRYPTOCOMPARE_API_KEY,
|
||||
baseUrl: "https://min-api.cryptocompare.com/data",
|
||||
}),
|
||||
);
|
||||
|
||||
const converter = new CurrencyConverter({
|
||||
provider: registry.get("cryptocompare"),
|
||||
staleDurationMs: 60_000,
|
||||
});
|
||||
|
||||
try {
|
||||
const conversion = await converter.convert(amount, "BTC");
|
||||
console.log(conversion);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof CurrencyProviderError &&
|
||||
error.code === CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR
|
||||
) {
|
||||
console.warn("CryptoCompare upstream:", error.upstreamMessage);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CryptoCompareConfig extends NoKeyConfig`
|
||||
- `apiKey?: string` — optional API key. Sent on every request via
|
||||
`authorization: Apikey <key>` when present and non-whitespace.
|
||||
Whitespace-only strings are treated as absent.
|
||||
- `baseUrl?: string` — overrides the default
|
||||
`https://min-api.cryptocompare.com/data` endpoint.
|
||||
- `fetch?: FetchLike` — injectable WHATWG-shape fetch. Defaults to
|
||||
`globalThis.fetch`.
|
||||
- `class CryptoCompareProvider implements CurrencyProvider`
|
||||
- `name: "cryptocompare"`, `supportsCrypto: true`,
|
||||
`supportsFiat: false`.
|
||||
- `constructor(config?: CryptoCompareConfig)` — every config field is
|
||||
optional. Constructing without a config wires `globalThis.fetch`, the
|
||||
default base URL, and no API key.
|
||||
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
|
||||
issues a single `GET <baseUrl>/pricemulti?fsyms=<targets>&tsyms=<base>`,
|
||||
inverts each price, and returns `{ base, rates, timestamp }`.
|
||||
`options.base` defaults to `"USD"`; `options.targets` defaults to the
|
||||
15 built-in symbols when omitted or empty; `options.signal` is
|
||||
forwarded into the shared `httpRequest` helper.
|
||||
- Raised `CurrencyProviderError` codes:
|
||||
- `HTTP_ERROR` — non-2xx response (raised by shared `httpRequest`).
|
||||
- `PARSE_ERROR` — body is not valid JSON, or the parsed payload is not
|
||||
a JSON object.
|
||||
- `UPSTREAM_ERROR` — payload carries `Response: "Error"`; the
|
||||
`Message` field (or `"unknown upstream error"` when absent) is copied
|
||||
to `error.upstreamMessage`.
|
||||
- `UNKNOWN_CURRENCY` — no requested symbol produced a finite positive
|
||||
quote in the requested base currency.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- CryptoCompare's `pricemulti` endpoint returns HTTP 200 even for
|
||||
per-symbol upstream errors — the payload carries `Response: "Error"` and
|
||||
a human-readable `Message`. The provider distinguishes that from a
|
||||
successful (possibly empty) quote map and surfaces it as
|
||||
`UPSTREAM_ERROR` rather than masquerading as `HTTP_ERROR`.
|
||||
- The free tier permits ~100,000 calls per month without a key; supplying
|
||||
an `apiKey` boosts that quota. The provider authenticates via the
|
||||
`authorization: Apikey <key>` header per CryptoCompare's documentation
|
||||
(Apikey scheme, not Bearer). Empty or whitespace-only keys are silently
|
||||
treated as absent so callers can pass `process.env.CRYPTOCOMPARE_API_KEY`
|
||||
directly without a guard.
|
||||
- Prices are inverted (`1 / price`) so the result map answers "one unit of
|
||||
`base` is worth X units of `symbol`" — consistent with every other
|
||||
sibling provider. Non-finite or non-positive quotes are skipped.
|
||||
- The default symbol list covers the top 15 crypto assets by market cap at
|
||||
package-publish time; callers wanting a different set always pass
|
||||
`targets` explicitly. The defaults exist only as a developer-friendly
|
||||
fallback for ad-hoc usage.
|
||||
- The provider does not wrap retry, backoff, or rate-limit semantics —
|
||||
compose `@suaveplan/retry` / future `RateLimitedProvider` wrappers at a
|
||||
higher layer, exactly the same way as every other sibling provider.
|
||||
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import { CryptoCompareProvider } from "./cryptocompare.js";
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
function recordingFetch(
|
||||
respond: (call: Call) => Response | Promise<Response>
|
||||
): { fetch: FetchLike; calls: Call[] } {
|
||||
const calls: Call[] = [];
|
||||
const fetch: FetchLike = async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const call: Call = { url, init };
|
||||
calls.push(call);
|
||||
return respond(call);
|
||||
};
|
||||
return { fetch, calls };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
describe("CryptoCompareProvider", () => {
|
||||
it("reports stable metadata identifying the provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new CryptoCompareProvider();
|
||||
expect(provider.name).toBe("cryptocompare");
|
||||
expect(provider.supportsCrypto).toBe(true);
|
||||
expect(provider.supportsFiat).toBe(false);
|
||||
});
|
||||
|
||||
it("inverts pricemulti payload, skips zero prices, and defaults base to USD", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
BTC: { USD: 67000 },
|
||||
ETH: { USD: 3500 },
|
||||
FAKE: { USD: 0 },
|
||||
})
|
||||
);
|
||||
const provider = new CryptoCompareProvider({ fetch });
|
||||
|
||||
const result = await provider.fetchRates();
|
||||
|
||||
expect(result.base).toBe("USD");
|
||||
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
|
||||
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 12);
|
||||
expect(result.rates.FAKE).toBeUndefined();
|
||||
expect(typeof result.timestamp).toBe("number");
|
||||
expect(calls[0]?.url).toContain("/pricemulti?fsyms=");
|
||||
expect(calls[0]?.url).toContain("&tsyms=USD");
|
||||
});
|
||||
|
||||
it("sends authorization header only when an apiKey is provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch: keylessFetch, calls: keylessCalls } = recordingFetch(
|
||||
() => jsonResponse({ BTC: { USD: 50000 } })
|
||||
);
|
||||
await new CryptoCompareProvider({ fetch: keylessFetch }).fetchRates({
|
||||
base: "USD",
|
||||
targets: ["BTC"],
|
||||
});
|
||||
expect(keylessCalls[0]?.init?.headers).toBeUndefined();
|
||||
|
||||
const { fetch: keyedFetch, calls: keyedCalls } = recordingFetch(() =>
|
||||
jsonResponse({ BTC: { USD: 50000 } })
|
||||
);
|
||||
await new CryptoCompareProvider({
|
||||
fetch: keyedFetch,
|
||||
apiKey: "secret-key",
|
||||
}).fetchRates({ base: "USD", targets: ["BTC"] });
|
||||
const headers = keyedCalls[0]?.init?.headers as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
expect(headers?.authorization).toBe("Apikey secret-key");
|
||||
});
|
||||
|
||||
it("treats whitespace-only apiKey as absent and omits the header", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({ BTC: { USD: 50000 } })
|
||||
);
|
||||
await new CryptoCompareProvider({
|
||||
fetch,
|
||||
apiKey: " ",
|
||||
}).fetchRates({ base: "USD", targets: ["BTC"] });
|
||||
expect(calls[0]?.init?.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honours custom baseUrl and explicit targets list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({ BTC: { EUR: 60000 }, ETH: { EUR: 3000 } })
|
||||
);
|
||||
const provider = new CryptoCompareProvider({
|
||||
fetch,
|
||||
baseUrl: "https://staging.example.com/data",
|
||||
});
|
||||
|
||||
const result = await provider.fetchRates({
|
||||
base: "EUR",
|
||||
targets: ["BTC", "ETH"],
|
||||
});
|
||||
|
||||
expect(calls[0]?.url).toBe(
|
||||
"https://staging.example.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR"
|
||||
);
|
||||
expect(result.base).toBe("EUR");
|
||||
expect(Object.keys(result.rates).sort()).toEqual(["BTC", "ETH"]);
|
||||
});
|
||||
|
||||
it("falls back to defaults when targets array is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({ BTC: { USD: 50000 } })
|
||||
);
|
||||
await new CryptoCompareProvider({ fetch }).fetchRates({
|
||||
base: "USD",
|
||||
targets: [],
|
||||
});
|
||||
expect(calls[0]?.url).toContain("fsyms=BTC,ETH,SOL,USDT");
|
||||
});
|
||||
|
||||
it("raises HTTP_ERROR on non-2xx response", { timeout: 5000 }, async () => {
|
||||
const fetch: FetchLike = async () =>
|
||||
new Response("", {
|
||||
status: 502,
|
||||
statusText: "Bad Gateway",
|
||||
});
|
||||
const provider = new CryptoCompareProvider({ fetch });
|
||||
|
||||
await expect(
|
||||
provider.fetchRates({ base: "USD" })
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.HTTP_ERROR,
|
||||
providerName: "cryptocompare",
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when the response body is not valid JSON", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetch: FetchLike = async () =>
|
||||
new Response("<<not json>>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const provider = new CryptoCompareProvider({ fetch });
|
||||
|
||||
await expect(
|
||||
provider.fetchRates({ base: "USD" })
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
providerName: "cryptocompare",
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when the parsed payload is not an object", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetch: FetchLike = async () =>
|
||||
new Response("null", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const provider = new CryptoCompareProvider({ fetch });
|
||||
|
||||
const error = await provider
|
||||
.fetchRates({ base: "USD" })
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
expect((error as CurrencyProviderError).code).toBe(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR
|
||||
);
|
||||
});
|
||||
|
||||
it("raises UPSTREAM_ERROR when payload carries Response='Error' with Message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetch: FetchLike = async () =>
|
||||
jsonResponse({
|
||||
Response: "Error",
|
||||
Message: "tsym XYZ is not available",
|
||||
});
|
||||
const provider = new CryptoCompareProvider({ fetch });
|
||||
|
||||
const error = await provider
|
||||
.fetchRates({ base: "XYZ" })
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
expect((error as CurrencyProviderError).code).toBe(
|
||||
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR
|
||||
);
|
||||
expect((error as CurrencyProviderError).upstreamMessage).toContain(
|
||||
"tsym XYZ"
|
||||
);
|
||||
});
|
||||
|
||||
it("raises UPSTREAM_ERROR with default message when Message field is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetch: FetchLike = async () =>
|
||||
jsonResponse({ Response: "Error" });
|
||||
const provider = new CryptoCompareProvider({ fetch });
|
||||
|
||||
const error = await provider
|
||||
.fetchRates({ base: "USD" })
|
||||
.catch((cause: unknown) => cause);
|
||||
expect((error as CurrencyProviderError).code).toBe(
|
||||
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR
|
||||
);
|
||||
expect((error as CurrencyProviderError).upstreamMessage).toBe(
|
||||
"unknown upstream error"
|
||||
);
|
||||
});
|
||||
|
||||
it("raises UNKNOWN_CURRENCY when no symbols resolve to a quote", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetch: FetchLike = async () => jsonResponse({});
|
||||
const provider = new CryptoCompareProvider({ fetch });
|
||||
|
||||
await expect(
|
||||
provider.fetchRates({ base: "USD", targets: ["BTC"] })
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
|
||||
providerName: "cryptocompare",
|
||||
});
|
||||
});
|
||||
|
||||
it("raises UNKNOWN_CURRENCY when every quote is non-finite or non-positive", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetch: FetchLike = async () =>
|
||||
jsonResponse({
|
||||
BTC: { USD: 0 },
|
||||
ETH: { USD: Number.NaN },
|
||||
SOL: { USD: -1 },
|
||||
});
|
||||
const provider = new CryptoCompareProvider({ fetch });
|
||||
|
||||
await expect(
|
||||
provider.fetchRates({
|
||||
base: "USD",
|
||||
targets: ["BTC", "ETH", "SOL"],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves PARSE_ERROR when the response body throws a non-Error value", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetch: FetchLike = async () => {
|
||||
const response = new Response("ignored", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
// Replace `.json()` with a closure that rejects with a non-Error.
|
||||
Object.defineProperty(response, "json", {
|
||||
value: async () => {
|
||||
const failure: unknown = "string-thrown";
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
return response;
|
||||
};
|
||||
const provider = new CryptoCompareProvider({ fetch });
|
||||
|
||||
const error = await provider
|
||||
.fetchRates({ base: "USD" })
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
expect((error as CurrencyProviderError).code).toBe(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR
|
||||
);
|
||||
expect((error as CurrencyProviderError).cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards a caller AbortSignal through httpRequest", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const seen: { signal: AbortSignal | null | undefined }[] = [];
|
||||
const fetch: FetchLike = async (_url, init) => {
|
||||
seen.push({ signal: init?.signal });
|
||||
return jsonResponse({ BTC: { USD: 50000 } });
|
||||
};
|
||||
const controller = new AbortController();
|
||||
await new CryptoCompareProvider({ fetch }).fetchRates({
|
||||
base: "USD",
|
||||
targets: ["BTC"],
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(seen[0]?.signal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
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";
|
||||
|
||||
const DEFAULT_URL = "https://min-api.cryptocompare.com/data";
|
||||
const NAME = "cryptocompare";
|
||||
const DEFAULT_BASE = "USD";
|
||||
const DEFAULT_SYMBOLS = [
|
||||
"BTC",
|
||||
"ETH",
|
||||
"SOL",
|
||||
"USDT",
|
||||
"USDC",
|
||||
"BNB",
|
||||
"XRP",
|
||||
"ADA",
|
||||
"DOGE",
|
||||
"DOT",
|
||||
"AVAX",
|
||||
"LINK",
|
||||
"UNI",
|
||||
"ATOM",
|
||||
"LTC",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Configuration for {@link CryptoCompareProvider}. Extends the shared
|
||||
* `NoKeyConfig` (`baseUrl?` + injectable `fetch?`) with an **optional**
|
||||
* `apiKey`. CryptoCompare's free tier works fully keyless; supplying a key
|
||||
* boosts the rate limit and is sent on every request via the
|
||||
* `authorization: Apikey <key>` header.
|
||||
*/
|
||||
export interface CryptoCompareConfig extends NoKeyConfig {
|
||||
/** Optional CryptoCompare API key. Free tier works keyless. */
|
||||
readonly apiKey?: string;
|
||||
}
|
||||
|
||||
interface PriceMultiPayload {
|
||||
readonly [symbol: string]: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
interface ErrorPayload {
|
||||
readonly Response?: string;
|
||||
readonly Message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CryptoCompare crypto-rates provider. Hits the public
|
||||
* `min-api.cryptocompare.com/data/pricemulti` endpoint and returns the
|
||||
* inverted price map (`1 / price`) per requested symbol. Crypto-only —
|
||||
* `supportsCrypto: true`, `supportsFiat: false`.
|
||||
*/
|
||||
export class CryptoCompareProvider implements CurrencyProvider {
|
||||
public readonly name = NAME;
|
||||
public readonly supportsCrypto = true;
|
||||
public readonly supportsFiat = false;
|
||||
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string | undefined;
|
||||
private readonly fetchImpl: FetchLike;
|
||||
|
||||
public constructor(config?: CryptoCompareConfig) {
|
||||
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
|
||||
this.apiKey = trimOrUndefined(config?.apiKey);
|
||||
this.fetchImpl = config?.fetch ?? globalThis.fetch;
|
||||
}
|
||||
|
||||
public async fetchRates(
|
||||
options?: RateFetchOptions
|
||||
): Promise<RateFetchResult> {
|
||||
const base = options?.base ?? DEFAULT_BASE;
|
||||
const symbols =
|
||||
options?.targets !== undefined && options.targets.length > 0
|
||||
? options.targets
|
||||
: DEFAULT_SYMBOLS;
|
||||
const url = `${this.baseUrl}/pricemulti?fsyms=${symbols.join(",")}&tsyms=${base}`;
|
||||
|
||||
const init: RequestInit =
|
||||
this.apiKey !== undefined
|
||||
? { headers: { authorization: `Apikey ${this.apiKey}` } }
|
||||
: {};
|
||||
|
||||
const response = await httpRequest({
|
||||
url,
|
||||
fetch: this.fetchImpl,
|
||||
providerName: NAME,
|
||||
init,
|
||||
...(options?.signal !== undefined
|
||||
? { signal: options.signal }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const payload = await parseJson(response);
|
||||
assertNotErrorPayload(payload);
|
||||
const data = payload as PriceMultiPayload;
|
||||
|
||||
const rates: Record<string, number> = {};
|
||||
for (const [symbol, prices] of Object.entries(data)) {
|
||||
const price = prices?.[base];
|
||||
if (
|
||||
typeof price === "number" &&
|
||||
Number.isFinite(price) &&
|
||||
price > 0
|
||||
) {
|
||||
rates[symbol] = 1 / price;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(rates).length === 0) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
|
||||
`${NAME}: no quotes resolved for base ${base} from symbols ${symbols.join(",")}`,
|
||||
{ providerName: NAME }
|
||||
);
|
||||
}
|
||||
|
||||
return { base, rates, timestamp: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
function trimOrUndefined(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length === 0 ? undefined : trimmed;
|
||||
}
|
||||
|
||||
async function parseJson(response: Response): Promise<unknown> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (cause) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
`${NAME}: failed to parse JSON response`,
|
||||
{
|
||||
providerName: NAME,
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotErrorPayload(payload: unknown): void {
|
||||
if (payload === null || typeof payload !== "object") {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
`${NAME}: unexpected payload shape (expected object)`,
|
||||
{ providerName: NAME }
|
||||
);
|
||||
}
|
||||
const candidate = payload as ErrorPayload;
|
||||
if (candidate.Response === "Error") {
|
||||
const message = candidate.Message ?? "unknown upstream error";
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
|
||||
`${NAME}: upstream error — ${message}`,
|
||||
{ providerName: NAME, upstreamMessage: message }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CryptoCompareConfig } from "./cryptocompare.js";
|
||||
export { CryptoCompareProvider } from "./cryptocompare.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type CryptoCompareConfig,
|
||||
CryptoCompareProvider,
|
||||
} from "./cryptocompare/cryptocompare.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
// Polyfills for test environment — none required. The package is universal
|
||||
// and uses only `globalThis.fetch` (overridable via FetchLike injection),
|
||||
// `URLSearchParams`, `Response`, `Object.entries`, `Date.now`, and standard
|
||||
// `Error` semantics — 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 { };
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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-cryptocompare",
|
||||
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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user