Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,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.

View 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"
]
}

View File

@@ -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`.

View File

@@ -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);
}
}
});
});

View File

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

View File

@@ -0,0 +1 @@
export { FixerProvider } from "./fixer.js";

View File

@@ -0,0 +1 @@
export { FixerProvider } from "./fixer/fixer.js";

View File

@@ -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 { };

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/currency-provider-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",
],
},
},
});

View File

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