Files
2026-05-27 23:29:53 +01:00

7.4 KiB

Design: @suaveplan/currency-provider-fixer

Non-normative. The normative requirements live in ./specs/currency-provider-fixer/spec.md. Cross-cutting architecture for the foundation-with-sibling-plugins family lives once, in openspec/archive/2026-05-17-slim-currency-providers/design.md — this document references it rather than duplicating it.

Shape

@suaveplan/currency-provider-fixer (this package)
└── FixerProvider — class implementing CurrencyProvider from @suaveplan/currency
    ├── construct: ApiKeyConfig from @suaveplan/currency-providers/types/configs
    │   ├── apiKey: string (non-empty — raises MISSING_CONFIG otherwise)
    │   ├── baseUrl?: string (default "https://data.fixer.io/api")
    │   └── fetch?: FetchLike (default globalThis.fetch — from @suaveplan/types)
    └── fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>
        ├── httpRequest({ ... }) from @suaveplan/currency-providers/http/http
        │     non-2xx → CurrencyProviderError(HTTP_ERROR, status, providerName)
        ├── JSON.parse the response → typed envelope check
        │     malformed → CurrencyProviderError(PARSE_ERROR)
        └── upstream envelope dispatch:
             { success: true,  base, rates, timestamp } → return result
             { success: false, error: { code: 105 } } → UNSUPPORTED_BASE
             { success: false, error: { code: 201|202 } } → UNKNOWN_CURRENCY
             { success: false, error: { code: any } } → UPSTREAM_ERROR

The package is a leaf — no @suaveplan/* runtime dep outside the slim core and the CurrencyProvider contract that the slim core itself depends on.

File layout

src/
├── fixer/
│   ├── fixer.ts                # FixerProvider class + upstream envelope types
│   ├── fixer.md                # sibling docs (Purpose / Features / Usage / API / Notes)
│   ├── fixer.test.ts           # behavior tests with recording FetchLike
│   └── index.ts                # explicit named barrel
└── index.ts                    # package root barrel — explicit named exports

Single feature module — the package's whole contract is one class. The folder structure mirrors every other sibling-package shape and keeps the subpath-export count equal to the feature-module count (1 subpath → 1 feature). The slim-core index.ts is re-imported, never re-exported.

Upstream envelope

Fixer.io always returns HTTP 200 — error states are signalled in the JSON body via { success: false, error: { code, type, info } }. The provider must therefore:

  1. Let httpRequest raise HTTP_ERROR on the rare genuine non-2xx (network layer, gateway, CDN-level outage).
  2. Otherwise await response.json() inside a try / catch and translate any parse failure to PARSE_ERROR.
  3. Narrow the parsed value: it must be an object with a boolean success field; anything else is PARSE_ERROR again (defends against an unexpected HTML redirect or an HTTP/2-pushed error page slipping through as 200).
  4. On success: true, project to { base, rates, timestamp: data.timestamp * 1000 }.
  5. On success: false, branch on error.code per the table above. error may be missing on malformed responses — treat absent error as UPSTREAM_ERROR with upstreamMessage = "<unknown>".

The data.timestamp field is Unix seconds per Fixer.io documentation; the universal RateFetchResult.timestamp is milliseconds — multiply by 1000.

Why upstream error.code and not error.type

Fixer.io's error.type is a kebab/snake-case string (base_currency_access_restricted, invalid_base_currency, …) and is documented as stable across the v1 API. error.code is the integer the dashboard uses (105, 201, 202, …). Both are stable; the integer is one comparison and avoids string allocation. We match on the integer and put the error.info human-readable message into upstreamMessage so consumers debugging in production still see the upstream narrative.

Why validate apiKey at construction

Two reasons:

  1. Fast failure. A test suite or boot wiring that misses the env var should fail before any network call lands; throwing MISSING_CONFIG at new FixerProvider({ apiKey: "" }) surfaces the misconfiguration with a discriminated error code that consumer fallbacks can match on.
  2. No wasted upstream call. Fixer.io's free tier counts every request against the monthly quota — including ones that fail authentication. A construction-time guard means a missing key never burns quota.

The upstream skeleton allowed an empty apiKey because the constructor used config.apiKey directly; this package preserves the exact same constructor signature but adds the guard.

Why httpRequest over a bare fetch

The slim core's httpRequest already:

  • composes the caller's AbortSignal (from RateFetchOptions.signal) with a timeout signal,
  • raises CurrencyProviderError(HTTP_ERROR) with providerName / status populated,
  • forwards init.headers so callers can attach diagnostic headers.

Recreating any of that in this package would duplicate slim-core behaviour and risk drift. Every sibling provider in the follow-up wave uses the same helper.

Tier purity

Universal-tier. Only URLSearchParams, JSON.parse, Number.parseInt, Object.freeze, and globalThis.fetch (via FetchLike injection); no node: / bun: / DOM imports. Runs unchanged under Node 18+, Bun, modern browsers, and Web Workers.

Test strategy

src/fixer/fixer.test.ts uses a recording FetchLike closure (no vi.mock, no vi.stubGlobal) and exercises every requirement:

  • Identity: name, supportsFiat, supportsCrypto flags.
  • Construction: empty apiKey raises MISSING_CONFIG.
  • Query construction: access_key, default base=EUR, custom base, symbols list when targets provided.
  • Default and custom baseUrl.
  • Custom fetch is honoured.
  • success: true path returns the canonical RateFetchResult with timestamp in ms.
  • success: false with error.code === 105 → UNSUPPORTED_BASE.
  • success: false with error.code === 201 → UNKNOWN_CURRENCY.
  • success: false with error.code === 202 → UNKNOWN_CURRENCY.
  • success: false with error.code === 999 → UPSTREAM_ERROR.
  • success: false with missing error → UPSTREAM_ERROR.
  • Malformed JSON → PARSE_ERROR.
  • Non-object JSON (null, "string", 42) → PARSE_ERROR.
  • Non-2xx HTTP → HTTP_ERROR (via slim-core httpRequest).
  • Caller AbortSignal is forwarded.

All tests import from @suaveplan/testing/runner. Every it(...) carries { timeout: 5000 }.

Coverage / docs

100/100/100/100 istanbul thresholds set from day one in vitest.config.ts. Every implementation .ts has a sibling .md with the six mandatory sections. docs:check runs as part of the pre-publish gate.

Migration narrative

Consumers that previously imported FixerProvider from @suaveplan/currency-providers (i.e. before the 2026-05-17 slim) should rewrite the import:

// Before
import { FixerProvider } from "@suaveplan/currency-providers";

// After
import { FixerProvider } from "@suaveplan/currency-provider-fixer";

The constructor signature is identical (new FixerProvider({ apiKey })), so no other call-site change is required. The slim-core's CurrencyProviderRegistry accepts the new instance unchanged.