navree
Documentation

The typed client

@navree/client: a client typed from your API's OpenAPI document, NavreeError with Navree's error codes, list queries, pages, query keys and operations.

On this page

@navree/client calls a Navree API with types generated from the API's own OpenAPI document, so a renamed field or a removed route is a compile error in the front end. It is openapi-fetch with Navree's conventions built in: the error shape and its code, the workspace header, request ids, list queries and the page shapes.

Generating the schema

navree openapi:client exports the app's document and runs openapi-typescript on it:

Shell
OptionDefaultMeaning
--out DIRweb/src/lib/apiWhere openapi.json and schema.d.ts go, relative to the app.
--input FILEnoneGenerate from this document instead of running openapi:export.

The command runs cargo run --quiet -- openapi:export <out>/openapi.json, then npx --yes openapi-typescript@7 <out>/openapi.json --output <out>/schema.d.ts. Commit both files and run the command again after changing routes or types; a CI step that runs it and fails on a diff keeps the front end and the API in step.

Creating the client

web/src/lib/api/client.ts
TypeScript

GET, POST, PUT, PATCH and DELETE take the path as the document writes it; the path parameters, query and body are typed from it, and so is the data. unwrap returns the data or throws a NavreeError.

With no token, the client is in cookie mode: requests go to the page's own origin with credentials: "same-origin" and no Authorization header. That is the mode for a browser behind a backend-for-frontend, which holds the tokens in httpOnly cookies.

With a token, the client is in bearer mode: it sends Authorization: Bearer <token> and omits cookies. Use it on servers, in scripts and in apps that hold the token themselves:

TypeScript

Options

OptionDefaultMeaning
baseUrl""Where the API (or the BFF) is. Empty means the page's own origin.
tokennoneA token, or a function returning one (sync or async). Turns on bearer mode.
credentialssame-origin in cookie mode, omit in bearer modeFetch credentials.
workspacenoneThe current workspace, or a function returning it; sent as workspaceHeader.
workspaceHeaderX-WorkspaceThe API's workspace.header.
requestIdtrueX-Request-Id on every request: true makes a UUID, a function makes your own, false sends none.
headers{}Headers for every request; a request's own headers win.
fetchglobalThis.fetchThe fetch to use (tests, server runtimes).
onUnauthorized(response, request)noneCalled on every 401: send the browser to sign-in.
onForbidden(error, request)noneCalled on every 403 with the parsed NavreeError: a toast.

Every request id is logged by the API with the request, and comes back on errors as error.requestId, so a support ticket can quote it.

Requests that aren't JSON

api.raw(path, init) sends a plain request with the same headers and hooks, for CSV exports, event streams and channel authorization:

TypeScript

List endpoints

Until an endpoint's OpenAPI entry describes its list query, give the client the query type with WithQuery:

web/src/lib/api/client.ts

Errors

Every error from a Navree API has the same body, and NavreeError carries all of it:

409 Conflict
MemberValue
statusThe HTTP status; 0 when the request got no answer.
codeThe body's code, or the status's default (not_found, unauthenticated, validation_failed for a 400 with field errors, network_error for no answer).
messageThe body's message; the messages of a validation error joined with ; .
messagesmessage as a list.
errorsField errors of a validation failure ({field: [message]}), empty otherwise.
fieldError(field)The first message for field.
isValidationA 400 with field errors or a list of messages.
is(...codes)True when code is one of codes.
requestIdThe response's X-Request-Id.
bodyThe body as sent, with code filled in.
TypeScript

errorMessage(error, fallback?) is a sentence for a toast, in English: the API's own message when it says something specific, else a sentence for the status ("That record no longer exists." for a 404, "Too many attempts. Wait a minute and try again." for a 429, "Can't reach the server. Check your connection." with no answer). For other languages, translate the code instead: see localization. The codes the framework sends are listed on the errors page.

List queries

A list endpoint reads filters, sort, search and paging from the query string. listQuery() builds one; every call returns a new builder:

