@financica/ecb-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The currencies for which the European Central Bank publishes daily euro
3
+ * foreign-exchange reference rates, as of the ECB's current daily reference
4
+ * list. Every rate is quoted as units of the currency per 1 EUR.
5
+ *
6
+ * The ECB occasionally adds or suspends currencies. This list is a convenience
7
+ * for callers and the source of the {@link EcbCurrency} literal union; the
8
+ * client never restricts lookups to it, so any ISO 4217 code the ECB exposes
9
+ * (including discontinued series) can still be requested.
10
+ */
11
+ declare const ECB_CURRENCIES: readonly ["AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PLN", "RON", "SEK", "SGD", "THB", "TRY", "USD", "ZAR"];
12
+ /** A currency on the ECB's daily reference list. */
13
+ type EcbCurrency = (typeof ECB_CURRENCIES)[number];
14
+ /** The base currency of every ECB reference rate. */
15
+ declare const BASE_CURRENCY = "EUR";
16
+ /** Narrowing guard: is `code` on the ECB daily reference list? */
17
+ declare const isEcbCurrency: (code: string) => code is EcbCurrency;
18
+
19
+ /** An ISO 8601 calendar date, formatted `YYYY-MM-DD`. */
20
+ type IsoDate = string;
21
+ /**
22
+ * A currency code. ECB reference currencies are strongly typed for
23
+ * autocomplete; any other ISO 4217 code is still accepted. The `string & {}`
24
+ * member preserves literal autocomplete while widening to accept any string.
25
+ */
26
+ type CurrencyCode = EcbCurrency | "EUR" | (string & {});
27
+ /** A single euro reference-rate observation. */
28
+ interface ReferenceRate {
29
+ /** ISO 4217 code of the quoted currency. */
30
+ currency: CurrencyCode;
31
+ /** Units of {@link currency} per 1 EUR. */
32
+ rate: number;
33
+ /**
34
+ * The date the observation actually applies to. Equal to the requested date
35
+ * on a TARGET business day, or the most recent prior business day when the
36
+ * requested date had no published rate (weekend, holiday).
37
+ */
38
+ date: IsoDate;
39
+ }
40
+ /** A set of reference rates resolved for a single requested date. */
41
+ interface RateSnapshot {
42
+ /** The date that was requested, normalized to `YYYY-MM-DD`. */
43
+ requestedDate: IsoDate;
44
+ /**
45
+ * One entry per currency. Each entry's effective {@link ReferenceRate.date}
46
+ * may differ — discontinued series resolve to their last published date.
47
+ */
48
+ rates: ReferenceRate[];
49
+ }
50
+ /** The outcome of converting an amount between two currencies. */
51
+ interface ConvertResult {
52
+ /** The converted amount, expressed in {@link to}. Not rounded. */
53
+ amount: number;
54
+ /** The effective {@link from} → {@link to} rate applied. */
55
+ rate: number;
56
+ /** Source currency. */
57
+ from: CurrencyCode;
58
+ /** Target currency. */
59
+ to: CurrencyCode;
60
+ /** The requested date, normalized to `YYYY-MM-DD`. */
61
+ requestedDate: IsoDate;
62
+ /** The effective observation date of the rate(s) applied. */
63
+ rateDate: IsoDate;
64
+ }
65
+
66
+ /**
67
+ * The subset of the WHATWG `fetch` contract this client relies on. The global
68
+ * `fetch` satisfies it, so callers rarely need to pass one — it exists to make
69
+ * the client trivially testable and proxy-friendly.
70
+ */
71
+ type FetchLike = (input: string, init?: {
72
+ headers?: Record<string, string>;
73
+ signal?: AbortSignal;
74
+ }) => Promise<{
75
+ ok: boolean;
76
+ status: number;
77
+ text: () => Promise<string>;
78
+ }>;
79
+ /** A cache for resolved snapshots, keyed by request URL. */
80
+ interface RateCache {
81
+ get(key: string): RateSnapshot | undefined;
82
+ set(key: string, value: RateSnapshot): void;
83
+ }
84
+ interface EcbClientOptions {
85
+ /**
86
+ * Base URL of the ECB data API.
87
+ * Default: `https://data-api.ecb.europa.eu/service`.
88
+ */
89
+ baseUrl?: string;
90
+ /** Custom fetch implementation. Defaults to the global `fetch`. */
91
+ fetch?: FetchLike;
92
+ /**
93
+ * Snapshot cache. Defaults to an unbounded in-process `Map`. Pass `null` to
94
+ * disable caching entirely.
95
+ */
96
+ cache?: RateCache | null;
97
+ /** Request timeout in milliseconds. Default `15000`; `0` disables it. */
98
+ timeoutMs?: number;
99
+ }
100
+ /**
101
+ * A small client for the European Central Bank's euro foreign-exchange
102
+ * reference rates, served from the ECB data API (dataflow `EXR`).
103
+ *
104
+ * Every rate is quoted against the euro (units of the currency per 1 EUR).
105
+ * Requests for a date that is not a TARGET business day resolve to the most
106
+ * recent prior business day, via the data API's `lastNObservations` parameter,
107
+ * and the resolved date is reported on each {@link ReferenceRate}.
108
+ */
109
+ declare class EcbClient {
110
+ private readonly baseUrl;
111
+ private readonly fetchImpl;
112
+ private readonly cache;
113
+ private readonly timeoutMs;
114
+ constructor(options?: EcbClientOptions);
115
+ /**
116
+ * Resolve the reference rate for a single currency on (or before) a date.
117
+ * `EUR` returns a unit rate without a network call.
118
+ */
119
+ getRate(currency: CurrencyCode, date: Date | IsoDate): Promise<ReferenceRate>;
120
+ /**
121
+ * Resolve reference rates on (or before) a date. Pass `currencies` to limit
122
+ * the request, or omit it to fetch every series the ECB publishes (note
123
+ * that discontinued series resolve to their last published date).
124
+ */
125
+ getRates(date: Date | IsoDate, currencies?: readonly CurrencyCode[]): Promise<RateSnapshot>;
126
+ /**
127
+ * Convert `amount` from one currency to another using the reference rates on
128
+ * (or before) `date`. Non-euro pairs are crossed through the euro.
129
+ */
130
+ convert(params: {
131
+ amount: number;
132
+ from: CurrencyCode;
133
+ to: CurrencyCode;
134
+ date: Date | IsoDate;
135
+ }): Promise<ConvertResult>;
136
+ private legFor;
137
+ private fetchSnapshot;
138
+ private get;
139
+ }
140
+
141
+ /** Base class for every error thrown by this package. */
142
+ declare class EcbError extends Error {
143
+ constructor(message: string, options?: {
144
+ cause?: unknown;
145
+ });
146
+ }
147
+ /** The ECB data API returned a non-2xx response. */
148
+ declare class EcbHttpError extends EcbError {
149
+ readonly status: number;
150
+ readonly body: string;
151
+ constructor(status: number, body: string);
152
+ }
153
+ /** No reference rate exists for the currency on or before the requested date. */
154
+ declare class NoRateError extends EcbError {
155
+ readonly currency: CurrencyCode;
156
+ readonly date: IsoDate;
157
+ constructor(currency: CurrencyCode, date: IsoDate);
158
+ }
159
+
160
+ /**
161
+ * Minimal RFC 4180 CSV parser, sufficient for the ECB data API's `text/csv`
162
+ * responses. Handles quoted fields containing commas, line breaks and escaped
163
+ * (doubled) quotes — the ECB's `TITLE_COMPL` column, for instance, embeds
164
+ * commas inside quotes.
165
+ */
166
+ declare const parseCsv: (text: string) => Record<string, string>[];
167
+
168
+ export { BASE_CURRENCY, type ConvertResult, type CurrencyCode, ECB_CURRENCIES, EcbClient, type EcbClientOptions, type EcbCurrency, EcbError, EcbHttpError, type FetchLike, type IsoDate, NoRateError, type RateCache, type RateSnapshot, type ReferenceRate, isEcbCurrency, parseCsv };
package/dist/index.js ADDED
@@ -0,0 +1,282 @@
1
+ // src/currencies.ts
2
+ var ECB_CURRENCIES = [
3
+ "AUD",
4
+ "BGN",
5
+ "BRL",
6
+ "CAD",
7
+ "CHF",
8
+ "CNY",
9
+ "CZK",
10
+ "DKK",
11
+ "GBP",
12
+ "HKD",
13
+ "HUF",
14
+ "IDR",
15
+ "ILS",
16
+ "INR",
17
+ "ISK",
18
+ "JPY",
19
+ "KRW",
20
+ "MXN",
21
+ "MYR",
22
+ "NOK",
23
+ "NZD",
24
+ "PHP",
25
+ "PLN",
26
+ "RON",
27
+ "SEK",
28
+ "SGD",
29
+ "THB",
30
+ "TRY",
31
+ "USD",
32
+ "ZAR"
33
+ ];
34
+ var BASE_CURRENCY = "EUR";
35
+ var ECB_CURRENCY_SET = new Set(ECB_CURRENCIES);
36
+ var isEcbCurrency = (code) => ECB_CURRENCY_SET.has(code);
37
+
38
+ // src/csv.ts
39
+ var parseCsv = (text) => {
40
+ const rows = splitRows(text);
41
+ const header = rows.shift();
42
+ if (!header) return [];
43
+ const records = [];
44
+ for (const row of rows) {
45
+ if (row.length === 1 && row[0] === "") continue;
46
+ const record = {};
47
+ for (let i = 0; i < header.length; i++) {
48
+ record[header[i] ?? `column_${i}`] = row[i] ?? "";
49
+ }
50
+ records.push(record);
51
+ }
52
+ return records;
53
+ };
54
+ var splitRows = (text) => {
55
+ const rows = [];
56
+ let row = [];
57
+ let field = "";
58
+ let inQuotes = false;
59
+ for (let i = 0; i < text.length; i++) {
60
+ const char = text[i];
61
+ if (inQuotes) {
62
+ if (char === '"') {
63
+ if (text[i + 1] === '"') {
64
+ field += '"';
65
+ i++;
66
+ } else {
67
+ inQuotes = false;
68
+ }
69
+ } else {
70
+ field += char;
71
+ }
72
+ continue;
73
+ }
74
+ if (char === '"') {
75
+ inQuotes = true;
76
+ } else if (char === ",") {
77
+ row.push(field);
78
+ field = "";
79
+ } else if (char === "\n" || char === "\r") {
80
+ if (char === "\r" && text[i + 1] === "\n") i++;
81
+ row.push(field);
82
+ rows.push(row);
83
+ row = [];
84
+ field = "";
85
+ } else {
86
+ field += char;
87
+ }
88
+ }
89
+ if (field !== "" || row.length > 0) {
90
+ row.push(field);
91
+ rows.push(row);
92
+ }
93
+ return rows;
94
+ };
95
+
96
+ // src/errors.ts
97
+ var EcbError = class extends Error {
98
+ constructor(message, options) {
99
+ super(message, options);
100
+ this.name = "EcbError";
101
+ }
102
+ };
103
+ var EcbHttpError = class extends EcbError {
104
+ status;
105
+ body;
106
+ constructor(status, body) {
107
+ super(`ECB data API returned HTTP ${status}`);
108
+ this.name = "EcbHttpError";
109
+ this.status = status;
110
+ this.body = body;
111
+ }
112
+ };
113
+ var NoRateError = class extends EcbError {
114
+ currency;
115
+ date;
116
+ constructor(currency, date) {
117
+ super(`No ECB reference rate for ${currency} on or before ${date}`);
118
+ this.name = "NoRateError";
119
+ this.currency = currency;
120
+ this.date = date;
121
+ }
122
+ };
123
+
124
+ // src/client.ts
125
+ var DEFAULT_BASE_URL = "https://data-api.ecb.europa.eu/service";
126
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
127
+ var EcbClient = class {
128
+ baseUrl;
129
+ fetchImpl;
130
+ cache;
131
+ timeoutMs;
132
+ constructor(options = {}) {
133
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
134
+ const resolvedFetch = options.fetch ?? globalThis.fetch;
135
+ if (!resolvedFetch) {
136
+ throw new EcbError(
137
+ "No fetch implementation available; pass `fetch` in EcbClientOptions"
138
+ );
139
+ }
140
+ this.fetchImpl = resolvedFetch;
141
+ this.cache = options.cache === void 0 ? new MapCache() : options.cache;
142
+ this.timeoutMs = options.timeoutMs ?? 15e3;
143
+ }
144
+ /**
145
+ * Resolve the reference rate for a single currency on (or before) a date.
146
+ * `EUR` returns a unit rate without a network call.
147
+ */
148
+ async getRate(currency, date) {
149
+ const requestedDate = normalizeDate(date);
150
+ if (currency === BASE_CURRENCY) {
151
+ return { currency: BASE_CURRENCY, rate: 1, date: requestedDate };
152
+ }
153
+ const snapshot = await this.fetchSnapshot([currency], requestedDate);
154
+ const rate = snapshot.rates.find((r) => r.currency === currency);
155
+ if (!rate) throw new NoRateError(currency, requestedDate);
156
+ return rate;
157
+ }
158
+ /**
159
+ * Resolve reference rates on (or before) a date. Pass `currencies` to limit
160
+ * the request, or omit it to fetch every series the ECB publishes (note
161
+ * that discontinued series resolve to their last published date).
162
+ */
163
+ async getRates(date, currencies) {
164
+ const requestedDate = normalizeDate(date);
165
+ const wanted = currencies?.filter((c) => c !== BASE_CURRENCY);
166
+ const snapshot = wanted && wanted.length === 0 ? { requestedDate, rates: [] } : await this.fetchSnapshot(wanted ?? null, requestedDate);
167
+ if (currencies?.includes(BASE_CURRENCY)) {
168
+ return {
169
+ requestedDate,
170
+ rates: [
171
+ { currency: BASE_CURRENCY, rate: 1, date: requestedDate },
172
+ ...snapshot.rates
173
+ ]
174
+ };
175
+ }
176
+ return snapshot;
177
+ }
178
+ /**
179
+ * Convert `amount` from one currency to another using the reference rates on
180
+ * (or before) `date`. Non-euro pairs are crossed through the euro.
181
+ */
182
+ async convert(params) {
183
+ const { amount, from, to } = params;
184
+ const requestedDate = normalizeDate(params.date);
185
+ if (from === to) {
186
+ return {
187
+ amount,
188
+ rate: 1,
189
+ from,
190
+ to,
191
+ requestedDate,
192
+ rateDate: requestedDate
193
+ };
194
+ }
195
+ const needed = [from, to].filter(
196
+ (c) => c !== BASE_CURRENCY
197
+ );
198
+ const snapshot = await this.fetchSnapshot(needed, requestedDate);
199
+ const fromLeg = this.legFor(from, snapshot, requestedDate);
200
+ const toLeg = this.legFor(to, snapshot, requestedDate);
201
+ const rate = toLeg.rate / fromLeg.rate;
202
+ return {
203
+ amount: amount * rate,
204
+ rate,
205
+ from,
206
+ to,
207
+ requestedDate,
208
+ rateDate: fromLeg.date > toLeg.date ? fromLeg.date : toLeg.date
209
+ };
210
+ }
211
+ legFor(currency, snapshot, requestedDate) {
212
+ if (currency === BASE_CURRENCY) {
213
+ return { currency: BASE_CURRENCY, rate: 1, date: requestedDate };
214
+ }
215
+ const leg = snapshot.rates.find((r) => r.currency === currency);
216
+ if (!leg) throw new NoRateError(currency, requestedDate);
217
+ return leg;
218
+ }
219
+ async fetchSnapshot(currencies, requestedDate) {
220
+ const key = currencies && currencies.length ? currencies.join("+") : "";
221
+ const url = `${this.baseUrl}/data/EXR/D.${key}.EUR.SP00.A?endPeriod=${requestedDate}&lastNObservations=1`;
222
+ const cached = this.cache?.get(url);
223
+ if (cached) return cached;
224
+ const text = await this.get(url);
225
+ const rates = [];
226
+ for (const record of parseCsv(text)) {
227
+ const currency = record["CURRENCY"];
228
+ const observedAt = record["TIME_PERIOD"];
229
+ const value = Number(record["OBS_VALUE"]);
230
+ if (!currency || !observedAt || !Number.isFinite(value)) continue;
231
+ rates.push({ currency, rate: value, date: observedAt });
232
+ }
233
+ const snapshot = { requestedDate, rates };
234
+ this.cache?.set(url, snapshot);
235
+ return snapshot;
236
+ }
237
+ async get(url) {
238
+ const controller = this.timeoutMs > 0 ? new AbortController() : void 0;
239
+ const timer = controller ? setTimeout(() => controller.abort(), this.timeoutMs) : void 0;
240
+ try {
241
+ const response = await this.fetchImpl(url, {
242
+ headers: { Accept: "text/csv" },
243
+ ...controller ? { signal: controller.signal } : {}
244
+ });
245
+ const body = await response.text();
246
+ if (!response.ok) throw new EcbHttpError(response.status, body);
247
+ return body;
248
+ } catch (error) {
249
+ if (error instanceof EcbError) throw error;
250
+ throw new EcbError(`ECB data API request failed: ${url}`, {
251
+ cause: error
252
+ });
253
+ } finally {
254
+ if (timer) clearTimeout(timer);
255
+ }
256
+ }
257
+ };
258
+ var MapCache = class {
259
+ store = /* @__PURE__ */ new Map();
260
+ get(key) {
261
+ return this.store.get(key);
262
+ }
263
+ set(key, value) {
264
+ this.store.set(key, value);
265
+ }
266
+ };
267
+ var normalizeDate = (date) => {
268
+ if (date instanceof Date) {
269
+ if (Number.isNaN(date.getTime())) {
270
+ throw new EcbError("Invalid Date passed for the rate date");
271
+ }
272
+ return date.toISOString().slice(0, 10);
273
+ }
274
+ if (!DATE_RE.test(date)) {
275
+ throw new EcbError(`Invalid date "${date}"; expected YYYY-MM-DD`);
276
+ }
277
+ return date;
278
+ };
279
+
280
+ export { BASE_CURRENCY, ECB_CURRENCIES, EcbClient, EcbError, EcbHttpError, NoRateError, isEcbCurrency, parseCsv };
281
+ //# sourceMappingURL=index.js.map
282
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/currencies.ts","../src/csv.ts","../src/errors.ts","../src/client.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * The currencies for which the European Central Bank publishes daily euro\n * foreign-exchange reference rates, as of the ECB's current daily reference\n * list. Every rate is quoted as units of the currency per 1 EUR.\n *\n * The ECB occasionally adds or suspends currencies. This list is a convenience\n * for callers and the source of the {@link EcbCurrency} literal union; the\n * client never restricts lookups to it, so any ISO 4217 code the ECB exposes\n * (including discontinued series) can still be requested.\n */\nexport const ECB_CURRENCIES = [\n\t\"AUD\",\n\t\"BGN\",\n\t\"BRL\",\n\t\"CAD\",\n\t\"CHF\",\n\t\"CNY\",\n\t\"CZK\",\n\t\"DKK\",\n\t\"GBP\",\n\t\"HKD\",\n\t\"HUF\",\n\t\"IDR\",\n\t\"ILS\",\n\t\"INR\",\n\t\"ISK\",\n\t\"JPY\",\n\t\"KRW\",\n\t\"MXN\",\n\t\"MYR\",\n\t\"NOK\",\n\t\"NZD\",\n\t\"PHP\",\n\t\"PLN\",\n\t\"RON\",\n\t\"SEK\",\n\t\"SGD\",\n\t\"THB\",\n\t\"TRY\",\n\t\"USD\",\n\t\"ZAR\",\n] as const;\n\n/** A currency on the ECB's daily reference list. */\nexport type EcbCurrency = (typeof ECB_CURRENCIES)[number];\n\n/** The base currency of every ECB reference rate. */\nexport const BASE_CURRENCY = \"EUR\";\n\nconst ECB_CURRENCY_SET: ReadonlySet<string> = new Set(ECB_CURRENCIES);\n\n/** Narrowing guard: is `code` on the ECB daily reference list? */\nexport const isEcbCurrency = (code: string): code is EcbCurrency =>\n\tECB_CURRENCY_SET.has(code);\n","/**\n * Minimal RFC 4180 CSV parser, sufficient for the ECB data API's `text/csv`\n * responses. Handles quoted fields containing commas, line breaks and escaped\n * (doubled) quotes — the ECB's `TITLE_COMPL` column, for instance, embeds\n * commas inside quotes.\n */\nexport const parseCsv = (text: string): Record<string, string>[] => {\n\tconst rows = splitRows(text);\n\tconst header = rows.shift();\n\tif (!header) return [];\n\tconst records: Record<string, string>[] = [];\n\tfor (const row of rows) {\n\t\t// Skip blank trailing lines (a single empty field, no real data).\n\t\tif (row.length === 1 && row[0] === \"\") continue;\n\t\tconst record: Record<string, string> = {};\n\t\tfor (let i = 0; i < header.length; i++) {\n\t\t\trecord[header[i] ?? `column_${i}`] = row[i] ?? \"\";\n\t\t}\n\t\trecords.push(record);\n\t}\n\treturn records;\n};\n\nconst splitRows = (text: string): string[][] => {\n\tconst rows: string[][] = [];\n\tlet row: string[] = [];\n\tlet field = \"\";\n\tlet inQuotes = false;\n\n\tfor (let i = 0; i < text.length; i++) {\n\t\tconst char = text[i];\n\t\tif (inQuotes) {\n\t\t\tif (char === '\"') {\n\t\t\t\tif (text[i + 1] === '\"') {\n\t\t\t\t\tfield += '\"';\n\t\t\t\t\ti++;\n\t\t\t\t} else {\n\t\t\t\t\tinQuotes = false;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tfield += char;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === '\"') {\n\t\t\tinQuotes = true;\n\t\t} else if (char === \",\") {\n\t\t\trow.push(field);\n\t\t\tfield = \"\";\n\t\t} else if (char === \"\\n\" || char === \"\\r\") {\n\t\t\tif (char === \"\\r\" && text[i + 1] === \"\\n\") i++;\n\t\t\trow.push(field);\n\t\t\trows.push(row);\n\t\t\trow = [];\n\t\t\tfield = \"\";\n\t\t} else {\n\t\t\tfield += char;\n\t\t}\n\t}\n\n\t// Flush a final row when the text does not end with a line break.\n\tif (field !== \"\" || row.length > 0) {\n\t\trow.push(field);\n\t\trows.push(row);\n\t}\n\treturn rows;\n};\n","import type { CurrencyCode, IsoDate } from \"./types.js\";\n\n/** Base class for every error thrown by this package. */\nexport class EcbError extends Error {\n\tconstructor(message: string, options?: { cause?: unknown }) {\n\t\tsuper(message, options);\n\t\tthis.name = \"EcbError\";\n\t}\n}\n\n/** The ECB data API returned a non-2xx response. */\nexport class EcbHttpError extends EcbError {\n\treadonly status: number;\n\treadonly body: string;\n\n\tconstructor(status: number, body: string) {\n\t\tsuper(`ECB data API returned HTTP ${status}`);\n\t\tthis.name = \"EcbHttpError\";\n\t\tthis.status = status;\n\t\tthis.body = body;\n\t}\n}\n\n/** No reference rate exists for the currency on or before the requested date. */\nexport class NoRateError extends EcbError {\n\treadonly currency: CurrencyCode;\n\treadonly date: IsoDate;\n\n\tconstructor(currency: CurrencyCode, date: IsoDate) {\n\t\tsuper(`No ECB reference rate for ${currency} on or before ${date}`);\n\t\tthis.name = \"NoRateError\";\n\t\tthis.currency = currency;\n\t\tthis.date = date;\n\t}\n}\n","import { BASE_CURRENCY } from \"./currencies.js\";\nimport { parseCsv } from \"./csv.js\";\nimport { EcbError, EcbHttpError, NoRateError } from \"./errors.js\";\nimport type {\n\tConvertResult,\n\tCurrencyCode,\n\tIsoDate,\n\tRateSnapshot,\n\tReferenceRate,\n} from \"./types.js\";\n\n/**\n * The subset of the WHATWG `fetch` contract this client relies on. The global\n * `fetch` satisfies it, so callers rarely need to pass one — it exists to make\n * the client trivially testable and proxy-friendly.\n */\nexport type FetchLike = (\n\tinput: string,\n\tinit?: { headers?: Record<string, string>; signal?: AbortSignal },\n) => Promise<{ ok: boolean; status: number; text: () => Promise<string> }>;\n\n/** A cache for resolved snapshots, keyed by request URL. */\nexport interface RateCache {\n\tget(key: string): RateSnapshot | undefined;\n\tset(key: string, value: RateSnapshot): void;\n}\n\nexport interface EcbClientOptions {\n\t/**\n\t * Base URL of the ECB data API.\n\t * Default: `https://data-api.ecb.europa.eu/service`.\n\t */\n\tbaseUrl?: string;\n\t/** Custom fetch implementation. Defaults to the global `fetch`. */\n\tfetch?: FetchLike;\n\t/**\n\t * Snapshot cache. Defaults to an unbounded in-process `Map`. Pass `null` to\n\t * disable caching entirely.\n\t */\n\tcache?: RateCache | null;\n\t/** Request timeout in milliseconds. Default `15000`; `0` disables it. */\n\ttimeoutMs?: number;\n}\n\nconst DEFAULT_BASE_URL = \"https://data-api.ecb.europa.eu/service\";\nconst DATE_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/**\n * A small client for the European Central Bank's euro foreign-exchange\n * reference rates, served from the ECB data API (dataflow `EXR`).\n *\n * Every rate is quoted against the euro (units of the currency per 1 EUR).\n * Requests for a date that is not a TARGET business day resolve to the most\n * recent prior business day, via the data API's `lastNObservations` parameter,\n * and the resolved date is reported on each {@link ReferenceRate}.\n */\nexport class EcbClient {\n\tprivate readonly baseUrl: string;\n\tprivate readonly fetchImpl: FetchLike;\n\tprivate readonly cache: RateCache | null;\n\tprivate readonly timeoutMs: number;\n\n\tconstructor(options: EcbClientOptions = {}) {\n\t\tthis.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n\t\tconst resolvedFetch =\n\t\t\toptions.fetch ?? (globalThis.fetch as FetchLike | undefined);\n\t\tif (!resolvedFetch) {\n\t\t\tthrow new EcbError(\n\t\t\t\t\"No fetch implementation available; pass `fetch` in EcbClientOptions\",\n\t\t\t);\n\t\t}\n\t\tthis.fetchImpl = resolvedFetch;\n\t\tthis.cache =\n\t\t\toptions.cache === undefined ? new MapCache() : options.cache;\n\t\tthis.timeoutMs = options.timeoutMs ?? 15000;\n\t}\n\n\t/**\n\t * Resolve the reference rate for a single currency on (or before) a date.\n\t * `EUR` returns a unit rate without a network call.\n\t */\n\tasync getRate(\n\t\tcurrency: CurrencyCode,\n\t\tdate: Date | IsoDate,\n\t): Promise<ReferenceRate> {\n\t\tconst requestedDate = normalizeDate(date);\n\t\tif (currency === BASE_CURRENCY) {\n\t\t\treturn { currency: BASE_CURRENCY, rate: 1, date: requestedDate };\n\t\t}\n\t\tconst snapshot = await this.fetchSnapshot([currency], requestedDate);\n\t\tconst rate = snapshot.rates.find((r) => r.currency === currency);\n\t\tif (!rate) throw new NoRateError(currency, requestedDate);\n\t\treturn rate;\n\t}\n\n\t/**\n\t * Resolve reference rates on (or before) a date. Pass `currencies` to limit\n\t * the request, or omit it to fetch every series the ECB publishes (note\n\t * that discontinued series resolve to their last published date).\n\t */\n\tasync getRates(\n\t\tdate: Date | IsoDate,\n\t\tcurrencies?: readonly CurrencyCode[],\n\t): Promise<RateSnapshot> {\n\t\tconst requestedDate = normalizeDate(date);\n\t\tconst wanted = currencies?.filter((c) => c !== BASE_CURRENCY);\n\t\tconst snapshot =\n\t\t\twanted && wanted.length === 0\n\t\t\t\t? { requestedDate, rates: [] }\n\t\t\t\t: await this.fetchSnapshot(wanted ?? null, requestedDate);\n\t\tif (currencies?.includes(BASE_CURRENCY)) {\n\t\t\treturn {\n\t\t\t\trequestedDate,\n\t\t\t\trates: [\n\t\t\t\t\t{ currency: BASE_CURRENCY, rate: 1, date: requestedDate },\n\t\t\t\t\t...snapshot.rates,\n\t\t\t\t],\n\t\t\t};\n\t\t}\n\t\treturn snapshot;\n\t}\n\n\t/**\n\t * Convert `amount` from one currency to another using the reference rates on\n\t * (or before) `date`. Non-euro pairs are crossed through the euro.\n\t */\n\tasync convert(params: {\n\t\tamount: number;\n\t\tfrom: CurrencyCode;\n\t\tto: CurrencyCode;\n\t\tdate: Date | IsoDate;\n\t}): Promise<ConvertResult> {\n\t\tconst { amount, from, to } = params;\n\t\tconst requestedDate = normalizeDate(params.date);\n\t\tif (from === to) {\n\t\t\treturn {\n\t\t\t\tamount,\n\t\t\t\trate: 1,\n\t\t\t\tfrom,\n\t\t\t\tto,\n\t\t\t\trequestedDate,\n\t\t\t\trateDate: requestedDate,\n\t\t\t};\n\t\t}\n\t\tconst needed = [from, to].filter(\n\t\t\t(c): c is CurrencyCode => c !== BASE_CURRENCY,\n\t\t);\n\t\tconst snapshot = await this.fetchSnapshot(needed, requestedDate);\n\t\tconst fromLeg = this.legFor(from, snapshot, requestedDate);\n\t\tconst toLeg = this.legFor(to, snapshot, requestedDate);\n\t\tconst rate = toLeg.rate / fromLeg.rate;\n\t\treturn {\n\t\t\tamount: amount * rate,\n\t\t\trate,\n\t\t\tfrom,\n\t\t\tto,\n\t\t\trequestedDate,\n\t\t\trateDate: fromLeg.date > toLeg.date ? fromLeg.date : toLeg.date,\n\t\t};\n\t}\n\n\tprivate legFor(\n\t\tcurrency: CurrencyCode,\n\t\tsnapshot: RateSnapshot,\n\t\trequestedDate: IsoDate,\n\t): ReferenceRate {\n\t\tif (currency === BASE_CURRENCY) {\n\t\t\treturn { currency: BASE_CURRENCY, rate: 1, date: requestedDate };\n\t\t}\n\t\tconst leg = snapshot.rates.find((r) => r.currency === currency);\n\t\tif (!leg) throw new NoRateError(currency, requestedDate);\n\t\treturn leg;\n\t}\n\n\tprivate async fetchSnapshot(\n\t\tcurrencies: readonly CurrencyCode[] | null,\n\t\trequestedDate: IsoDate,\n\t): Promise<RateSnapshot> {\n\t\tconst key = currencies && currencies.length ? currencies.join(\"+\") : \"\";\n\t\tconst url = `${this.baseUrl}/data/EXR/D.${key}.EUR.SP00.A?endPeriod=${requestedDate}&lastNObservations=1`;\n\n\t\tconst cached = this.cache?.get(url);\n\t\tif (cached) return cached;\n\n\t\tconst text = await this.get(url);\n\t\tconst rates: ReferenceRate[] = [];\n\t\tfor (const record of parseCsv(text)) {\n\t\t\tconst currency = record[\"CURRENCY\"];\n\t\t\tconst observedAt = record[\"TIME_PERIOD\"];\n\t\t\tconst value = Number(record[\"OBS_VALUE\"]);\n\t\t\tif (!currency || !observedAt || !Number.isFinite(value)) continue;\n\t\t\trates.push({ currency, rate: value, date: observedAt });\n\t\t}\n\n\t\tconst snapshot: RateSnapshot = { requestedDate, rates };\n\t\tthis.cache?.set(url, snapshot);\n\t\treturn snapshot;\n\t}\n\n\tprivate async get(url: string): Promise<string> {\n\t\tconst controller =\n\t\t\tthis.timeoutMs > 0 ? new AbortController() : undefined;\n\t\tconst timer = controller\n\t\t\t? setTimeout(() => controller.abort(), this.timeoutMs)\n\t\t\t: undefined;\n\t\ttry {\n\t\t\tconst response = await this.fetchImpl(url, {\n\t\t\t\theaders: { Accept: \"text/csv\" },\n\t\t\t\t...(controller ? { signal: controller.signal } : {}),\n\t\t\t});\n\t\t\tconst body = await response.text();\n\t\t\tif (!response.ok) throw new EcbHttpError(response.status, body);\n\t\t\treturn body;\n\t\t} catch (error) {\n\t\t\tif (error instanceof EcbError) throw error;\n\t\t\tthrow new EcbError(`ECB data API request failed: ${url}`, {\n\t\t\t\tcause: error,\n\t\t\t});\n\t\t} finally {\n\t\t\tif (timer) clearTimeout(timer);\n\t\t}\n\t}\n}\n\nclass MapCache implements RateCache {\n\tprivate readonly store = new Map<string, RateSnapshot>();\n\tget(key: string): RateSnapshot | undefined {\n\t\treturn this.store.get(key);\n\t}\n\tset(key: string, value: RateSnapshot): void {\n\t\tthis.store.set(key, value);\n\t}\n}\n\nconst normalizeDate = (date: Date | IsoDate): IsoDate => {\n\tif (date instanceof Date) {\n\t\tif (Number.isNaN(date.getTime())) {\n\t\t\tthrow new EcbError(\"Invalid Date passed for the rate date\");\n\t\t}\n\t\treturn date.toISOString().slice(0, 10);\n\t}\n\tif (!DATE_RE.test(date)) {\n\t\tthrow new EcbError(`Invalid date \"${date}\"; expected YYYY-MM-DD`);\n\t}\n\treturn date;\n};\n"]}
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@financica/ecb-client",
3
+ "version": "0.1.0",
4
+ "description": "Tiny, zero-dependency client for the European Central Bank euro foreign-exchange reference rates, with historical single-day lookups and last-business-day fallback.",
5
+ "keywords": [
6
+ "ecb",
7
+ "exchange-rates",
8
+ "fx",
9
+ "currency",
10
+ "euro",
11
+ "eur",
12
+ "reference-rates",
13
+ "forex",
14
+ "accounting",
15
+ "typescript"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Financica",
19
+ "homepage": "https://github.com/financica/ecb-client#readme",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/financica/ecb-client.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/financica/ecb-client/issues"
26
+ },
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "main": "./dist/index.cjs",
30
+ "module": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js",
36
+ "require": "./dist/index.cjs"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "src",
43
+ "!src/**/*.test.ts",
44
+ "CHANGELOG.md",
45
+ "LICENSE",
46
+ "README.md"
47
+ ],
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup",
53
+ "clean": "rm -rf dist",
54
+ "lint": "tsc --noEmit",
55
+ "type-check": "tsc --noEmit",
56
+ "test": "vitest run",
57
+ "test:watch": "vitest",
58
+ "ci": "npm run lint && npm run test && npm run build",
59
+ "prepublishOnly": "npm run clean && npm run build && npm test"
60
+ },
61
+ "devDependencies": {
62
+ "@types/node": "^22.0.0",
63
+ "tsup": "^8.3.0",
64
+ "typescript": "^5.6.0",
65
+ "vitest": "^2.1.0"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ }
70
+ }