-
Notifications
You must be signed in to change notification settings - Fork 2
RESTler
A small, cross-runtime base class for building typed REST/HTTP API clients.
You extend RESTler once per API vendor, and it handles URL building,
authentication, content-type (de)serialization, timeouts, events, and
errors — over a runtime-aware fetch that also supports Unix sockets and
TLS client authentication.
Not Microsoft's RESTler (an API fuzz-testing tool). This RESTler is a client base class for building typed, per-vendor API SDKs.
Built on the standard fetch global, so it runs unchanged on Workers and in
the browser too. Unix-socket and TLS-client-auth transport (socketPath /
tls) is opt-in per client instance — neither has a per-endpoint override
— and only works on Deno and Bun; configuring either on Node, Workers, or in
the browser throws UnsupportedRuntimeError rather than being silently
ignored. Plain HTTP/HTTPS needs neither and works everywhere.
- Overview
- Features
- Installation
- Quick Start
- Configuration
- Defining a Client
- Requests & Responses
- Content Types
- Streaming
- Authentication
- Unix Sockets
- TLS Client Authentication
- Events
- Rate-limit retry
- Observability
- Vendor Response Handling
- Error Handling
- API Reference
- Documentation
- License
@tundralibs/restler is not a fetch wrapper you instantiate directly.
It is an abstract class you subclass to model a specific API. Your subclass
sets a vendor identifier and exposes domain methods (e.g. getUser,
listContainers) that call the protected _makeRequest helper. RESTler then:
- builds the full URL from
baseURL+path(+ optionalport,version,query); - injects authentication headers;
- serializes the request body by content type and parses the response body, or streams either without buffering;
- enforces a per-request timeout;
- emits lifecycle events (
call,authFailure,rateLimit, …); - maps failures onto typed errors.
Requests run over @tundralibs/compat's runtime-aware
fetch, so the same client works on Deno, Bun, Node, Workers, and in the
browser — including, on Deno and Bun, its Unix socket and TLS client-auth
extensions.
| Feature | Deno | Bun | Node.js | Workers | Browser |
|---|---|---|---|---|---|
| HTTP / HTTPS requests | ✅ | ✅ | ✅ | ✅ | ✅ |
| JSON / XML / FORM / TEXT / BLOB bodies | ✅ | ✅ | ✅ | ✅ | ✅ |
| Streaming request & response bodies | ✅ | ✅ | ✅ | ✅ | ✅ |
| BASIC / BEARER / custom authentication | ✅ | ✅ | ✅ | ✅ | ✅ |
| Per-request timeout | ✅ | ✅ | ✅ | ✅ | ✅ |
| Lifecycle events & rate-limit parsing | ✅ | ✅ | ✅ | ✅ | ✅ |
Unix domain socket transport (socketPath) |
✅ | ✅ | ❌* | ❌* | ❌* |
TLS client authentication (tls) |
✅ | ✅ | ❌* | ❌* | ❌* |
* Unix sockets and TLS client auth are provided by compat's fetch and are
only available on Deno and Bun; configuring either on Node, Cloudflare
Workers, or in the browser throws UnsupportedRuntimeError. Plain HTTP/HTTPS
needs neither and works everywhere.
Deno:
deno add @tundralibs/restlerBun:
bunx jsr add @tundralibs/restlerNode.js:
npx jsr add @tundralibs/restlerDirect import (Deno):
import { RESTler } from 'jsr:@tundralibs/restler';import { RESTler } from '@tundralibs/restler';
interface Todo {
id: number;
title: string;
completed: boolean;
}
class TodoAPI extends RESTler {
public readonly vendor = 'jsonplaceholder';
constructor() {
super({ baseURL: 'https://jsonplaceholder.typicode.com' });
}
getTodo(id: number) {
return this._makeRequest<Todo>({ path: `/todos/${id}`, method: 'GET' });
}
createTodo(todo: Omit<Todo, 'id'>) {
return this._makeRequest<Todo>({
path: '/todos',
method: 'POST',
contentType: 'JSON',
payload: todo,
});
}
}
const api = new TodoAPI();
const res = await api.getTodo(1);
console.log(res.status, res.body?.title);RESTlerOptions is passed to super(...) in your subclass constructor. Only
baseURL is required.
| Option | Type | Default | Notes |
|---|---|---|---|
baseURL |
string |
— | Required. May contain a {version} placeholder. |
port |
number |
— | 1–65535. |
headers |
Record<string, string> |
{} |
Default headers sent with every request. |
timeout |
number |
30 |
Seconds. Must be >= 1 and <= 120. |
contentType |
RESTlerContentType |
'JSON' |
Default body content type (JSON | XML | FORM | TEXT | BLOB | STREAM). |
maxRetryWait |
number |
— | Seconds. Enables one retry of a rate-limited request, and caps the wait. Absent = no retry. See Rate-limit retry. |
defaultRetryWait |
number |
— | Seconds to wait when rate-limited with no readable hint. Absent = no retry in that case. |
version |
string |
— | Replaces {version} in URLs, query values, and headers. |
socketPath |
string |
— | Route over a Unix socket (Deno/Bun). Must point to an existing path. |
tls |
TLSOptions |
— | TLS client auth (Deno/Bun). See TLS. |
auth |
RESTlerAuth |
— | Default authentication. See Authentication. |
witness |
Witness |
— | Observability wrap hook (suite convention). See Observability. |
headerProvider |
RESTlerHeaderProvider |
— | Per-request outbound headers (traceparent, correlation ids). See Observability. |
Values are validated in the constructor; an invalid value — or a missing
required baseURL (including when it's absent from loosely-typed config loaded
from JSON/env) — throws RESTlerConfigError.
Subclass RESTler, set the abstract vendor field, and add methods that call
the protected _makeRequest<T>(endpoint). The generic T types the parsed
response body.
import { RESTler } from '@tundralibs/restler';
class GitHubAPI extends RESTler {
public readonly vendor = 'github';
constructor(token: string) {
super({
baseURL: 'https://api.github.com',
version: 'v3',
headers: { Accept: 'application/vnd.github+json' },
auth: { type: 'BEARER', token },
});
}
getUser(login: string) {
return this._makeRequest<{ id: number; login: string }>({
path: `/users/${login}`,
method: 'GET',
});
}
}Every endpoint may override instance-level options (baseURL, port,
version, timeout, contentType, auth, headers) on a per-request
basis via the RESTlerEndpoint fields. Overrides are validated the same
way as their instance-level counterparts — e.g. a per-endpoint timeout
outside the 1…120 second range, or a contentType that isn't one of
JSON/XML/FORM/TEXT/BLOB, throws RESTlerConfigError.
_makeRequest resolves to a RESTlerResponse<T>:
import type { RESTlerResponse } from '@tundralibs/restler';
declare const api: {
getUser(
login: string,
): Promise<RESTlerResponse<{ id: number; login: string }>>;
};
const res = await api.getUser('octocat');
res.url; // Final requested URL
res.status; // HTTP status code (e.g. 200) or null if no response was received
res.statusText; // e.g. "OK"
res.headers; // Record<string, string> (lowercased keys)
res.body; // Parsed body, typed as T
res.timeTaken; // Milliseconds
res.error; // RESTlerError if the request failedHTTP error statuses (4xx/5xx) are returned normally — inspect
res.status. Transport-level failures (timeout, connection error, a thrown
error) reject: wrap calls in try/catch. See
Error Handling.
Set contentType (and payload) on a body-bearing endpoint. The body is
serialized and a default Content-Type header is set when you haven't
provided one.
contentType |
payload type |
Serialized as / Content-Type
|
|---|---|---|
JSON |
Record<string, unknown> |
JSON.stringify / application/json
|
XML |
Record<string, unknown> |
XML string / application/xml
|
FORM |
FormData |
FormData (the runtime sets the boundary)* |
FORM |
URLSearchParams or plain object |
urlencoded string / application/x-www-form-urlencoded** |
TEXT |
string |
raw string / text/plain
|
BLOB |
Blob |
the Blob as-is |
STREAM |
ReadableStream<Uint8Array> |
streamed unbuffered / application/octet-stream*** |
* For a FormData payload, any inherited Content-Type header is removed
so fetch can set the correct multipart/form-data boundary.
*** A STREAM payload is sent without being held in memory and is consumed
ONCE, so such a request can never be replayed — see Streaming.
** FORM's wire format is decided by the payload's SHAPE — a URLSearchParams
or plain object sends application/x-www-form-urlencoded, the format
essentially every OAuth2 token exchange (and Stripe's whole API) requires.
URLSearchParams's own serializer (space → +) is used here, which is
correct for this media type — note this is DIFFERENT from endpoint.query
(the URL's query string), which is percent-encoded per RFC 3986
(space → %20) instead, since a + there breaks any signing-based auth
that re-derives a canonical query string (e.g. AWS SigV4).
The response body is parsed from its Content-Type, matched by substring —
vendor suffixes like application/vnd.api+json or application/atom+xml
route to their structured parser: json → object, xml → object,
text → string. An unknown or missing type is parsed best-effort — JSON
first, then XML — falling back to the raw string.
The default path above reads the response body as text, which corrupts
binary payloads — so for files, images, and other binary bodies you must set
responseType on the endpoint. 'BLOB' reads the body as a Blob,
'ARRAY_BUFFER' as an ArrayBuffer; content-type parsing is skipped
entirely.
import { RESTler } from '@tundralibs/restler';
class FileAPI extends RESTler {
public readonly vendor = 'files';
constructor() {
super({ baseURL: 'https://files.example.com' });
}
download(id: string) {
return this._makeRequest<Blob>({
path: `/files/${id}`,
method: 'GET',
responseType: 'BLOB',
});
}
}
const res = await new FileAPI().download('report.pdf');
console.log(res.body?.size, res.body?.type); // Blob size and MIME typeFor bodies too large to hold in memory. Both directions stream; they are independent of each other.
_makeStreamRequest is a sibling of _makeRequest, not a mode of it. It hands
back the response body unread, as a ReadableStream<Uint8Array>:
class Storage extends RESTler {
public readonly vendor = 'Storage';
download(key: string) {
return this._makeStreamRequest({ path: `/objects/${key}`, method: 'GET' });
}
}
const { body } = await new Storage({ baseURL: 'https://api.example.com' })
.download('backup.tar');
// `body` is a ReadableStream — pipe it somewhere, don't buffer it.Three things differ from _makeRequest, and each is why it is a separate
method rather than a flag:
-
The body is never parsed.
responseSchemacannot validate a stream without consuming it, soRESTlerStreamOptionssimply does not have the option.responseHandlerstill runs, but only on a failure status — an error envelope is a small document worth reading, a success body is the payload you asked to stream. -
The timeout is an idle timeout.
timeoutbounds the wait for response headers only; once they arrive,idleTimeout(default 60s) takes over and resets on every chunk. A slow but healthy transfer runs as long as it needs, while a stalled one still dies — unliketimeout, whose 120s ceiling would cut off any sizeable download. -
The
callevent fires when the stream settles, not when the method returns — the moment the transfer actually finished, failed, or was cancelled.timeTakenon the returned response measures time-to-headers; the event's copy measures the whole transfer.
You own the returned stream: consume it or cancel() it, or the connection
stays open and the idle timer stays armed. A 204 yields an empty stream
rather than a null body, so calling code keeps one shape.
A streamed request body needs no special method — contentType: 'STREAM' with
a ReadableStream payload works on either:
upload(key: string, body: ReadableStream<Uint8Array>) {
return this._makeRequest({
path: `/objects/${key}`,
method: 'PUT',
contentType: 'STREAM',
payload: body,
});
}It is sent unbuffered with no Content-Length, so the transfer is chunked, and
Content-Type defaults to application/octet-stream. Node requires
duplex: 'half' for a stream body and RESTler sets it for you — only when the
body is a stream, so every other request is unchanged.
A streamed request body is consumed once and cannot be replayed, so nothing in RESTler will ever resend one — see Rate-limit retry.
auth is a discriminated union keyed by type. Set it instance-wide (in
RESTlerOptions) or per request (on the endpoint — the endpoint wins).
// Basic — sends `Authorization: Basic base64(user:pass)`. An EMPTY password
// is valid (RFC 7617) — e.g. Stripe's `sk_live_...:` pattern.
{ type: 'BASIC', username: 'user', password: 'secret' }
{ type: 'BASIC', username: 'sk_live_abc123', password: '' }
// Bearer — sends `Authorization: <prefix> <token>` (prefix defaults to "BEARER")
{ type: 'BEARER', token: 'abc123' }
{ type: 'BEARER', token: 'abc123', prefix: 'Bearer' }
// Custom — interpreted by your subclass (see below)
{ type: 'CUSTOM', apiKey: 'xyz' }The base class injects the Authorization header for BASIC and BEARER.
For anything else (API key in the query string or a custom header), override
_authInjector. It may be async (e.g. to refresh a token before the call).
The endpoint handed to _authInjector is a per-request copy, so mutating it
(as the example below does) never writes back onto the caller's endpoint
object. A shared or reused endpoint object therefore never accumulates one
call's credentials — safe for an endpoint catalog shared across per-tenant
client instances.
import { RESTler } from '@tundralibs/restler';
import type { RESTlerEndpoint } from '@tundralibs/restler';
class WeatherAPI extends RESTler {
public readonly vendor = 'openweathermap';
constructor(private apiKey: string) {
super({ baseURL: 'https://api.openweathermap.org/data/2.5' });
}
// Add the API key to every request's query string.
protected override _authInjector(endpoint: RESTlerEndpoint): void {
endpoint.query = { ...endpoint.query, appid: this.apiKey };
}
getCurrentWeather(city: string) {
return this._makeRequest({
path: '/weather',
method: 'GET',
query: { q: city, units: 'metric' },
});
}
}By the time _authInjector runs, endpoint.headers already holds the
full outbound header set — instance-level defaults, headerProvider()'s
output, and the caller's own explicit headers, already merged (the caller's
own entries win on a collision). A signature computed over endpoint.headers
therefore covers everything actually sent, not just whatever the caller
happened to pass to one particular call. The one exception: the default
Content-Type for JSON/XML/TEXT payloads is computed later, in _buildBody
— to sign it, set Content-Type explicitly on endpoint.headers yourself
before calling _makeRequest.
_base64Utf8(value: string): string is protected, not private — reuse
it for Basic-style header encoding in your own scheme instead of
reimplementing UTF-8-correct base64.
import { RESTler } from '@tundralibs/restler';
import type { RESTlerEndpoint } from '@tundralibs/restler';
class SignedAPI extends RESTler {
public readonly vendor = 'signed-vendor';
constructor(private secret: string) {
super({ baseURL: 'https://api.example.com' });
}
protected override _authInjector(endpoint: RESTlerEndpoint): void {
// endpoint.headers is already the FULL set — sign it as-is.
const signature = this.sign(endpoint.headers ?? {}, this.secret);
endpoint.headers = { ...endpoint.headers, 'X-Signature': signature };
}
private sign(_headers: Record<string, string>, _secret: string): string {
return 'computed-signature'; // real HMAC/SigV4 canonicalization goes here
}
}A CUSTOM auth that fetches its own token needs to make a request as part
of _authInjector — but _authInjector runs unconditionally on every
_makeRequest call, so a token-fetch that itself called _makeRequest
would recurse into its own _authInjector before the token exists.
skipAuth: true on that ONE call breaks the recursion while keeping
everything else _makeRequest normally provides — timeout/abort, the
call event, error normalization, witness/tracing:
import { RESTler } from '@tundralibs/restler';
import type { RESTlerEndpoint } from '@tundralibs/restler';
class OAuth2API extends RESTler {
public readonly vendor = 'oauth2-vendor';
private token: string | undefined;
constructor(private clientId: string, private clientSecret: string) {
super({ baseURL: 'https://api.example.com' });
}
protected override async _authInjector(
endpoint: RESTlerEndpoint,
): Promise<void> {
if (this.token === undefined) {
const res = await this._makeRequest<{ access_token: string }>(
{
path: '/oauth/token',
method: 'POST',
contentType: 'FORM',
payload: {
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
},
},
{ skipAuth: true }, // <- breaks the recursion
);
this.token = res.body?.access_token;
}
endpoint.headers = {
...endpoint.headers,
Authorization: `Bearer ${this.token}`,
};
}
}Set socketPath to route requests over a Unix domain socket instead of TCP —
ideal for the Docker Engine API, container runtimes, and local daemons. The
baseURL host is ignored for transport but still used to build the path.
Available on Deno and Bun only (provided by compat's
fetch). On Node, Cloudflare Workers, or in the browser, this throwsUnsupportedRuntimeError.
import { RESTler } from '@tundralibs/restler';
class DockerAPI extends RESTler {
public readonly vendor = 'docker';
constructor(socketPath = '/var/run/docker.sock') {
super({ baseURL: 'http://localhost', socketPath });
}
listContainers(all = false) {
return this._makeRequest<unknown[]>({
path: '/containers/json',
method: 'GET',
query: { all: all ? 'true' : 'false' },
});
}
ping() {
return this._makeRequest({ path: '/_ping', method: 'GET' })
.then((r) => r.status === 200);
}
}
const docker = new DockerAPI();
console.log(await docker.ping());Set tls for mutual TLS, a custom CA, or to skip verification. Supply either
inline PEM (cert / key / ca) or file paths (certFile / keyFile /
caFile) — the two styles are mutually exclusive — plus the optional
rejectUnauthorized flag.
Available on Deno and Bun only. On Node, Cloudflare Workers, or in the browser, this throws
UnsupportedRuntimeError. Plain HTTPS against public CAs needs notlsoption and works everywhere.
import { RESTler } from '@tundralibs/restler';
class SecureAPI extends RESTler {
public readonly vendor = 'secure';
constructor() {
super({
baseURL: 'https://internal.example.com',
tls: {
certFile: './client.crt',
keyFile: './client.key',
caFile: './ca.crt',
},
});
}
}RESTler is an event emitter. Subscribe with on / once / off.
| Event | Fires when | Handler arguments |
|---|---|---|
call |
After every request (success or failure) | (vendor, request, response, error?) |
authFailure |
Response status is 401, 403, or 407 | (vendor, request, response) |
rateLimit |
Response status is 429 | (vendor, limit?, reset?, remaining?) |
retry |
Before waiting to retry a rate-limited request | (vendor, request, waitSeconds) |
authentication |
Your subclass authenticates (you emit it) | (vendor, data?) |
track |
Custom tracking (you emit it) | (vendor, name, data) |
On a rateLimit, RESTler reads x-ratelimit-limit / -remaining / -reset
(and the unprefixed variants) from the response headers.
Off unless you ask for it. Set maxRetryWait (seconds) and a rate-limited
response is retried once, after waiting exactly as long as the vendor
asked:
const api = new MyAPI({ baseURL: 'https://api.example.com', maxRetryWait: 10 });RESTler honours the vendor's own hint rather than inventing a schedule. That is why each header is read in its own format — they do not share one:
| Header | Value means |
|---|---|
Retry-After |
delta seconds or an HTTP-date |
X-RateLimit-Reset-After |
delta seconds (may be fractional) |
RateLimit-Reset |
delta seconds |
X-RateLimit-Reset |
an absolute Unix timestamp |
Reading one as another is not a rounding error: X-RateLimit-Reset: 1774000000
taken as a delta would wait 56 years. Override _retryHeaders on your subclass
to add a vendor's private header or drop one it misuses, pairing each name with
its format.
It throws RESTlerRateLimitError — rather than waiting — when:
- the vendor gave no readable hint and no
defaultRetryWaitis set (RESTler never guesses a delay; several vendors send a bare 429); - the hint is longer than
maxRetryWait, so the decision goes back to you rather than a long block being imposed; - the single retry was itself rate-limited;
- the request body was a
STREAM, which cannot be replayed.
The error carries context.retryAfter (the parsed wait in seconds, when there
was one) and context.retried, so a caller can schedule its own attempt and
knows whether a silent pause already happened.
Two timing guarantees worth knowing: timeout bounds each attempt, and the
wait between them is not charged against it — a 25-second wait cannot consume a
30-second request budget. And the retry is decided on the raw status before
the body is read and before _responseHandler runs, so a vendor hook never
sees an attempt that is about to be retried.
A retry event fires before the wait, so tracing and logs see the pause coming
instead of inferring it from a latency spike.
_makeStreamRequest retries the same way. The header timeout bounds each
attempt, and idleTimeout covers only the body of the attempt that succeeds.
A STREAM request body is still never retried.
The request handed to call and authFailure — and the copy stored on a
RESTlerError's context (including one thrown by your own
_responseHandler) — is credential-redacted before it reaches a listener or
an error context: sensitive header values, url query-string values and
userinfo, and the payload (omitted entirely) never leak into a log. The
request actually sent over the wire is unaffected.
See Restler-Security for the full redaction contract — exactly what is and isn't covered, how a transport failure's
causechain is scrubbed, and how to extend the sensitive-header set for a vendor-specific credential header via_isSensitiveHeader.
A throwing or rejecting event listener never corrupts a request. Each call,
authFailure, and rateLimit listener runs in isolation: a listener's
synchronous exception is contained, and an async listener's rejection is
caught (so it never escapes as an unhandled rejection that would terminate the
process). Either way the listeners registered after it still run, and the
request's own success or error is unaffected — so a bug in a monitoring or
metrics listener can neither reject a successful response, mask the real error,
nor silence the other listeners.
import { RESTler } from '@tundralibs/restler';
class GitHubAPI extends RESTler {
public readonly vendor = 'github';
constructor(token: string) {
super({
baseURL: 'https://api.github.com',
auth: { type: 'BEARER', token },
});
}
}
declare const token: string;
const api = new GitHubAPI(token);
api.on('rateLimit', (vendor, limit, reset, remaining) => {
console.warn(
`[${vendor}] rate limited: ${remaining}/${limit}, resets ${reset}`,
);
});
api.on('call', (vendor, request, response) => {
console.debug(
`[${vendor}] ${request.method} ${request.url} -> ${response.status}`,
);
});RESTler imports no logging or tracing package — observability wires up at
the application's composition root through two generic constructor
options, plus the events above. The vendor client stays
observability-agnostic; it just lets the hooks flow through to super
(the RESTlerHooks type is exported for exactly this):
import { RESTler, type RESTlerHooks } from '@tundralibs/restler';
declare const token: string;
declare const tracer: {
wrapClient: RESTlerHooks['witness'];
propagation: RESTlerHooks['headerProvider'];
};
class GitHubAPI extends RESTler {
public readonly vendor = 'github';
constructor(token: string, hooks: RESTlerHooks = {}) {
super({
baseURL: 'https://api.github.com',
auth: { type: 'BEARER', token },
...hooks, // witness? headerProvider? — never inspected here
});
}
getUser(login: string) {
return this._makeRequest<{ id: number; login: string }>({
path: `/users/${login}`,
method: 'GET',
});
}
}
// The app wires observability once; domain calls don't change:
const api = new GitHubAPI(token, {
witness: tracer.wrapClient, // a CLIENT span per outbound request
headerProvider: tracer.propagation, // traceparent per request (tracer >= 0.5)
});
const res = await api.getUser('octocat'); // traced + propagated, nothing new herewitness — the suite's Witness convention
(shared shape with norm): every request runs through the hook with a
span-style name (restler.github GET) and low-cardinality attributes
(vendor, method, raw path — never the resolved URL or query string, which
can carry credentials). A witness observes and must not interfere: it calls
the wrapped fn exactly once, returns its result unchanged, and re-throws
its errors.
headerProvider — a per-request thunk whose headers go out on the wire,
layered defaults < provider < endpoint.headers (auth always wins). It runs
inside the witnessed window, which is the property propagation depends
on: tracer.propagation reads the active span at send time, so the
traceparent carries that request's span id and the downstream service
joins the trace correctly parented. A throwing provider is contained — the
request proceeds without its headers. It is not tracing-specific:
headerProvider: () => ({
'x-correlation-id': String(ambient.get()?.correlationId ?? ''),
}),Correlated logs. Event listeners fire inside the calling request's async
context, so a logger wired with a contextProvider
(ambient request bag, trace identity via
tracer.logContext) stamps correlation ids on every line a listener emits —
no argument threading. See
Slogger-Correlation.
For the raw-fetch shape these hooks replace — or clients not built on
RESTler — see
Outbound: propagating the trace.
Some APIs report failures inside a successful HTTP response — a 200 whose
body is { "ok": false, "error": "…" }, or a success envelope wrapping the
real data. Others just don't guarantee their response actually matches what
you expect — a vendor's contract can silently change. _makeRequest's second
argument is an OPTIONS bag with two independently optional hooks that compose
into one pipeline, plus skipAuth (see
OAuth2 token exchange):
_makeRequest(endpoint, {
responseHandler?: (response) => H | Promise<H>,
responseSchema?: (data: H) => B | Promise<B>,
skipAuth?: boolean,
})raw parsed body
→ if responseHandler present: data = await responseHandler(response) // full response — status/headers visible, not just body
→ if responseSchema present: data = await responseSchema(data) // data = raw body if no handler ran, handler's output otherwise
→ response.body = data
Neither present → today's default (the parsed body, untouched). Only one present → its result is final. Both present → the handler's output feeds the schema.
Runs on every response — error statuses and empty bodies included — so it
can translate a vendor convention. It receives the FULL RESTlerResponse
(status/headers included, not just the body):
-
throw to reject the request (throw a
RESTlerErrorsubclass to surface it unwrapped; other errors are wrapped inRESTlerRequestErrorwith the original ascause), or -
return the value the request resolves to — an unwrapped envelope, or
simply
response.bodyunchanged if nothing needs transforming. There is no mutate-in-place channel; the return value IS the result.
Set a vendor-wide default via the protected _responseHandler field, or pass
responseHandler per call (which takes precedence entirely — it does not
compose with the vendor default; only one of the two ever runs).
import { RESTler, RESTlerRequestError } from '@tundralibs/restler';
import type { RESTlerResponseHandler } from '@tundralibs/restler';
type Envelope = { ok: boolean; data?: unknown; error?: string };
class PaymentAPI extends RESTler {
public readonly vendor = 'payments';
// Every endpoint of this vendor shares the same envelope convention.
protected override _responseHandler: RESTlerResponseHandler = (response) => {
const body = response.body as Envelope;
if (body?.ok === false) {
throw new RESTlerRequestError(`Vendor error: ${body.error}`, {
vendor: this.vendor,
request: { url: response.url, method: 'GET', timeout: 30 },
});
}
return body?.data; // unwrap: callers see the payload directly
};
constructor() {
super({ baseURL: 'https://api.payments.example' });
}
getBalance(account: string) {
return this._makeRequest<{ balance: number }>({
path: `/accounts/${account}/balance`,
method: 'GET',
});
}
// A per-call handler overrides the vendor default when one endpoint
// deviates from the convention. Must return `response.body` explicitly
// to leave it unchanged.
rawHealth() {
return this._makeRequest({ path: '/health', method: 'GET' }, {
responseHandler: (response) => response.body,
});
}
}A plain runtime validator/parser — (data: H) => B | Promise<B> — for the
value the request ultimately resolves to. B is INFERRED from the schema's
return type, so you no longer separately write out (and manually keep in
sync) a type argument that nothing actually checked against the wire.
No coupling to any particular validation library — a
@tundralibs/guardian schema's own
.parse satisfies the signature directly, and so does any hand-rolled
function:
import { RESTler } from '@tundralibs/restler';
class UserAPI extends RESTler {
public readonly vendor = 'users';
constructor() {
super({ baseURL: 'https://api.example.com' });
}
getUser(id: string) {
return this._makeRequest(
{ path: `/users/${id}`, method: 'GET' },
{
responseSchema: (data) => {
// A real schema would be e.g. `(d) => UserSchema.parse(d)`.
const d = data as { id: string; name: string };
if (typeof d.id !== 'string') throw new Error('missing id');
return { id: d.id, name: d.name };
},
},
);
}
}A thrown validation error (a GuardianError, or whatever your schema
throws) surfaces as RESTlerResponseValidationError — distinct from a
transport failure or timeout: the request SUCCEEDED, but what came back
didn't match what you declared to expect. See
Error Handling.
responseHandler is not required to unwrap an envelope — a schema that
itself encodes the "wrapped or not" shape (e.g. via a discriminated union)
can validate the RAW body directly:
import { Guardian } from '@tundralibs/guardian';
const Envelope = <T>(inner: ReturnType<typeof Guardian.object>) =>
Guardian.discriminatedUnion('status', [
Guardian.object({ status: Guardian.literal('ok'), data: inner }),
Guardian.object({ status: Guardian.literal('error'), error: ErrorSchema }),
]);
this._makeRequest(endpoint, {
responseSchema: (data) => Envelope(UserSchema).parse(data),
});A non-throw no longer implies success here — it means the response matched
ONE of the declared shapes. response.body is the real discriminated union;
narrow on it afterward (if (response.body.status === 'error')) with full
type safety, instead of being forced into exception-based control flow for
an expected error shape.
_makeRequest rejects on transport failures (it attaches the error to
response.error and re-throws it). HTTP error statuses do not reject —
unless a response handler inspects the body and
throws, or a response schema rejects the response.
| Error | Thrown when |
|---|---|
RESTlerConfigError |
Invalid client options or endpoint config (bad baseURL/port/auth/…). |
RESTlerTimeoutError |
The request exceeded timeout. |
RESTlerRateLimitError |
Rate-limited with no retry possible. context.retryAfter carries the parsed wait in seconds when the vendor gave one; context.retried says whether a wait was already spent. |
RESTlerResponseValidationError |
responseSchema threw — the request succeeded, but the response didn't match what you declared to expect. The original error is preserved as cause. |
RESTlerRequestError |
Any other failure while making the request. RESTlerTimeoutError and RESTlerResponseValidationError are both subclasses. |
RESTlerError |
Base class for all of the above. |
Every error's
context.requestis credential-redacted the same way as thecallevent's copy (see Restler-Security) — butRESTlerTimeoutError.messageis currently a literal, un-interpolated string (it readsRequest timed out after ${request.timeout}sverbatim, with the placeholder text and not the actual number). Match oninstanceof, not on the message text, until this is fixed upstream.
import {
RESTlerError,
type RESTlerResponse,
RESTlerResponseValidationError,
RESTlerTimeoutError,
} from '@tundralibs/restler';
declare const api: {
getUser(
login: string,
): Promise<RESTlerResponse<{ id: number; login: string }>>;
};
try {
const res = await api.getUser('octocat');
if (res.status === 404) {
console.log('not found');
} else {
console.log(res.body);
}
} catch (err) {
if (err instanceof RESTlerTimeoutError) {
console.error('timed out');
} else if (err instanceof RESTlerResponseValidationError) {
// Retrying won't fix this the way retrying a timeout might — the
// vendor's response no longer matches its declared contract.
console.error(
`response schema rejected it: ${(err.cause as Error)?.message}`,
);
} else if (err instanceof RESTlerError) {
console.error(`request failed: ${err.message}`);
} else {
throw err;
}
}Extend once per API vendor.
Constructor: new (options: RESTlerOptions, defaults?: Partial<O>) —
validates and stores options; defaults are applied where options omit them.
Abstract members:
-
vendor: string— identifier used in events and errors.
Protected members (used by / overridable in your subclass):
-
_makeRequest<H, B>(endpoint: RESTlerEndpoint, options?: RESTlerRequestOptions<H, B>): Promise<RESTlerResponse<B>>— perform a request. ThrowsRESTlerTimeoutError/RESTlerResponseValidationError/RESTlerRequestError/RESTlerConfigError.options.responseHandlerandoptions.responseSchemacompose into one pipeline;options.skipAuthskips_authInjectorfor this one call (see Vendor Response Handling). -
_responseHandler?: RESTlerResponseHandler— vendor-wide default response handler;options.responseHandleroverrides it entirely (the two do not compose with each other). -
_authInjector(endpoint): void | Promise<void>— inject auth. Override for custom schemes; may be async. By the time it runs,endpoint.headersalready holds the FULL outbound set (defaults +headerProvider+ caller-explicit), so a signing scheme can sign everything actually sent. -
_base64Utf8(value: string): string— UTF-8-correct base64 encoding, identical across Deno/Bun/Node; reuse it in aCUSTOMauth override instead of reimplementing it. -
_fetch: typeof fetch— thefetchimplementation (compat's by default). Override to supply a custom transport or a stub; plainfetchworks for any request that doesn't usesocketPathortls. -
_defaultHeaders,_authStatus([401, 403, 407]),_rateLimitStatus([429]) — overridable defaults.
Inherited (event emitter): on(event, handler), once(...),
off(...), emit(...).
-
RESTlerOptions— client configuration (see Configuration). -
RESTlerEndpoint— per-request config:path+method(required) plus optionalbaseURL,port,version,auth,query,headers,timeout,responseType('BLOB' | 'ARRAY_BUFFER', see Binary Responses), and (for body methods)contentType+payload.pathis NORMALIZED viapath.joinagainst the base URL (//collapses,./..resolve) — percent-encode an opaque, caller-controlled segment (an object-storage key, a filename) yourself first if it could plausibly contain those sequences. -
RESTlerResponse<T>—{ url, status, statusText, headers?, body?, error?, timeTaken }. -
RESTlerRequestOptions<H, B>—_makeRequest's options bag:{ responseHandler?, responseSchema?, skipAuth? }. See Vendor Response Handling. -
RESTlerResponseHandler<H>—(response: RESTlerResponse<unknown>) => H | Promise<H>; the vendor hook described in Vendor Response Handling. -
RESTlerResponseSchema<H, B>—(data: H) => B | Promise<B>; the runtime validator described inresponseSchema. Plain function, no coupling to any particular validation library. -
RESTlerAuth—BASIC | BEARER | CUSTOMdiscriminated union.RESTlerAuthTypesis the discriminator;RESTlerAuthBasic({ username, password }— password may be empty, RFC 7617) andRESTlerAuthBearer({ token, prefix? }) are the per-scheme payloads. -
RESTlerContentType—'JSON' | 'XML' | 'FORM' | 'TEXT' | 'BLOB' | 'STREAM'.FORM's wire format depends on the payload's shape — see Content Types;STREAMis described under Streaming. -
RESTlerStreamOptions<H>—_makeStreamRequest's options bag:{ responseHandler?, skipAuth?, errorStatus?, idleTimeout? }. Deliberately has noresponseSchema— see Streaming. -
RESTlerMethod—'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'. -
RESTlerEvents— the event handler signatures. -
RESTlerErrorMeta— metadata carried by everyRESTlerError:vendorplus error-specific fields (e.g.key/value,request).
- Security - The credential-redaction contract: what's covered, what isn't, and extending it for a vendor-specific header
- Examples - A runnable, end-to-end vendor client (auth + response handling + events + error mapping)
MIT