Skip to content

Cookbook: adapting endpoints

Eugene Lazutkin edited this page Aug 8, 2026 · 1 revision

Cookbook: Adapting Endpoints

Components that fetch their own data — a table, a form, a chart — are only reusable if they can assume one response shape. Real endpoints rarely agree on one. Rather than teaching every component about every vendor, adapt at the I/O layer: a pair of URL-scoped hooks turns a vendor's shape into your house shape, once, for every consumer that touches that endpoint.

The house shape used throughout this wiki is the one io.paginate understands natively — data, offset, limit, an optional total, and a links object with prev/next.

Normalize a response envelope

A response inspector receives the finished envelope and may rewrite it in place. This is the whole adapter for a Django REST Framework collection ({count, next, previous, results}):

import io from 'double-meh';

io.inspect.response(envelope => {
  const body = envelope.data;
  if (!body || !Array.isArray(body.results)) return; // not a collection — leave it alone
  envelope.data = {
    data: body.results,
    total: body.count,
    links: {next: body.next, prev: body.previous}
  };
}, 'https://api.example.com/');

Every consumer of that host now sees the house envelope — including io.paginate, which will follow links.next without knowing DRF exists:

for await (const row of io.paginate('https://api.example.com/products')) render(row);

Guard the shape before rewriting, as above. An inspector scoped to a host still sees every response from it, collections and single objects alike, so a bare envelope.data = {...} would mangle the ones you did not mean.

Reshape the request

A request inspector runs on the prepared request and can rewrite the URL — useful when a vendor spells the paging parameters differently:

io.inspect.request(request => {
  const url = new URL(request.url); // absolute: the match below is an absolute prefix
  const q = url.searchParams;
  for (const [ours, theirs] of [
    ['offset', 'skip'],
    ['limit', 'take']
  ]) {
    if (!q.has(ours)) continue;
    q.set(theirs, q.get(ours));
    q.delete(ours);
  }
  request.url = url.href;
}, 'https://vendor.example.com/');

For anything that is a plain default rather than a transformation, prefer io.defaults — it is the lowest merge layer, so per-call options still win:

io.defaults('https://vendor.example.com/', {
  accept: 'application/vnd.vendor+json',
  listSeparator: ',' // this vendor wants ?tags=a,b rather than repeated keys
});

Scope it

Every hook takes an optional match — a URL prefix, a RegExp, or a predicate:

io.inspect.response(normalizeDrf, 'https://api.example.com/'); // prefix
io.inspect.response(normalizeOData, /\/odata\//); // RegExp
io.inspect.response(normalizeLegacy, url => url.includes('/v1/')); // predicate

Unscoped hooks run for every request, so give an adapter the narrowest match that covers its endpoint. When two APIs need genuinely different machinery rather than different settings — separate caches, separate services — reach for io.create() instead; see Cookbook: multiple APIs.

Take over a whole content type

When a vendor ships its own media type rather than a differently-shaped JSON, the seam is registerMime (decode) and registerData (encode) rather than an inspector — the decode runs on error bodies too, so BadStatus.problem picks the vendor's fault envelope up for free. Response inspectors are for reworking shared MIME types that must not be hijacked globally. See Concepts: pluggable envelopes.

Where each hook runs, and what it affects

The two sides are not symmetric, and the difference shows up in the cache:

Hook Runs Affects the cache key? Cache stores
io.defaults lowest merge layer, before the request is built yes
io.inspect.request after prepare, before request identity yes
io.inspect.response after the service onion, on the way out no the wire body

So a request-side rewrite canonicalizes identity too — two vendor spellings of one resource collapse onto a single cache and dedup entry.

A response-side rewrite does not, and that is deliberate: the cache is a wire cache. It holds what the server actually sent, which is what keeps ETag/304 revalidation and Vary selection meaningful — a reshaped entry would no longer correspond to any HTTP representation the server could revalidate. It also keeps your adapter swappable: edit a transform and every cached entry stays valid, whereas a cache of transformed bodies would keep serving the old shape until eviction. Transforms sit outside the cache, and re-running one on each delivery costs a field reshuffle — nothing next to the request it saved. Only worry about it if a response inspector does something genuinely expensive.

See also

Clone this wiki locally