TypeScript
Text
Builder callQuery string
where("stage", "won")filter[stage]=won
where("amount_cents", "gte", 1000)filter[amount_cents][gte]=1000 (several operators on one field add up)
whereIn("stage", ["lead", "won"]) or where("stage", ["lead", "won"])filter[stage][in]=lead,won
where("owner_id", "null", true)filter[owner_id][null]=true
without("stage")removes the filter
sort("-created_at", "name")sort=-created_at,name (- sorts descending)
search("acme")search=acme
page(2), perPage(25)page=2&per_page=25
cursor("eyJpZCI6NDJ9"), limit(50)cursor=eyJpZCI6NDJ9&limit=50 (drops page and per_page)

The operators are eq, ne, gt, gte, lt, lte, in, not_in, contains, starts_with and null. Dates are sent as ISO 8601, booleans and numbers as text, and lists joined with commas.

The same format works as data: serializeListQuery(query) writes URLSearchParams, parseListQuery(input) reads a query string, URLSearchParams or a Next.js searchParams object (ignoring parameters it doesn't own), and the client's query serializer writes a ListQuery object in this format. setFilter(query, field, value) changes one filter and goes back to page 1, withPage(query, n) moves to a page, and toggleSort(sort, field) cycles a column through ascending, descending and off.

Pages

List endpoints answer offset pages or cursor pages:

JSON
HelperReturns
isPage(value), isCursorPage(value)Which shape a value has.
hasNextPage(page)Whether another page follows.
pageRange(meta){from, to}: the rows the page shows, counting from 1 (26–50).
facetCounts(meta, field)[{value, count, label?}] from meta.facets, whether the API sends a map of counts or a list.
pages(fetchPage, query)An async iterator over every page, by page number or by cursor.
collectAll(fetchPage, query, {maxPages})Every row of every page.
TypeScript

Query keys

queryKeys(resource) gives one family of TanStack Query keys per resource, nested so one invalidation reaches every list and record:

TypeScript
KeyValue
deals.all["deals"]
deals.lists()["deals", "list"]
deals.list(query)["deals", "list", query]
deals.details()["deals", "detail"]
deals.detail(id)["deals", "detail", "42"]
deals.board("u1")["deals", "board", "u1"]

Operations

Long work a user starts (an import, an export, a bulk change) is an operation the user follows: the queue module serves it at GET /api/operations/{id}, DELETE /api/operations/{id} and GET /api/operations/{id}/events. followOperation reads its server-sent event stream with fetch, so it works through the BFF and with bearer tokens, and returns the finished operation:

TypeScript
OptionDefaultMeaning
fetchfetch on the page's originSends the requests (api.raw adds the client's headers).
basePath/api/operationsWhere the routes are mounted.
onUpdate(operation)noneCalled on every change, the first state included.
signalnoneStops following; the promise rejects with the abort reason.
retries5Reconnects in a row without a new event before giving up with stream_closed.
retryDelayMs1000Reconnect n waits n × retryDelayMs.

A dropped stream is resumed with Last-Event-ID. A 404 (an operation the user may not see) throws a NavreeError at once. cancelOperation(id) sends DELETE and returns the cancelled operation, getOperation(id) loads it, and isFinished(op) is true for succeeded, failed and cancelled. readEventStream(body) is the event-stream parser underneath, for your own streams.

Abilities and real time

Two entry points need a library of their own, so they are separate imports:

  • @navree/client/abilities (needs @casl/ability): createAbility(rules) builds a CASL ability that checks API records by their __type, and fetchAbilities({fetch}) loads the rules from GET /api/me/abilities.
  • @navree/client/realtime (needs pusher-js): connectRealtime({server, authorize}) connects to the API's Pusher-protocol server. listen(channel, event, callback) returns a function that stops listening, and a channel is left when its last listener stops. A bare channel name is private (crm.deals is private-crm.deals), publicChannel("status") is public, and presence- names stay as they are. createMemoryRealtime() is the same interface without a socket, for tests and mock modes: emit(channel, event, data) plays the server.

The Next.js and Nuxt packages wrap both in providers and hooks.