first commit
This commit is contained in:
155
docs/money/finance/currency-providers/src/http/http.md
Normal file
155
docs/money/finance/currency-providers/src/http/http.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# `http` — Shared HTTP helper for sibling provider packages
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Exposes the two HTTP utilities every sibling
|
||||
`@suaveplan/currency-provider-<name>` package repeats: `httpRequest` (an
|
||||
async request through the injected `FetchLike` with typed-error mapping and
|
||||
abort-signal composition) and `composeAbortSignal` (a pure helper that
|
||||
merges a caller `AbortSignal` with an optional timeout-derived one).
|
||||
|
||||
The previous shape of `@suaveplan/currency-providers` had every provider
|
||||
re-implement the same `await this.fetchImpl(url); if (!response.ok) throw
|
||||
new CurrencyProviderError(HTTP_ERROR, ...)` block by hand. That repetition
|
||||
is exactly the signal that the helper belongs in the slim core: every
|
||||
sibling now `import { httpRequest } from "@suaveplan/currency-providers"`
|
||||
and the HTTP_ERROR call site lives in one place, exercised by one test
|
||||
file.
|
||||
|
||||
## Features
|
||||
|
||||
- `httpRequest` — single async helper covering the full HTTP lifecycle:
|
||||
abort-signal composition, timeout derivation, fetch invocation, non-2xx
|
||||
→ `CurrencyProviderError(HTTP_ERROR)` mapping
|
||||
- `composeAbortSignal` — pure four-way composition: `undefined`, caller
|
||||
only, timeout only, both (via `AbortSignal.any`)
|
||||
- Returns the raw `Response` so callers handle `.json()` / `.text()` /
|
||||
`.arrayBuffer()` according to their own payload shape — no premature
|
||||
body-format assumption
|
||||
- Universal — uses only WHATWG `fetch`, `AbortSignal.timeout`, and
|
||||
`AbortSignal.any` (all available in Node 20+, Bun, and modern browsers)
|
||||
- Typed error carries `providerName` and `status` discriminants so audit
|
||||
log handlers can dispatch without parsing the message string
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { httpRequest } from "@suaveplan/currency-providers";
|
||||
|
||||
const response = await httpRequest({
|
||||
url: "https://api.example.com/v1/rates",
|
||||
fetch: globalThis.fetch,
|
||||
providerName: "example",
|
||||
});
|
||||
const payload = await response.json();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire `httpRequest` into a sibling provider with both a timeout and the
|
||||
caller's `RateFetchOptions.signal`, then handle `HTTP_ERROR` against the
|
||||
typed code rather than the message:
|
||||
|
||||
```ts
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
httpRequest,
|
||||
type NoKeyConfig,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import type { RateFetchOptions, RateFetchResult } from "@suaveplan/currency";
|
||||
|
||||
const DEFAULT_URL = "https://api.example.com/v1/rates";
|
||||
const NAME = "example";
|
||||
|
||||
export class ExampleProvider {
|
||||
public readonly name = NAME;
|
||||
public readonly supportsCrypto = false;
|
||||
public readonly supportsFiat = true;
|
||||
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl;
|
||||
|
||||
constructor(config?: NoKeyConfig) {
|
||||
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
|
||||
this.fetchImpl = config?.fetch ?? globalThis.fetch;
|
||||
}
|
||||
|
||||
async fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> {
|
||||
try {
|
||||
const response = await httpRequest({
|
||||
url: this.baseUrl,
|
||||
fetch: this.fetchImpl,
|
||||
providerName: NAME,
|
||||
signal: options?.signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
base: string;
|
||||
rates: Record<string, number>;
|
||||
timestamp: number;
|
||||
};
|
||||
return {
|
||||
base: payload.base,
|
||||
rates: payload.rates,
|
||||
timestamp: payload.timestamp * 1000,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof CurrencyProviderError &&
|
||||
error.code === CURRENCY_PROVIDER_CODES.HTTP_ERROR
|
||||
) {
|
||||
// optionally: backoff, fallback, audit-log etc.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface HttpRequestInput`
|
||||
- `url: string` — fully-resolved request URL (query string already
|
||||
appended at the call site).
|
||||
- `fetch: FetchLike` — injected `FetchLike` (the sibling provider
|
||||
defaults to `globalThis.fetch` at construction time).
|
||||
- `providerName: string` — carried on the raised
|
||||
`CurrencyProviderError.providerName`.
|
||||
- `init?: RequestInit` — optional WHATWG `RequestInit` (method, headers,
|
||||
body, etc.). The helper preserves caller-supplied `headers` and
|
||||
`method`; it only inserts `signal` when an abort signal needs to be
|
||||
attached.
|
||||
- `signal?: AbortSignal` — caller's abort signal (usually from
|
||||
`RateFetchOptions.signal`).
|
||||
- `timeoutMs?: number` — when set, the helper derives
|
||||
`AbortSignal.timeout(timeoutMs)` and composes it with the caller signal.
|
||||
- `function httpRequest(input: HttpRequestInput): Promise<Response>` —
|
||||
resolves to the raw `Response` on 2xx, rejects with
|
||||
`CurrencyProviderError(HTTP_ERROR)` on non-2xx.
|
||||
- `function composeAbortSignal(caller, timeout): AbortSignal | undefined`
|
||||
— pure four-way composition. Returns the single input verbatim when only
|
||||
one is provided (no wrapping cost). Returns
|
||||
`AbortSignal.any([caller, timeout])` when both are present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The helper does **not** apply retry, backoff, or rate-limit semantics —
|
||||
those wrappers compose at a higher layer (see the deferred surfaces in
|
||||
the 2026-05-17 archive's `## Out of scope`).
|
||||
- `AbortSignal.timeout` was chosen over a manual `setTimeout +
|
||||
AbortController` to keep the helper universal (no `setTimeout`
|
||||
scheduling owned by the helper) and to avoid the cleanup foot-gun of a
|
||||
pending timer outliving a fast-success request.
|
||||
- `composeAbortSignal` deliberately returns the **single input** unchanged
|
||||
when only one is provided. Wrapping a single signal through
|
||||
`AbortSignal.any([s])` would still produce a valid signal but would
|
||||
break identity-comparison invariants downstream consumers might rely on.
|
||||
- The non-2xx error message intentionally uses `response.statusText` (the
|
||||
HTTP reason phrase) so the human-readable form survives audit log
|
||||
redaction; programmatic dispatch should use `error.code` and
|
||||
`error.status` instead.
|
||||
Reference in New Issue
Block a user