Initial Commit
This commit is contained in:
89
packages/money/finance/currency-provider-fixer/README.md
Normal file
89
packages/money/finance/currency-provider-fixer/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# @suaveplan/currency-provider-fixer - Fixer.io fiat-rates CurrencyProvider sibling of @suaveplan/currency-providers. API-key required; 170+ fiat currencies; maps upstream error codes (105 → UNSUPPORTED_BASE, 201/202 → UNKNOWN_CURRENCY, other success:false → UPSTREAM_ERROR) to the slim core's typed CurrencyProviderError. Universal, FetchLike-injectable.
|
||||
|
||||
[Fixer.io](https://fixer.io/) fiat-rates sibling provider for the slim
|
||||
`@suaveplan/currency-providers` core. Implements the `CurrencyProvider`
|
||||
contract from `@suaveplan/currency`, requires an API key, and maps the
|
||||
upstream `{ success, error: { code, info } }` envelope to typed
|
||||
`CurrencyProviderError` codes — including the free-tier
|
||||
`base_currency_access_restricted` case (`UNSUPPORTED_BASE`).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [fixer](./src/fixer/fixer.md) — `FixerProvider` is the Fixer.io sibling provider for the slim `@suaveplan/currency-providers` core. It implements the `…
|
||||
- [fixer](./src/fixer/fixer.md) — `FixerProvider` is the Fixer.io sibling provider for the slim `@suaveplan/currency-providers` core. It implements the `…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/currency-provider-fixer \
|
||||
@suaveplan/currency-providers \
|
||||
@suaveplan/currency
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
|
||||
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
|
||||
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(new FixerProvider({ apiKey: process.env.FIXER_KEY! }));
|
||||
|
||||
const converter = new CurrencyConverter({
|
||||
provider: registry.get("fixer"),
|
||||
staleDurationMs: 3_600_000,
|
||||
});
|
||||
|
||||
const rate = await converter.getRate("EUR", "GBP");
|
||||
```
|
||||
|
||||
Direct instantiation without the registry is equally supported:
|
||||
|
||||
```ts
|
||||
const provider = new FixerProvider({ apiKey: process.env.FIXER_KEY! });
|
||||
const result = await provider.fetchRates();
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `CurrencyProvider` (`name = "fixer"`, `supportsFiat = true`,
|
||||
`supportsCrypto = false`).
|
||||
- Constructor takes the slim core's shared `ApiKeyConfig`; an empty
|
||||
`apiKey` raises `CURRENCY_PROVIDER.MISSING_CONFIG` synchronously
|
||||
before any network call (no wasted quota).
|
||||
- HTTP transport via the slim core's `httpRequest` — `AbortSignal`
|
||||
composition, non-2xx → typed `HTTP_ERROR`, `providerName` / `status`
|
||||
populated on the raised error.
|
||||
- Typed upstream-code mapping: `105` → `UNSUPPORTED_BASE`, `201` / `202`
|
||||
→ `UNKNOWN_CURRENCY`, any other `success: false` → `UPSTREAM_ERROR`
|
||||
with `upstreamMessage` carrying the upstream `error.info`.
|
||||
- `PARSE_ERROR` for malformed JSON, non-object JSON, and successful
|
||||
envelopes missing required fields.
|
||||
- Universal — runs unchanged under Node 18+, Bun, modern browsers,
|
||||
and Web Workers.
|
||||
- One subpath export, one feature module; explicit named barrels;
|
||||
100/100/100/100 istanbul coverage.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/fixer/fixer.md`](./src/fixer/fixer.md) — `FixerProvider` class,
|
||||
envelope handling, error-code mapping table.
|
||||
|
||||
## Sibling packages
|
||||
|
||||
Part of the foundation-with-sibling-plugins family established by
|
||||
the design document. Other concrete
|
||||
providers (ECB, Open Exchange Rates, Binance, CoinGecko, CoinMarketCap,
|
||||
CryptoCompare, Kraken, Virtual) ship as separate
|
||||
`@suaveplan/currency-provider-<name>` packages.
|
||||
|
||||
## Status
|
||||
|
||||
`0.1.0` (pre-graduation). New native sibling landed on
|
||||
2026-05-17 against the slimmed core. See
|
||||
the design proposal
|
||||
for the proposal, design, and parity checklist.
|
||||
|
||||
68
packages/money/finance/currency-provider-fixer/package.json
Normal file
68
packages/money/finance/currency-provider-fixer/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@suaveplan/currency-provider-fixer",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Fixer.io fiat-rates CurrencyProvider sibling of @suaveplan/currency-providers. API-key required; 170+ fiat currencies; maps upstream error codes (105 → UNSUPPORTED_BASE, 201/202 → UNKNOWN_CURRENCY, other success:false → UPSTREAM_ERROR) to the slim core's typed CurrencyProviderError. Universal, FetchLike-injectable.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./fixer/fixer": {
|
||||
"import": {
|
||||
"types": "./dist/fixer/fixer.d.ts",
|
||||
"default": "./dist/fixer/fixer.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,154 @@
|
||||
# FixerProvider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`FixerProvider` is the [Fixer.io](https://fixer.io/) sibling provider for
|
||||
the slim `@suaveplan/currency-providers` core. It implements the
|
||||
`CurrencyProvider` contract from `@suaveplan/currency`, fetches the
|
||||
latest fiat exchange rates from the Fixer `/latest` REST endpoint, and
|
||||
maps the upstream JSON envelope into the canonical `RateFetchResult`
|
||||
shape the converter expects.
|
||||
|
||||
Fixer.io requires an API key on every request. Free-tier accounts are
|
||||
restricted to `base = "EUR"` and a fixed monthly request quota; paid plans
|
||||
unlock arbitrary base currencies and higher throughput. The provider
|
||||
surfaces the free-tier restriction as a typed `UNSUPPORTED_BASE` error so
|
||||
consumers can dispatch on the discriminator rather than parsing the
|
||||
upstream message string.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `CurrencyProvider` with stable identity flags
|
||||
(`name = "fixer"`, `supportsFiat = true`, `supportsCrypto = false`).
|
||||
- Constructor takes the slim core's shared `ApiKeyConfig` (`apiKey`,
|
||||
optional `baseUrl`, optional injectable `fetch`). An empty `apiKey`
|
||||
raises `CURRENCY_PROVIDER.MISSING_CONFIG` synchronously before any
|
||||
network call is issued, preventing wasted quota.
|
||||
- Delegates HTTP transport to the slim core's `httpRequest` helper —
|
||||
`AbortSignal` composition, non-2xx → typed `HTTP_ERROR` mapping, and
|
||||
`providerName` / `status` carry on the raised error.
|
||||
- Maps the upstream `{ success: false, error: { code, info } }` envelope
|
||||
to typed codes: `105` → `UNSUPPORTED_BASE`, `201` / `202` →
|
||||
`UNKNOWN_CURRENCY`, any other code → `UPSTREAM_ERROR`. The upstream
|
||||
`error.info` is preserved on `upstreamMessage` so diagnostics survive.
|
||||
- Distinguishes parse failures (non-JSON, non-object JSON, success
|
||||
envelope missing required fields) under a single `PARSE_ERROR` code.
|
||||
- Universal — no `node:` / `bun:` / DOM imports; runs unchanged
|
||||
under Node 18+, Bun, modern browsers, and Web Workers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
|
||||
|
||||
const provider = new FixerProvider({ apiKey: process.env.FIXER_KEY! });
|
||||
|
||||
// Free-tier: base is implicitly EUR.
|
||||
const result = await provider.fetchRates();
|
||||
console.log(result.base, result.rates.GBP, result.timestamp);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire the provider through the slim core's `CurrencyProviderRegistry` and
|
||||
use it as the primary source for a `CurrencyConverter`. The injected
|
||||
`FetchLike` makes the provider trivial to unit-test against a fixture
|
||||
fetch closure:
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
|
||||
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
|
||||
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(
|
||||
new FixerProvider({
|
||||
apiKey: process.env.FIXER_KEY!,
|
||||
// Optional: proxy through your own gateway for caching / auditing.
|
||||
baseUrl: "https://rates.internal/fixer/api",
|
||||
// Optional: inject a wrapped fetch for retries or logging.
|
||||
fetch: async (url, init) => {
|
||||
const response = await globalThis.fetch(url, init);
|
||||
return response;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const converter = new CurrencyConverter({
|
||||
provider: registry.get("fixer"),
|
||||
staleDurationMs: 3_600_000,
|
||||
});
|
||||
|
||||
// Free-tier accounts MUST keep base = "EUR" — supplying any other base
|
||||
// raises CurrencyProviderError(UNSUPPORTED_BASE) via the typed mapping
|
||||
// from upstream error code 105.
|
||||
const rate = await converter.getRate("EUR", "GBP");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class FixerProvider implements CurrencyProvider`
|
||||
|
||||
- `readonly name = "fixer"`
|
||||
- `readonly supportsCrypto = false`
|
||||
- `readonly supportsFiat = true`
|
||||
|
||||
#### `constructor(config: ApiKeyConfig)`
|
||||
|
||||
Validates `config.apiKey` is non-empty (throws
|
||||
`CurrencyProviderError(MISSING_CONFIG)` otherwise) and caches
|
||||
`config.baseUrl ?? "https://data.fixer.io/api"` and
|
||||
`config.fetch ?? globalThis.fetch`.
|
||||
|
||||
**Throws**: `CurrencyProviderError(MISSING_CONFIG)` when `apiKey === ""`.
|
||||
|
||||
#### `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
|
||||
|
||||
Builds `?access_key=…&base=…[&symbols=…]`, delegates to the slim core's
|
||||
`httpRequest`, parses the JSON envelope, and returns the canonical
|
||||
`RateFetchResult` with `timestamp` converted from Unix seconds to
|
||||
milliseconds.
|
||||
|
||||
**Parameters** (all optional): `base` (default `"EUR"`), `targets`
|
||||
(array of currency codes — joined into `symbols`), `signal`
|
||||
(`AbortSignal` forwarded to the underlying fetch).
|
||||
|
||||
**Returns**: `{ base, rates, timestamp }` where `timestamp` is
|
||||
milliseconds since epoch.
|
||||
|
||||
**Throws** (all `CurrencyProviderError`):
|
||||
|
||||
- `MISSING_CONFIG` — at construction when `apiKey` is empty.
|
||||
- `HTTP_ERROR` — non-2xx upstream response (via `httpRequest`).
|
||||
- `PARSE_ERROR` — body is not JSON, parsed value is not an object, or
|
||||
a `success: true` envelope is missing `base` / `rates` / `timestamp`.
|
||||
- `UNSUPPORTED_BASE` — upstream code `105` (free-tier base restriction).
|
||||
- `UNKNOWN_CURRENCY` — upstream code `201` (`invalid_base_currency`) or
|
||||
`202` (`invalid_currency_codes`).
|
||||
- `UPSTREAM_ERROR` — any other `success: false` envelope, including the
|
||||
case where `error` is absent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Fixer.io returns HTTP 200 even for application-level errors; the
|
||||
provider must therefore parse every body and discriminate on
|
||||
`success`. The non-2xx path (`HTTP_ERROR`) is reserved for genuine
|
||||
network / gateway failures.
|
||||
- The upstream `error.code` integer is the discriminator, not
|
||||
`error.type` — the integer is one comparison and avoids string
|
||||
allocation. The human-readable `error.info` is preserved on
|
||||
`upstreamMessage` for diagnostics.
|
||||
- The `apiKey` non-empty guard at construction is suaveplan-native — the
|
||||
upstream skeleton allowed an empty string and let the API reject on the
|
||||
wire, wasting the request against the monthly quota.
|
||||
- `fetchRates` forwards `options.signal` through `httpRequest` so the
|
||||
caller can cancel an in-flight request from outside the provider; this
|
||||
too is suaveplan-native (upstream did not propagate the signal).
|
||||
- The package is universal and free of side effects (`sideEffects:
|
||||
false`). Importing the package never mutates any global registry — the
|
||||
caller decides whether to register the instance with
|
||||
`CurrencyProviderRegistry`.
|
||||
@@ -0,0 +1,465 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import { FixerProvider } from "./fixer.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("FixerProvider — identity", () => {
|
||||
it("exposes the stable identity flags name/supportsFiat/supportsCrypto", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new FixerProvider({ apiKey: "k" });
|
||||
expect(provider.name).toBe("fixer");
|
||||
expect(provider.supportsFiat).toBe(true);
|
||||
expect(provider.supportsCrypto).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FixerProvider — construction", () => {
|
||||
it("raises MISSING_CONFIG when apiKey is empty at construction", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new FixerProvider({ 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("fixer");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FixerProvider — request construction", () => {
|
||||
it("defaults the request base URL to data.fixer.io when no override is provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
base: "EUR",
|
||||
rates: { USD: 1.1 },
|
||||
timestamp: 1_700_000_000,
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", fetch });
|
||||
await provider.fetchRates();
|
||||
expect(
|
||||
calls[0]?.url.startsWith("https://data.fixer.io/api/latest?")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("honours a custom baseUrl override", { timeout: 5000 }, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
base: "EUR",
|
||||
rates: {},
|
||||
timestamp: 1,
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({
|
||||
apiKey: "k",
|
||||
baseUrl: "https://proxy/api",
|
||||
fetch,
|
||||
});
|
||||
await provider.fetchRates();
|
||||
expect(calls[0]?.url.startsWith("https://proxy/api/latest?")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults base to EUR and forwards access_key, omits symbols when targets is unset", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
base: "EUR",
|
||||
rates: {},
|
||||
timestamp: 1,
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", fetch });
|
||||
await provider.fetchRates();
|
||||
const url = calls[0]?.url ?? "";
|
||||
expect(url.includes("access_key=k")).toBe(true);
|
||||
expect(url.includes("base=EUR")).toBe(true);
|
||||
expect(url.includes("symbols=")).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards a custom base and comma-joins targets into the symbols param", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
base: "USD",
|
||||
rates: {},
|
||||
timestamp: 1,
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", fetch });
|
||||
await provider.fetchRates({ base: "USD", targets: ["EUR", "GBP"] });
|
||||
const url = calls[0]?.url ?? "";
|
||||
expect(url.includes("base=USD")).toBe(true);
|
||||
expect(url.includes("symbols=EUR%2CGBP")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores an empty targets array and omits the symbols param", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
base: "EUR",
|
||||
rates: {},
|
||||
timestamp: 1,
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", fetch });
|
||||
await provider.fetchRates({ targets: [] });
|
||||
expect(calls[0]?.url.includes("symbols=")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FixerProvider — FetchLike injection", () => {
|
||||
it("invokes the injected FetchLike instead of globalThis.fetch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
base: "EUR",
|
||||
rates: {},
|
||||
timestamp: 1,
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", fetch });
|
||||
await provider.fetchRates();
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to globalThis.fetch when no fetch is injected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Cannot exercise the network from a unit test; instead assert the
|
||||
// constructor accepts an ApiKeyConfig without `fetch` and produces
|
||||
// a working instance. The `globalThis.fetch` default is reached at
|
||||
// construction time and is the only code path that uses the global.
|
||||
const provider = new FixerProvider({ apiKey: "k" });
|
||||
expect(provider.name).toBe("fixer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FixerProvider — successful envelope", () => {
|
||||
it("returns the canonical RateFetchResult with the timestamp converted from seconds to milliseconds", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
base: "EUR",
|
||||
rates: { GBP: 0.85 },
|
||||
timestamp: 1_700_000_000,
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", fetch });
|
||||
const result = await provider.fetchRates();
|
||||
expect(result.base).toBe("EUR");
|
||||
expect(result.rates.GBP).toBe(0.85);
|
||||
expect(result.timestamp).toBe(1_700_000_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FixerProvider — typed error mapping", () => {
|
||||
it("maps upstream error.code 105 to UNSUPPORTED_BASE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: false,
|
||||
error: {
|
||||
code: 105,
|
||||
type: "base_currency_access_restricted",
|
||||
info: "Access Restricted",
|
||||
},
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", fetch });
|
||||
try {
|
||||
await provider.fetchRates({ base: "USD" });
|
||||
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.upstreamMessage).toBe("Access Restricted");
|
||||
expect(err.providerName).toBe("fixer");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps upstream error.code 201 to UNKNOWN_CURRENCY", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: false,
|
||||
error: { code: 201, info: "invalid base" },
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", fetch });
|
||||
try {
|
||||
await provider.fetchRates({ base: "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.upstreamMessage).toBe("invalid base");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps upstream error.code 202 to UNKNOWN_CURRENCY", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: false,
|
||||
error: { code: 202, info: "invalid symbols" },
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", fetch });
|
||||
try {
|
||||
await provider.fetchRates({ targets: ["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.upstreamMessage).toBe("invalid symbols");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps any other success:false code to UPSTREAM_ERROR with upstreamMessage", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
success: false,
|
||||
error: { code: 999, info: "rate limit exceeded" },
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ 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.upstreamMessage).toBe("rate limit exceeded");
|
||||
expect(err.providerName).toBe("fixer");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps success:false with no error field to UPSTREAM_ERROR with <unknown>", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({ success: false })
|
||||
);
|
||||
const provider = new FixerProvider({ 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.upstreamMessage).toBe("<unknown>");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FixerProvider — parse failures", () => {
|
||||
it("raises PARSE_ERROR when the response body is not valid JSON", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() => new Response("<html>", { status: 200 })
|
||||
);
|
||||
const provider = new FixerProvider({ 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.providerName).toBe("fixer");
|
||||
expect(err.cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when the parsed JSON is null or a non-object", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cases = ["null", '"a string"', "42", "[1,2,3]"];
|
||||
for (const body of cases) {
|
||||
const { fetch } = recordingFetch(
|
||||
() =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", fetch });
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
expect.fail(`fetchRates should have rejected for body ${body}`);
|
||||
} catch (error) {
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when a success envelope is missing required fields", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => jsonResponse({ success: true }));
|
||||
const provider = new FixerProvider({ 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FixerProvider — HTTP transport via slim core", () => {
|
||||
it("raises HTTP_ERROR with status and providerName on a non-2xx response", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() =>
|
||||
new Response("Service Unavailable", {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
})
|
||||
);
|
||||
const provider = new FixerProvider({ apiKey: "k", 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.status).toBe(503);
|
||||
expect(err.providerName).toBe("fixer");
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards the caller AbortSignal to the underlying fetch", {
|
||||
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 FixerProvider({ apiKey: "k", fetch: fetchImpl });
|
||||
const promise = provider.fetchRates({ signal: controller.signal });
|
||||
controller.abort(new Error("caller cancelled"));
|
||||
await expect(promise).rejects.toThrow(/caller cancelled/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FixerProvider — package shape", () => {
|
||||
it("has no side effect at import time and exposes only FixerProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mod = await import("../index.js");
|
||||
const keys = Object.keys(mod).sort();
|
||||
expect(keys).toEqual(["FixerProvider"]);
|
||||
expect(typeof mod.FixerProvider).toBe("function");
|
||||
});
|
||||
|
||||
it("imports only universal modules — no node:, bun:, fs, or DOM-only globals", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const banned = [
|
||||
/from\s+['"]node:/,
|
||||
/from\s+['"]bun:/,
|
||||
/from\s+['"]fs['"]/,
|
||||
/from\s+['"]path['"]/,
|
||||
];
|
||||
const srcDir = join(__dirname, "..");
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
full.endsWith(".ts") &&
|
||||
!full.endsWith(".test.ts") &&
|
||||
!full.endsWith(".types.ts")
|
||||
) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(srcDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
for (const pattern of banned) {
|
||||
expect(pattern.test(text)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
CurrencyProvider,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
} from "@suaveplan/currency";
|
||||
import type { ApiKeyConfig } from "@suaveplan/currency-providers";
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
httpRequest,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
/** Default Fixer.io REST endpoint. Free-tier and paid plans share the host. */
|
||||
const DEFAULT_URL = "https://data.fixer.io/api";
|
||||
/** Stable identifier surfaced on every raised `CurrencyProviderError`. */
|
||||
const PROVIDER_NAME = "fixer";
|
||||
/** Upstream code → typed `CurrencyProviderError` code mapping. */
|
||||
const FIXER_FREE_TIER_BASE_RESTRICTED = 105;
|
||||
const FIXER_INVALID_BASE_CURRENCY = 201;
|
||||
const FIXER_INVALID_CURRENCY_CODES = 202;
|
||||
|
||||
/**
|
||||
* Shape of the upstream `{ success: false, error: { code, type, info } }`
|
||||
* envelope. `error` may be absent on malformed responses — the discriminator
|
||||
* tolerates that and falls back to `UPSTREAM_ERROR` with `<unknown>`.
|
||||
*/
|
||||
interface FixerErrorPayload {
|
||||
readonly code?: number;
|
||||
readonly type?: string;
|
||||
readonly info?: string;
|
||||
}
|
||||
|
||||
/** Common shape returned by the `/latest` endpoint. Discriminated by `success`. */
|
||||
interface FixerEnvelope {
|
||||
readonly success?: boolean;
|
||||
readonly error?: FixerErrorPayload;
|
||||
readonly base?: string;
|
||||
readonly rates?: Readonly<Record<string, number>>;
|
||||
readonly timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixer.io exchange-rate provider. Implements the `CurrencyProvider` contract
|
||||
* from `@suaveplan/currency` and delegates HTTP transport to the slim core's
|
||||
* `httpRequest` helper so caller-supplied `AbortSignal`s are honoured and
|
||||
* non-2xx responses surface as `CURRENCY_PROVIDER.HTTP_ERROR`.
|
||||
*
|
||||
* Free-tier accounts are restricted to `base = "EUR"`; the provider maps the
|
||||
* upstream `error.code === 105` envelope to `UNSUPPORTED_BASE` so callers
|
||||
* can dispatch on the typed code rather than parsing the upstream message.
|
||||
*/
|
||||
export class FixerProvider 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 fetchImpl: FetchLike;
|
||||
|
||||
public constructor(config: ApiKeyConfig) {
|
||||
if (config.apiKey === "") {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.MISSING_CONFIG,
|
||||
"FixerProvider requires a non-empty apiKey",
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
this.apiKey = config.apiKey;
|
||||
this.baseUrl = config.baseUrl ?? DEFAULT_URL;
|
||||
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
||||
}
|
||||
|
||||
public async fetchRates(
|
||||
options?: RateFetchOptions
|
||||
): Promise<RateFetchResult> {
|
||||
const url = this.buildUrl(options);
|
||||
const response = await httpRequest({
|
||||
url,
|
||||
fetch: this.fetchImpl,
|
||||
providerName: PROVIDER_NAME,
|
||||
...(options?.signal !== undefined
|
||||
? { signal: options.signal }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const data = await parseEnvelope(response);
|
||||
|
||||
if (data.success === true) {
|
||||
return projectSuccess(data);
|
||||
}
|
||||
throw mapErrorEnvelope(data.error);
|
||||
}
|
||||
|
||||
private buildUrl(options: RateFetchOptions | undefined): string {
|
||||
const params = new URLSearchParams({
|
||||
access_key: this.apiKey,
|
||||
base: options?.base ?? "EUR",
|
||||
});
|
||||
if (options?.targets && options.targets.length > 0) {
|
||||
params.set("symbols", options.targets.join(","));
|
||||
}
|
||||
return `${this.baseUrl}/latest?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseEnvelope(response: Response): Promise<FixerEnvelope> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch (cause) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
`${PROVIDER_NAME} returned a body that could not be parsed as JSON`,
|
||||
{ providerName: PROVIDER_NAME, cause }
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
`${PROVIDER_NAME} returned a non-object JSON body`,
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
return parsed as FixerEnvelope;
|
||||
}
|
||||
|
||||
function projectSuccess(data: FixerEnvelope): RateFetchResult {
|
||||
if (
|
||||
typeof data.base !== "string" ||
|
||||
data.rates === undefined ||
|
||||
typeof data.timestamp !== "number"
|
||||
) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
`${PROVIDER_NAME} returned a success envelope missing required fields`,
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
return {
|
||||
base: data.base,
|
||||
rates: data.rates,
|
||||
timestamp: data.timestamp * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function mapErrorEnvelope(
|
||||
error: FixerErrorPayload | undefined
|
||||
): CurrencyProviderError {
|
||||
const info = error?.info ?? "<unknown>";
|
||||
const code = error?.code;
|
||||
if (code === FIXER_FREE_TIER_BASE_RESTRICTED) {
|
||||
return new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UNSUPPORTED_BASE,
|
||||
`${PROVIDER_NAME} rejected the requested base currency: ${info}`,
|
||||
{
|
||||
providerName: PROVIDER_NAME,
|
||||
upstreamMessage: info,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (
|
||||
code === FIXER_INVALID_BASE_CURRENCY ||
|
||||
code === FIXER_INVALID_CURRENCY_CODES
|
||||
) {
|
||||
return new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
|
||||
`${PROVIDER_NAME} rejected a currency code: ${info}`,
|
||||
{
|
||||
providerName: PROVIDER_NAME,
|
||||
upstreamMessage: info,
|
||||
}
|
||||
);
|
||||
}
|
||||
return new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
|
||||
`${PROVIDER_NAME} upstream error: ${info}`,
|
||||
{
|
||||
providerName: PROVIDER_NAME,
|
||||
upstreamMessage: info,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FixerProvider } from "./fixer.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { FixerProvider } from "./fixer/fixer.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`, `JSON.parse`, `Number`, `Object.freeze`, and
|
||||
// `Map`-style record access — available in every supported runtime
|
||||
// (Node 18+, Bun, browsers, Web Workers). This file exists to give vitest a
|
||||
// stable setup file path mirroring the slim core's layout.
|
||||
export { };
|
||||
12
packages/money/finance/currency-provider-fixer/tsconfig.json
Normal file
12
packages/money/finance/currency-provider-fixer/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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-fixer",
|
||||
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