Initial Commit
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# @suaveplan/currency-provider-open-exchange-rates - Universal @suaveplan/currency provider for openexchangerates.org. API-key (app_id) required. Free tier limited to USD base; paid tiers opt in via allowNonUsdBase. Routes every request through @suaveplan/currency-providers httpRequest() and raises typed CurrencyProviderError for HTTP / parse / upstream / config / base / currency failures.
|
||||
|
||||
Universal `@suaveplan/currency` provider for
|
||||
[openexchangerates.org](https://openexchangerates.org). Routes `/latest.json`
|
||||
through the slim-core `httpRequest()` helper, validates the upstream payload,
|
||||
and raises typed `CurrencyProviderError` for every failure mode.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [provider](./src/provider/provider.md) — `OpenExchangeRatesProvider` is the `@suaveplan/currency` provider for openexchangerates.org. It wraps the `/latest.json…
|
||||
- [provider](./src/provider/provider.md) — `OpenExchangeRatesProvider` is the `@suaveplan/currency` provider for openexchangerates.org. It wraps the `/latest.json…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/currency-provider-open-exchange-rates
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { OpenExchangeRatesProvider } from "@suaveplan/currency-provider-open-exchange-rates";
|
||||
|
||||
const oxr = new OpenExchangeRatesProvider({
|
||||
apiKey: process.env.OXR_APP_ID!,
|
||||
// allowNonUsdBase: true, // paid plans only
|
||||
});
|
||||
|
||||
const converter = new CurrencyConverter({ provider: oxr });
|
||||
|
||||
const usdToEur = await converter.getRate({ from: "USD", to: "EUR" });
|
||||
console.log(usdToEur);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Type | Default | Notes |
|
||||
| ----------------- | ----------- | --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `apiKey` | `string` | — | OXR `app_id`. Empty string raises `MISSING_CONFIG`. |
|
||||
| `baseUrl` | `string?` | `https://openexchangerates.org/api` | Override for staging / proxy fixtures. |
|
||||
| `fetch` | `FetchLike?`| `globalThis.fetch` | Injectable for testing. |
|
||||
| `allowNonUsdBase` | `boolean?` | `false` | Set `true` on paid plans to forward non-USD `base` requests. |
|
||||
|
||||
## Error codes
|
||||
|
||||
Every failure mode raises `CurrencyProviderError` from
|
||||
`@suaveplan/currency-providers` with one of:
|
||||
|
||||
- `CURRENCY_PROVIDER.MISSING_CONFIG` — empty `apiKey`.
|
||||
- `CURRENCY_PROVIDER.UNSUPPORTED_BASE` — non-USD base on free tier.
|
||||
- `CURRENCY_PROVIDER.HTTP_ERROR` — non-2xx response (raised by `httpRequest()`).
|
||||
- `CURRENCY_PROVIDER.PARSE_ERROR` — body is not JSON or missing required fields.
|
||||
- `CURRENCY_PROVIDER.UPSTREAM_ERROR` — 2xx with OXR `{ error: true, … }` body.
|
||||
- `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` — `options.targets` contains a code absent from the upstream rates map.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/provider/provider.md`](./src/provider/provider.md) — `OpenExchangeRatesProvider` class and config.
|
||||
|
||||
## Tier
|
||||
|
||||
Universal — runs in Node 18+, Bun, browsers, and Web Workers without any
|
||||
runtime polyfill.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@suaveplan/currency-provider-open-exchange-rates",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal @suaveplan/currency provider for openexchangerates.org. API-key (app_id) required. Free tier limited to USD base; paid tiers opt in via allowNonUsdBase. Routes every request through @suaveplan/currency-providers httpRequest() and raises typed CurrencyProviderError for HTTP / parse / upstream / config / base / currency failures.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./provider/provider": {
|
||||
"import": {
|
||||
"types": "./dist/provider/provider.d.ts",
|
||||
"default": "./dist/provider/provider.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,4 @@
|
||||
export {
|
||||
OpenExchangeRatesProvider,
|
||||
type OpenExchangeRatesProviderConfig,
|
||||
} from "./provider/provider.js";
|
||||
@@ -0,0 +1,148 @@
|
||||
# `OpenExchangeRatesProvider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`OpenExchangeRatesProvider` is the `@suaveplan/currency` provider for
|
||||
[openexchangerates.org](https://openexchangerates.org). It wraps the
|
||||
`/latest.json` bulk-fetch endpoint, validates the upstream payload, and
|
||||
surfaces every failure mode through the typed `CurrencyProviderError`
|
||||
hierarchy from `@suaveplan/currency-providers`. The provider is universal
|
||||
— it relies only on `globalThis.fetch` (or an injected `FetchLike`) and the
|
||||
slim-core `httpRequest()` helper, so it runs unchanged in Node, Bun, browsers,
|
||||
and Web Workers.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `CurrencyProvider` with `name === "open-exchange-rates"`,
|
||||
`supportsFiat === true`, `supportsCrypto === false`.
|
||||
- Constructor takes the OXR `app_id` as `apiKey`. Empty string raises
|
||||
`CurrencyProviderError(MISSING_CONFIG)` synchronously.
|
||||
- Free-tier safe by default: any `RateFetchOptions.base !== "USD"` raises
|
||||
`CurrencyProviderError(UNSUPPORTED_BASE)` before any network call. Paid-plan
|
||||
callers opt in with `allowNonUsdBase: true`.
|
||||
- Routes every request through `@suaveplan/currency-providers` `httpRequest()`
|
||||
so non-2xx responses surface `CurrencyProviderError(HTTP_ERROR)` with
|
||||
`status` and `providerName` populated. Caller `AbortSignal` from
|
||||
`RateFetchOptions.signal` is forwarded.
|
||||
- Validates the upstream `{ base, rates, timestamp }` shape. Mismatches raise
|
||||
`CurrencyProviderError(PARSE_ERROR)`.
|
||||
- Detects the OXR 2xx error-payload shape (`{ error: true, status, message }`)
|
||||
and raises `CurrencyProviderError(UPSTREAM_ERROR)` with `upstreamMessage`
|
||||
carrying the upstream `message` field.
|
||||
- Filters the rates map to `RateFetchOptions.targets`. Missing target codes
|
||||
raise `CurrencyProviderError(UNKNOWN_CURRENCY)`.
|
||||
- Converts the OXR Unix-seconds timestamp to milliseconds.
|
||||
- Injectable `FetchLike` for offline testing; defaults to `globalThis.fetch`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { OpenExchangeRatesProvider } from "@suaveplan/currency-provider-open-exchange-rates";
|
||||
|
||||
const provider = new OpenExchangeRatesProvider({
|
||||
apiKey: process.env.OXR_APP_ID ?? "",
|
||||
});
|
||||
|
||||
const result = await provider.fetchRates({ base: "USD" });
|
||||
console.log(result.base, result.rates.EUR, result.timestamp);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wiring the provider into `CurrencyConverter` with a paid-tier app_id, custom
|
||||
`baseUrl` for staging, and `AbortSignal`-driven cancellation:
|
||||
|
||||
```typescript
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { OpenExchangeRatesProvider } from "@suaveplan/currency-provider-open-exchange-rates";
|
||||
|
||||
const oxr = new OpenExchangeRatesProvider({
|
||||
apiKey: process.env.OXR_APP_ID!,
|
||||
allowNonUsdBase: true,
|
||||
baseUrl: process.env.OXR_BASE_URL ?? "https://openexchangerates.org/api",
|
||||
});
|
||||
|
||||
const converter = new CurrencyConverter({
|
||||
provider: oxr,
|
||||
staleDurationMs: 30 * 60 * 1000, // 30 minutes
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(new Error("timeout")), 5000);
|
||||
|
||||
const rates = await oxr.fetchRates({
|
||||
base: "EUR",
|
||||
targets: ["USD", "GBP", "JPY"],
|
||||
signal: controller.signal,
|
||||
});
|
||||
```
|
||||
|
||||
The slim-core `CurrencyProviderRegistry` is also supported when the consumer
|
||||
wires multiple providers behind a single lookup surface — see
|
||||
`@suaveplan/currency-providers` README.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `OpenExchangeRatesProviderConfig`
|
||||
|
||||
Extends `ApiKeyConfig` from `@suaveplan/currency-providers`:
|
||||
|
||||
- `apiKey: string` — required. The OXR `app_id`.
|
||||
- `baseUrl?: string` — defaults to `"https://openexchangerates.org/api"`.
|
||||
- `fetch?: FetchLike` — defaults to `globalThis.fetch`.
|
||||
- `allowNonUsdBase?: boolean` — defaults to `false`. When `false`, any
|
||||
non-USD base raises `UNSUPPORTED_BASE` before any network call.
|
||||
|
||||
### `class OpenExchangeRatesProvider`
|
||||
|
||||
#### Properties
|
||||
|
||||
- `readonly name: "open-exchange-rates"`
|
||||
- `readonly supportsFiat: true`
|
||||
- `readonly supportsCrypto: false`
|
||||
|
||||
#### `constructor(config: OpenExchangeRatesProviderConfig)`
|
||||
|
||||
Throws `CurrencyProviderError(MISSING_CONFIG)` when `config.apiKey === ""`.
|
||||
|
||||
#### `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
|
||||
|
||||
Issues `GET ${baseUrl}/latest.json?app_id=<apiKey>&base=<base>` via
|
||||
`httpRequest()`. Returns `{ base, rates, timestamp }` with `timestamp` in
|
||||
milliseconds.
|
||||
|
||||
Throws:
|
||||
|
||||
- `MISSING_CONFIG` — from the constructor when `apiKey === ""`.
|
||||
- `UNSUPPORTED_BASE` — when `options.base !== "USD"` and `allowNonUsdBase`
|
||||
is not set to `true`.
|
||||
- `HTTP_ERROR` — via `httpRequest()` for any non-2xx response.
|
||||
- `PARSE_ERROR` — when the response body is not JSON, not an object, or is
|
||||
missing one of `base` / `rates` / `timestamp` in the expected shape.
|
||||
- `UPSTREAM_ERROR` — when the response is 2xx but the body is the OXR error
|
||||
shape (`{ error: true, status, message }`).
|
||||
- `UNKNOWN_CURRENCY` — when `options.targets` contains a code not present in
|
||||
the upstream `rates` map.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The OXR free tier restricts the base currency to USD; the provider enforces
|
||||
this client-side so the failure surfaces as a typed `UNSUPPORTED_BASE`
|
||||
rather than a generic upstream error. Paid plans pass `allowNonUsdBase:
|
||||
true` to opt out.
|
||||
- OXR returns `timestamp` in Unix seconds; the provider multiplies by 1000 so
|
||||
every `CurrencyProvider` answer is in milliseconds, matching
|
||||
`RateFetchResult.timestamp`'s contract.
|
||||
- OXR's error-payload shape is returned with a 2xx HTTP status, which would
|
||||
otherwise pass `httpRequest()`'s non-2xx check silently. The provider
|
||||
detects `{ error: true, … }` after parsing and surfaces `UPSTREAM_ERROR`
|
||||
with `status` and `upstreamMessage` populated.
|
||||
- The provider does not retry, dedupe, or rate-limit by itself — the slim
|
||||
core's `## Out of scope` defers those concerns to a cross-cutting follow-up
|
||||
wave so every sibling provider gains them in lockstep.
|
||||
- `apiKey` is `encodeURIComponent`-wrapped in the query string to prevent
|
||||
injection when callers pass keys containing reserved characters.
|
||||
@@ -0,0 +1,424 @@
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import { OpenExchangeRatesProvider } from "./provider.js";
|
||||
|
||||
interface FetchCall {
|
||||
url: string;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
function recordingFetch(
|
||||
handler: (call: FetchCall) => Promise<Response> | 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 };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("OpenExchangeRatesProvider", () => {
|
||||
it("exposes stable identity fields", { timeout: 5000 }, () => {
|
||||
const provider = new OpenExchangeRatesProvider({
|
||||
apiKey: "k",
|
||||
fetch: recordingFetch(() => jsonResponse({})).fetch,
|
||||
});
|
||||
expect(provider.name).toBe("open-exchange-rates");
|
||||
expect(provider.supportsFiat).toBe(true);
|
||||
expect(provider.supportsCrypto).toBe(false);
|
||||
});
|
||||
|
||||
it("raises MISSING_CONFIG when apiKey is empty", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new OpenExchangeRatesProvider({ apiKey: "" });
|
||||
expect.fail("constructor should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.MISSING_CONFIG);
|
||||
expect(err.providerName).toBe("open-exchange-rates");
|
||||
}
|
||||
});
|
||||
|
||||
it("calls /latest.json with app_id and base, forwarding caller signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({ base: "USD", rates: { EUR: 0.91 }, timestamp: 1 })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({
|
||||
apiKey: "abc",
|
||||
fetch,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
const result = await provider.fetchRates({
|
||||
base: "USD",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]?.url).toBe(
|
||||
"https://openexchangerates.org/api/latest.json?app_id=abc&base=USD"
|
||||
);
|
||||
expect(calls[0]?.init?.signal).toBeDefined();
|
||||
expect(result.base).toBe("USD");
|
||||
});
|
||||
|
||||
it("uses default USD base when options.base is omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({ base: "USD", rates: { EUR: 0.91 }, timestamp: 1 })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({
|
||||
apiKey: "abc",
|
||||
fetch,
|
||||
});
|
||||
|
||||
await provider.fetchRates();
|
||||
|
||||
expect(calls[0]?.url).toContain("base=USD");
|
||||
});
|
||||
|
||||
it("honours custom baseUrl override", { timeout: 5000 }, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({ base: "USD", rates: { EUR: 0.91 }, timestamp: 1 })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({
|
||||
apiKey: "abc",
|
||||
baseUrl: "https://proxy/oxr",
|
||||
fetch,
|
||||
});
|
||||
|
||||
await provider.fetchRates();
|
||||
|
||||
expect(calls[0]?.url).toBe(
|
||||
"https://proxy/oxr/latest.json?app_id=abc&base=USD"
|
||||
);
|
||||
});
|
||||
|
||||
it("converts OXR Unix-seconds timestamp to milliseconds", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
base: "USD",
|
||||
rates: { EUR: 0.91 },
|
||||
timestamp: 1_700_000_000,
|
||||
})
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({
|
||||
apiKey: "abc",
|
||||
fetch,
|
||||
});
|
||||
|
||||
const result = await provider.fetchRates();
|
||||
|
||||
expect(result.timestamp).toBe(1_700_000_000_000);
|
||||
expect(result.rates.EUR).toBe(0.91);
|
||||
});
|
||||
|
||||
it("filters rates down to options.targets", { timeout: 5000 }, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
base: "USD",
|
||||
rates: { EUR: 0.91, GBP: 0.79, JPY: 150 },
|
||||
timestamp: 1,
|
||||
})
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({
|
||||
apiKey: "abc",
|
||||
fetch,
|
||||
});
|
||||
|
||||
const result = await provider.fetchRates({ targets: ["EUR", "GBP"] });
|
||||
|
||||
expect(Object.keys(result.rates).sort()).toEqual(["EUR", "GBP"]);
|
||||
});
|
||||
|
||||
it("raises UNSUPPORTED_BASE when free-tier caller requests non-USD base", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({ base: "EUR", rates: {}, timestamp: 1 })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
try {
|
||||
await provider.fetchRates({ base: "EUR" });
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UNSUPPORTED_BASE);
|
||||
expect(err.providerName).toBe("open-exchange-rates");
|
||||
}
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("honours allowNonUsdBase=true and forwards base to /latest.json", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({ base: "EUR", rates: { USD: 1.1 }, timestamp: 1 })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({
|
||||
apiKey: "k",
|
||||
allowNonUsdBase: true,
|
||||
fetch,
|
||||
});
|
||||
|
||||
const result = await provider.fetchRates({ base: "EUR" });
|
||||
|
||||
expect(calls[0]?.url).toContain("base=EUR");
|
||||
expect(result.base).toBe("EUR");
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when upstream payload is missing required fields", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => jsonResponse({ base: "USD" }));
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when base is not a string", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({ base: 123, rates: { EUR: 0.91 }, timestamp: 1 })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when upstream payload is not an object", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => jsonResponse("not-an-object"));
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when upstream payload is null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => jsonResponse(null));
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when timestamp is not a number", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({ base: "USD", rates: {}, timestamp: "now" })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when rates is an array", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({ base: "USD", rates: [], timestamp: 1 })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when response body is invalid JSON", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() => new Response("not-json{", { status: 200 })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
|
||||
expect(err.cause).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when response.json() throws a non-Error value", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const responseLike = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: () => Promise.reject("string-rejection"),
|
||||
} as unknown as Response;
|
||||
const fetchImpl: FetchLike = async () => responseLike;
|
||||
const provider = new OpenExchangeRatesProvider({
|
||||
apiKey: "k",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
|
||||
expect(err.cause).toBeInstanceOf(Error);
|
||||
expect((err.cause as Error).message).toBe("string-rejection");
|
||||
}
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when error payload is missing status or message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => jsonResponse({ error: true }));
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises UPSTREAM_ERROR when OXR returns 2xx error payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
error: true,
|
||||
status: 401,
|
||||
message: "invalid_app_id",
|
||||
description: "Invalid app_id provided.",
|
||||
})
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
|
||||
expect(err.status).toBe(401);
|
||||
expect(err.upstreamMessage).toBe("invalid_app_id");
|
||||
expect(err.providerName).toBe("open-exchange-rates");
|
||||
}
|
||||
});
|
||||
|
||||
it("raises UPSTREAM_ERROR even without a description field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({ error: true, status: 429, message: "rate_limited" })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
|
||||
status: 429,
|
||||
upstreamMessage: "rate_limited",
|
||||
});
|
||||
});
|
||||
|
||||
it("raises UNKNOWN_CURRENCY when options.targets contains a code absent from upstream payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({ base: "USD", rates: { EUR: 0.91 }, timestamp: 1 })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
try {
|
||||
await provider.fetchRates({ targets: ["EUR", "ZZZ"] });
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
|
||||
expect(err.message).toContain("ZZZ");
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces HTTP_ERROR with providerName on non-2xx upstream response", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() => new Response("", { status: 503, statusText: "down" })
|
||||
);
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k", fetch });
|
||||
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
|
||||
expect(err.status).toBe(503);
|
||||
expect(err.providerName).toBe("open-exchange-rates");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to globalThis.fetch when no fetch is injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (url: string | URL | Request) => {
|
||||
calls.push(String(url));
|
||||
return jsonResponse({
|
||||
base: "USD",
|
||||
rates: { EUR: 1 },
|
||||
timestamp: 1,
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const provider = new OpenExchangeRatesProvider({ apiKey: "k" });
|
||||
await provider.fetchRates();
|
||||
expect(calls.length).toBe(1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import type {
|
||||
CurrencyProvider,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
} from "@suaveplan/currency";
|
||||
import {
|
||||
type ApiKeyConfig,
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
httpRequest,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
const PROVIDER_NAME = "open-exchange-rates";
|
||||
const DEFAULT_URL = "https://openexchangerates.org/api";
|
||||
|
||||
/**
|
||||
* Constructor configuration for {@link OpenExchangeRatesProvider}.
|
||||
*
|
||||
* Extends `@suaveplan/currency-providers` `ApiKeyConfig` with one OXR-specific
|
||||
* opt-in: `allowNonUsdBase`. The free OXR tier only permits a USD base, so by
|
||||
* default this provider refuses any non-USD `base` request synchronously with
|
||||
* `CurrencyProviderError(UNSUPPORTED_BASE)`. Paid plans set this to `true` to
|
||||
* forward the base through to the upstream API.
|
||||
*/
|
||||
export interface OpenExchangeRatesProviderConfig extends ApiKeyConfig {
|
||||
/**
|
||||
* Set to `true` when the configured OXR plan supports any base currency
|
||||
* (paid tiers). Default `false` — non-USD bases are rejected before
|
||||
* issuing the request.
|
||||
*/
|
||||
readonly allowNonUsdBase?: boolean;
|
||||
}
|
||||
|
||||
/** Upstream OXR success payload shape (subset relevant to bulk fetch). */
|
||||
interface OxrSuccessPayload {
|
||||
readonly base: string;
|
||||
readonly rates: Record<string, number>;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
/** Upstream OXR error payload shape (returned with 2xx for some failures). */
|
||||
interface OxrErrorPayload {
|
||||
readonly error: true;
|
||||
readonly status: number;
|
||||
readonly message: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
type OxrPayload = OxrSuccessPayload | OxrErrorPayload;
|
||||
|
||||
/**
|
||||
* `@suaveplan/currency` provider for openexchangerates.org. Wraps
|
||||
* `/latest.json` via the slim-core `httpRequest()` helper, validates the
|
||||
* upstream payload, and surfaces typed `CurrencyProviderError`s for every
|
||||
* failure mode.
|
||||
*/
|
||||
export class OpenExchangeRatesProvider implements CurrencyProvider {
|
||||
public readonly name = PROVIDER_NAME;
|
||||
public readonly supportsCrypto = false;
|
||||
public readonly supportsFiat = true;
|
||||
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly allowNonUsdBase: boolean;
|
||||
private readonly fetchImpl: FetchLike;
|
||||
|
||||
public constructor(config: OpenExchangeRatesProviderConfig) {
|
||||
if (config.apiKey === "") {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.MISSING_CONFIG,
|
||||
`${PROVIDER_NAME}: apiKey is required (OXR app_id)`,
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
this.apiKey = config.apiKey;
|
||||
this.baseUrl = config.baseUrl ?? DEFAULT_URL;
|
||||
this.allowNonUsdBase = config.allowNonUsdBase ?? false;
|
||||
this.fetchImpl = config.fetch ?? (globalThis.fetch as FetchLike);
|
||||
}
|
||||
|
||||
public async fetchRates(
|
||||
options?: RateFetchOptions
|
||||
): Promise<RateFetchResult> {
|
||||
const base = options?.base ?? "USD";
|
||||
if (base !== "USD" && !this.allowNonUsdBase) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UNSUPPORTED_BASE,
|
||||
`${PROVIDER_NAME}: free-tier base must be USD; pass allowNonUsdBase=true for paid plans (requested ${base})`,
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}/latest.json?app_id=${encodeURIComponent(
|
||||
this.apiKey
|
||||
)}&base=${encodeURIComponent(base)}`;
|
||||
|
||||
const response = await httpRequest({
|
||||
url,
|
||||
fetch: this.fetchImpl,
|
||||
providerName: PROVIDER_NAME,
|
||||
...(options?.signal !== undefined
|
||||
? { signal: options.signal }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const payload = await parsePayload(response);
|
||||
if (isErrorPayload(payload)) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
|
||||
`${PROVIDER_NAME}: upstream returned error ${payload.status}: ${payload.message}`,
|
||||
{
|
||||
providerName: PROVIDER_NAME,
|
||||
status: payload.status,
|
||||
upstreamMessage: payload.message,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const filteredRates = filterTargets(payload.rates, options?.targets);
|
||||
return {
|
||||
base: payload.base,
|
||||
rates: filteredRates,
|
||||
timestamp: payload.timestamp * 1000,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the OXR response body and raise `PARSE_ERROR` on shape mismatch. */
|
||||
async function parsePayload(response: Response): Promise<OxrPayload> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await response.json();
|
||||
} catch (cause) {
|
||||
const causeError =
|
||||
cause instanceof Error ? cause : new Error(String(cause));
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
`${PROVIDER_NAME}: response body is not valid JSON`,
|
||||
{ providerName: PROVIDER_NAME, cause: causeError }
|
||||
);
|
||||
}
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
throw parseError("response body is not an object");
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const error = obj["error"];
|
||||
const status = obj["status"];
|
||||
const message = obj["message"];
|
||||
const description = obj["description"];
|
||||
const base = obj["base"];
|
||||
const rates = obj["rates"];
|
||||
const timestamp = obj["timestamp"];
|
||||
if (error === true) {
|
||||
if (typeof status !== "number" || typeof message !== "string") {
|
||||
throw parseError("error payload missing status or message");
|
||||
}
|
||||
const errorPayload: OxrErrorPayload = {
|
||||
error: true,
|
||||
status,
|
||||
message,
|
||||
...(typeof description === "string" ? { description } : {}),
|
||||
};
|
||||
return errorPayload;
|
||||
}
|
||||
if (typeof base !== "string") {
|
||||
throw parseError("payload missing string field 'base'");
|
||||
}
|
||||
if (typeof timestamp !== "number") {
|
||||
throw parseError("payload missing numeric field 'timestamp'");
|
||||
}
|
||||
if (rates === null || typeof rates !== "object" || Array.isArray(rates)) {
|
||||
throw parseError("payload missing object field 'rates'");
|
||||
}
|
||||
return {
|
||||
base,
|
||||
rates: rates as Record<string, number>,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function isErrorPayload(p: OxrPayload): p is OxrErrorPayload {
|
||||
return (p as OxrErrorPayload).error === true;
|
||||
}
|
||||
|
||||
function parseError(detail: string): CurrencyProviderError {
|
||||
return new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
`${PROVIDER_NAME}: ${detail}`,
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the upstream rates map down to `targets`. Missing targets raise
|
||||
* `UNKNOWN_CURRENCY` with the offending code in the message.
|
||||
*/
|
||||
function filterTargets(
|
||||
rates: Record<string, number>,
|
||||
targets: readonly string[] | undefined
|
||||
): Record<string, number> {
|
||||
if (targets === undefined) return rates;
|
||||
const filtered: Record<string, number> = {};
|
||||
for (const t of targets) {
|
||||
const value = rates[t];
|
||||
if (value === undefined) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
|
||||
`${PROVIDER_NAME}: requested target '${t}' not present in upstream rates`,
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
filtered[t] = value;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const pkgRoot = join(__dirname, "..");
|
||||
const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")) as {
|
||||
name: string;
|
||||
sideEffects: boolean;
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
describe("dep-graph invariants", () => {
|
||||
it("declares sideEffects: false", { timeout: 5000 }, () => {
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("declares the expected runtime dependencies", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(pkg.dependencies).sort()).toEqual([
|
||||
"@suaveplan/currency",
|
||||
"@suaveplan/currency-providers",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses workspace:^ for every @suaveplan/* dep", { timeout: 5000 }, () => {
|
||||
for (const [name, range] of Object.entries(pkg.dependencies)) {
|
||||
if (name.startsWith("@suaveplan/")) {
|
||||
expect(range).toBe("workspace:^");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("pins package name and version baseline", { timeout: 5000 }, () => {
|
||||
expect(pkg.name).toBe(
|
||||
"@suaveplan/currency-provider-open-exchange-rates"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// Polyfills for test environment — none required. The package is universal
|
||||
// and uses only `globalThis.fetch` (overridable via FetchLike injection),
|
||||
// `Response`, `Number.parseFloat`, `Date.now`, `Map`, and `Object.entries` —
|
||||
// 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,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir:
|
||||
"../../../node_modules/.vite/packages/currency-provider-open-exchange-rates",
|
||||
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