developer guide
Connect a Data Grid to a REST or JSON API
The endpoint you already have: rename its parameters, declare only the operators it truly applies, and the grid pushes what it can and finishes the rest.
For the API you already wrote
Most data does not sit behind a warehouse. It sits behind a service somebody on the team wrote, in front of a database, and until now the honest answer to "how do I connect the grid to it" was "write a fetch function". The REST adapter is that fetch function, with the query already translated into query parameters and their names yours to choose, so pointing the grid at an existing endpoint is a few lines of configuration rather than a hand-written source.
import { createGrid, createPushdownSource, restAdapter } from '@toclocoinc/lattice-grid';
import * as compute from '@toclocoinc/lattice-grid';
createGrid(el, {
columns,
rowKey: 'id',
source: createPushdownSource({
adapter: restAdapter({
url: '/api/orders',
// Rename the parameters to whatever your endpoint already calls them.
params: { offset: 'skip', limit: 'take', sort: 'orderBy', order: 'dir' },
// Declare only the comparisons the endpoint genuinely applies.
operators: ['eq', 'gt', 'lt', 'contains'],
rows: (body) => body.results, // pull the rows out of your shape
total: (body) => body.meta.totalCount, // and the match count
}),
compute,
pageSize: 100,
}),
});
Declare only what the endpoint truly does
The adapter declares almost nothing by default, and that is deliberate. Paging and sorting are assumed, because an endpoint that cannot page is not one you would put a grid in front of. Filtering is assumed absent until you say otherwise, because guessing wrong here returns the wrong rows silently rather than merely being slow. Claiming an operator the endpoint ignores would mean the grid pushes a filter that is dropped and then trusts the unfiltered result; claiming too little only costs speed. So the safe direction is to declare exactly what your endpoint genuinely applies.
restAdapter({
url: '/api/readings',
params: { offset: 'from', limit: 'size', sort: 'orderBy', order: 'dir' },
// An endpoint that pages and sorts, but does not filter:
capabilities: { sort: 'single', range: true, total: true, filter: false },
})
Whatever you do not declare, the grid does itself. Leave filtering off and the grid fetches the matching
set and filters it in the browser, correct and slower, until you widen the endpoint and the same filter
moves to the server. That trade is visible after every query through source.lastPlan(), and
the source warns once when it has to finish work the endpoint could not, naming the part.
Reading your response shape
Your endpoint returns whatever shape it already returns. Point rows at the array inside the
body and total at the match count, or pass a bare array and the adapter reads it directly.
Rename the offset, limit, sort, order, filter and search parameters with params, and encode
the filter tree however your endpoint expects with encodeFilter.
The one thing to get right is total: it is the count of everything matching the filter, not
the length of the page returned. The grid sizes its scrollbar from it, so returning the page length
makes a large result look like a single page. If your endpoint can report the real match count, read it
into total; if it cannot, leave it and the grid manages without an exact scrollbar.
Authenticating the endpoint
The adapter carries no auth logic of its own; it only sends what you give it. A credential that does not
change - an API key or a long-lived token - goes in headers, which the adapter merges into
every request. A credential that expires needs a different approach, because a static header goes stale:
pass a custom fetch instead, and the adapter calls it fresh for every request, which is the
one place a new token can be minted per call.
// A fixed credential (an API key, or a long-lived token) goes in headers,
// sent as-is on every request.
restAdapter({
url: '/api/orders',
headers: { Authorization: 'Bearer ' + apiKey },
})
// A token that expires cannot live in a static header, so refresh it in a
// custom fetch instead. The adapter calls your fetch fresh for every
// request, which is the one place a new token can be minted per call.
restAdapter({
url: '/api/orders',
fetch: async (url, init) => {
const token = await getFreshToken();
return fetch(url, { ...init, headers: { ...init.headers, Authorization: 'Bearer ' + token } });
},
})
Those two options cover every scheme, because headers and a custom fetch are all
an HTTP call ever needs. The examples below are the common cases spelled out.
Basic auth and a custom header name
A username and password becomes an Authorization: Basic header once you encode it, and it
goes in headers like any other fixed credential. An endpoint that wants its key under its own
header name is the same idea: the adapter sends whatever keys you give it, so X-API-Key or
any other name works, not just Bearer.
// Basic auth: encode username and password into the Authorization header.
restAdapter({
url: '/api/orders',
headers: { Authorization: 'Basic ' + btoa(user + ':' + pass) },
})
// A non-standard scheme is just another header. Any name works, not only
// Authorization, because the adapter sends the map through unchanged.
restAdapter({
url: '/api/orders',
headers: { 'X-API-Key': apiKey },
})
Cookie or session auth
If your users already hold a session cookie from signing in, you can let the browser send it with each
request rather than managing a token at all. The adapter sends no cookies on its own, so ask for them in
a custom fetch by setting credentials: 'include'. One thing to get right for a
cross-origin endpoint: it has to allow credentialed requests in its CORS response, or the browser will
quietly leave the cookie off.
// Cookie or session auth: let the browser attach the session cookie by
// setting credentials on the request. The adapter carries no cookies of its
// own, so this lives in a custom fetch. A cross-origin endpoint must return
// Access-Control-Allow-Credentials, or the browser drops the cookie.
restAdapter({
url: 'https://api.example.com/orders',
fetch: (url, init) => fetch(url, { ...init, credentials: 'include' }),
})
Refresh on 401 and retry
Minting a fresh token on every single request works, but it is wasteful when the token you hold is still good. The pattern most OAuth setups actually want is to use the cached token, and only refresh when the endpoint rejects it with a 401, then retry the one request. A still-valid token is reused; an expired one is replaced exactly once, at the moment it stops working. It is written for the grid's own read requests; if you adapt it for a call that sends a body, carry that body onto the retry so the second attempt is the same request as the first.
// Refresh on 401 and retry once. This is what most OAuth integrations
// actually need: use the cached token, and only mint a fresh one when the
// endpoint says the old one has expired, so a still-valid token is never
// thrown away.
restAdapter({
url: '/api/orders',
fetch: async (url, init) => {
const withToken = (token) => ({
...init,
headers: { ...init.headers, Authorization: 'Bearer ' + token },
});
let response = await fetch(url, withToken(await getToken()));
if (response.status === 401) {
response = await fetch(url, withToken(await getToken({ force: true })));
}
return response;
},
})
OAuth client-credentials
For a service-to-service integration, fetch a token from your identity provider's token endpoint, hold it
until it is close to expiring, and refresh it from inside the custom fetch. Keeping the token
endpoint call in a small helper means the adapter never has to know how the token is obtained; it only
ever receives the finished header. The cache shown here is shared, so an app that runs several connections
against different services or tenants would scope the cache per connection rather than through one.
// OAuth client-credentials: mint a token from the token endpoint, cache it
// until it is close to expiry, and refresh from inside the custom fetch. The
// getToken helper owns the token endpoint call; the adapter only ever sees
// the header it produces.
let cached = null; // { token, expiresAt }
async function getToken() {
if (cached && cached.expiresAt > Date.now() + 30_000) return cached.token;
const res = await fetch('https://your-idp/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
}),
});
const { access_token, expires_in } = await res.json();
cached = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };
return cached.token;
}
restAdapter({
url: '/api/orders',
fetch: async (url, init) => {
const token = await getToken();
return fetch(url, { ...init, headers: { ...init.headers, Authorization: 'Bearer ' + token } });
},
})
The REST adapter produces an ordinary remote source, so blocks, caching and abort-on-supersede all apply. The pushdown guide covers the capability model and the split rules in full, and connect your data lists the other options.