liaisev5.3.1
/Defining endpoints

Guide

Defining endpoints

An endpoint definition tells liaise an endpoint’s method, its path, and the types of its params and response. Read this page when you add an endpoint, or when a call doesn’t compile and you want to know why.

You describe each endpoint once, and every call to it is then checked against that description. Here is one endpoint, listRepos, and the calls you can make to it:

import { createApi, defineRequest } from 'liaise'

type Repo = { id: number; name: string }

// The first type is the response. The second is the params the path doesn't name.
const listRepos = defineRequest<Repo[], { page?: number }>()({
  method: 'GET',
  path: '/orgs/:org/repos',
})

const api = createApi({ baseUrl: 'https://api.example.com', requests: { listRepos } })

await api.listRepos({ org: 'acme' })           // GET /orgs/acme/repos
await api.listRepos({ org: 'acme', page: 2 })  // GET /orgs/acme/repos?page=2

// Leaving out org doesn't compile:
// await api.listRepos({ page: 2 })

The path /orgs/:org/repos names org, so every call must pass it, and its value is filled into the URL. page comes from the second type argument. It is optional, and for a GET it goes in the query string. The commented-out call at the end doesn’t compile, because it leaves out org.

Path params

  • The path names the required params. Each :name that starts a path segment becomes a required param of type string | number. Its value is filled into the URL and left out of the query string and the body.
  • A colon anywhere else is plain text. In /v1/documents:batchGet and /time/12:30 the colon doesn’t start a segment, so nothing is filled and the path is sent as written. A call that passes a param named after one, such as batchGet, is refused, so its value isn’t sent somewhere you didn’t put it. Colons in a path has the details.
  • Query params go in the second type argument, not in the path. A path can’t fill a query string, so a :name after ?, & or =, as in /v2/simple/price?:qs, is a compile error. Declare the params instead, as in Query string or body.
  • A path param must have a usable value. It must be a non-empty string, a finite number, a bigint or a boolean. undefined, null, '', an object, an array, a Date and NaN are refused before anything is sent, with an error naming the param.
  • That catches the most common mistake, which is calling before an id has loaded. An org that is still undefined at runtime returns an error with kind: 'network' instead of fetching /orgs/undefined/repos.
  • A # in the path is a compile error. A URL fragment is never sent to the server, so path: '/docs#section' is refused where you write it. URL fragments has the details.

Query string or body

  • Every other param goes in the second type argument. For GET and DELETE, these params go in the query string. For POST, PUT and PATCH, they go in a JSON body. Sending data covers what each one can hold.

  • bodyAs flips that default. Use it for an API that does it the other way round:

    type Job = { id: string }
    
    // A DELETE that takes a JSON body
    const bulkDelete = defineRequest<{ deleted: number }, { ids: string[] }>()({
      method: 'DELETE',
      path: '/items',
      bodyAs: 'body',
    })
    
    // A POST that sends its params in the query string
    const triggerJob = defineRequest<Job, { priority: number }>()({
      method: 'POST',
      path: '/jobs/trigger',
      bodyAs: 'query',
    })
  • An endpoint with no params is called with no arguments. With defineRequest<{ status: string }>()({ method: 'GET', path: '/health' }), both api.health() and api.health({}) work.

The response

  • The first type argument is the response type. data has that type once you have checked error.

  • responseType says how to read the response body. It defaults to 'json'. The options are under Reading responses.

  • responseType: 'none' needs the response type undefined. Anything else is a compile error, because data is always undefined for an endpoint that sends no body:

    defineRequest<undefined>()({ method: 'POST', path: '/ping', responseType: 'none' })  // ✓
    // @ts-expect-error
    defineRequest<Repo>()({ method: 'POST', path: '/ping', responseType: 'none' })       // ✗
  • With a schema, you write no response type at all. The schema supplies it (Validating responses).

Cookies and other fetch options

fetchOptions passes options through to fetch: credentials, mode, cache, redirect, keepalive, priority and anything else RequestInit has, except method, headers, body and signal, which liaise sets itself. Set it on the client, on an endpoint or on one call:

import { createApi, defineRequest } from 'liaise'

type User = { id: string; name: string }

const api = createApi({
  baseUrl: 'https://api.example.com',
  // Send the session cookie on every call, to an API on another origin.
  fetchOptions: { credentials: 'include' },
  requests: {
    getMe: defineRequest<User>()({ method: 'GET', path: '/me' }),
    // Answers change often: skip the browser's HTTP cache for this endpoint.
    getUser: defineRequest<User>()({ method: 'GET', path: '/users/:id', fetchOptions: { cache: 'no-store' } }),
  },
})

const me = await api.getMe()
// keepalive lets this one call finish even if the page is closing.
const user = await api.getUser({ id: '2' }, { fetchOptions: { keepalive: true } })
  • The most specific level wins, field by field, as with headers. getUser sends credentials: 'include' from the client and its own cache: 'no-store'. A field set to undefined doesn’t replace the level below, so to undo the client’s credentials for one call, pass the value you want, such as 'same-origin'.
  • credentials: 'include' is what cookie auth to another origin needs. By default fetch sends cookies only to the page’s own origin.
  • Middleware sees the merged options as ctx.request.fetchOptions, and can change them (MiddlewareContext).
  • Calls whose options differ never share a request.

Why two calls

defineRequest<Repo[]>()({ ... }) is two calls because TypeScript can’t infer some type arguments while you write others. The first call takes the response type you write, and the second infers the params from the path. In a single call, writing the response type would quietly turn the path checking off.

Without defineRequest

new Request<TParams, TResponse>(config) is the class that defineRequest builds. You write the params type yourself, covering path, query and body params together, and nothing checks it against the path:

import { createApi, Request } from 'liaise'

type User = { id: string; name: string }

const getUser = new Request<{ userId: string }, User>({ method: 'GET', path: '/users/:id' })
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })

await api.getUser({ userId: '42' })
// Compiles. The call then returns an error, because the path has no :userId and :id is never filled in.

Use new Request when the config isn’t a literal, for example when you build it at runtime, or when you don’t want the path checked. It is not deprecated. For an endpoint with no params, write Record<string, never> as TParams.

Importing liaise’s Request hides the Fetch API’s Request class in that file. If the file needs the standard one too, rename liaise’s on import, import { Request as Endpoint } from 'liaise', or reach the global as globalThis.Request. defineRequest doesn’t have this problem.

Next: Handling errors shows what a call gives back when something goes wrong, and how to tell the failures apart.

esc