liaise 5.0.2 → 5.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/MIGRATION.md +6 -0
- package/README.md +1463 -948
- package/dist/types.d.ts +29 -32
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -2,273 +2,539 @@
|
|
|
2
2
|
|
|
3
3
|
*lee-AYZ* — to act as the link between two parties.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Your API calls, minus the surprises.**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Type-safe REST and GraphQL on plain fetch. Never throws. Zero dependencies. Works with any framework.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- **Never throws** — every call returns `{ data, error, response, retry }`, no try/catch required
|
|
11
|
-
- **Composable middleware** — retry, cache, dedupe, auth, logging — applied at global, per-endpoint, or per-call level
|
|
12
|
-
- **Types by inference** — declare params and response once on the endpoint definition; types flow to every call site automatically
|
|
13
|
-
- **Runtime-agnostic** — Node.js 20+, browsers, Bun, Deno, Cloudflare Workers, React Native (its built-in `fetch`; not tested in CI) — any environment with `fetch`
|
|
14
|
-
- **Tiny** — about **5.8 kB gzipped** for a REST-only import, 6.9 kB for the core entry, 8.0 kB with all middleware (measured by `npm run size`); tree-shaking drops what you do not import
|
|
9
|
+
[](https://www.npmjs.com/package/liaise) [](https://github.com/iremlopsum/liaise/actions/workflows/ci.yml)  
|
|
15
10
|
|
|
16
|
-
```
|
|
11
|
+
```bash
|
|
17
12
|
npm install liaise
|
|
18
13
|
```
|
|
19
14
|
|
|
20
|
-
|
|
15
|
+
Formerly published as `@iremlopsum/apify`; switching takes two steps, see [MIGRATION.md](./MIGRATION.md#upgrading-to-500).
|
|
16
|
+
|
|
17
|
+
**Contents**
|
|
21
18
|
|
|
22
|
-
- [
|
|
23
|
-
- [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
- [
|
|
19
|
+
- [The problem it solves](#the-problem-it-solves)
|
|
20
|
+
- [Quick start](#quick-start)
|
|
21
|
+
- [How it fits together](#how-it-fits-together)
|
|
22
|
+
- [Guide](#guide)
|
|
23
|
+
- [Defining endpoints](#defining-endpoints)
|
|
24
|
+
- [Handling errors](#handling-errors)
|
|
25
|
+
- [Sending data](#sending-data)
|
|
26
|
+
- [Reading responses](#reading-responses)
|
|
27
|
+
- [Validating responses](#validating-responses)
|
|
28
|
+
- [Cancelling, deadlines and stale requests](#cancelling-deadlines-and-stale-requests)
|
|
29
|
+
- [Sharing identical requests](#sharing-identical-requests)
|
|
30
|
+
- [Retries, caching and logging](#retries-caching-and-logging)
|
|
31
|
+
- [Writing middleware](#writing-middleware)
|
|
27
32
|
- [Pagination](#pagination)
|
|
28
|
-
- [
|
|
29
|
-
- [
|
|
30
|
-
|
|
31
|
-
- [
|
|
32
|
-
|
|
33
|
-
- [
|
|
34
|
-
- [
|
|
35
|
-
- [
|
|
36
|
-
- [
|
|
37
|
-
- [
|
|
38
|
-
- [
|
|
39
|
-
- [
|
|
40
|
-
- [
|
|
41
|
-
|
|
42
|
-
- [
|
|
43
|
-
- [
|
|
44
|
-
- [
|
|
45
|
-
- [
|
|
46
|
-
- [
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
33
|
+
- [GraphQL](#graphql)
|
|
34
|
+
- [Testing your code](#testing-your-code)
|
|
35
|
+
- [Recipes](#recipes)
|
|
36
|
+
- [Add an auth header and refresh the token on a 401](#add-an-auth-header-and-refresh-the-token-on-a-401)
|
|
37
|
+
- [Search as you type](#search-as-you-type)
|
|
38
|
+
- [Use with TanStack Query](#use-with-tanstack-query)
|
|
39
|
+
- [Use with React](#use-with-react)
|
|
40
|
+
- [Load the current user into a store](#load-the-current-user-into-a-store)
|
|
41
|
+
- [One /me per page view on the server](#one-me-per-page-view-on-the-server)
|
|
42
|
+
- [Retry a flaky backend within one deadline](#retry-a-flaky-backend-within-one-deadline)
|
|
43
|
+
- [Give each attempt its own timeout](#give-each-attempt-its-own-timeout)
|
|
44
|
+
- [Report errors to Sentry](#report-errors-to-sentry)
|
|
45
|
+
- [Upload and download files](#upload-and-download-files)
|
|
46
|
+
- [Choosing liaise](#choosing-liaise)
|
|
47
|
+
- [When it fits, and when it doesn't](#when-it-fits-and-when-it-doesnt)
|
|
48
|
+
- [How it compares](#how-it-compares)
|
|
49
|
+
- [Where it runs](#where-it-runs)
|
|
50
|
+
- [Design principles](#design-principles)
|
|
51
|
+
- [Reference](#reference)
|
|
52
|
+
- [createApi options](#createapi-options)
|
|
53
|
+
- [createGraphQL options](#creategraphql-options)
|
|
54
|
+
- [Endpoint options](#endpoint-options)
|
|
55
|
+
- [Operation options](#operation-options)
|
|
56
|
+
- [CallOptions](#calloptions)
|
|
57
|
+
- [Result and ApiError](#result-and-apierror)
|
|
58
|
+
- [MiddlewareContext](#middlewarecontext)
|
|
59
|
+
- [Built-in middleware options](#built-in-middleware-options)
|
|
60
|
+
- [liaise/testing](#liaisetesting)
|
|
61
|
+
- [Exports](#exports)
|
|
62
|
+
- [Behaviour in detail](#behaviour-in-detail)
|
|
63
|
+
- [Upgrading, contributing, licence](#upgrading-contributing-licence)
|
|
64
|
+
|
|
65
|
+
## The problem it solves
|
|
66
|
+
|
|
67
|
+
`fetch` is a good building block. Every project still ends up writing the same few things around it, and they are easy to get subtly wrong.
|
|
68
|
+
|
|
69
|
+
| With plain fetch | liaise | See |
|
|
70
|
+
| ---------------- | ------ | --- |
|
|
71
|
+
| Typing fast in a search box shows old results. A slow early search lands last. | `dedupe` cancels the older call. | [Stale requests](#drop-stale-calls-with-dedupe) |
|
|
72
|
+
| Five components load the same data, or five 401s each refresh the token. That's five identical requests. | `share` sends one and hands everyone the answer. | [Sharing identical requests](#sharing-identical-requests) |
|
|
73
|
+
| A 500 counts as success, offline throws, a hung server waits forever. | Every call returns `{ data, error }`, and `error.kind` names the failure. With `timeout` set, a hung server becomes an error too. | [Handling errors](#handling-errors) |
|
|
74
|
+
| Retries run straight past your timeout. | `timeout` covers the whole operation, retries included. | [Deadlines](#set-a-deadline-with-timeout) |
|
|
75
|
+
| The backend changes a field and the page crashes three components later. | A schema checks the response. A bad shape is an error you handle. | [Validating responses](#validating-responses) |
|
|
76
|
+
|
|
77
|
+
### Before and after
|
|
78
|
+
|
|
79
|
+
Here is one form submit, written both ways. `show` stands for whatever puts a message on screen.
|
|
80
|
+
|
|
81
|
+
**With plain fetch**
|
|
51
82
|
|
|
52
83
|
```ts
|
|
53
|
-
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch('/api/orders', { method: 'POST', body: JSON.stringify(order) })
|
|
86
|
+
show(`Order ${(await res.json()).id} confirmed`) // a 500 lands here too
|
|
87
|
+
} catch {
|
|
88
|
+
show('Something went wrong') // offline? broken JSON? no way to tell
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**With liaise**
|
|
93
|
+
|
|
94
|
+
<!-- tested: problem-after -->
|
|
95
|
+
```ts
|
|
96
|
+
async function submit(order: { items: string[] }) {
|
|
97
|
+
const { data, error } = await api.placeOrder(order)
|
|
98
|
+
if (!error) return show(`Order ${data.id} confirmed`)
|
|
54
99
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
100
|
+
switch (error.kind) {
|
|
101
|
+
case 'http': return show(`The server said no (${error.status})`)
|
|
102
|
+
case 'network': return show("You're offline. We'll try again.")
|
|
103
|
+
case 'timeout': return show('This is taking too long. Try again.')
|
|
104
|
+
case 'parse': return show('The server sent something unexpected.')
|
|
105
|
+
}
|
|
60
106
|
}
|
|
107
|
+
```
|
|
61
108
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
109
|
+
`api.placeOrder` is an endpoint defined like the ones in [Quick start](#quick-start), with `timeout: 5000` so a hung server gives up after five seconds.
|
|
110
|
+
|
|
111
|
+
The `switch` leaves out `'abort'`, because nothing here cancels a call, and `'middleware'`, which points at a bug in your own code ([all six kinds](#handling-errors)).
|
|
112
|
+
|
|
113
|
+
### Why another API client?
|
|
114
|
+
|
|
115
|
+
Without it, you have two options. You can hand-roll a wrapper around fetch, which means writing the same boilerplate on every project: a typed function per endpoint, status checks, error handling, retries, cancellation.
|
|
66
116
|
|
|
67
|
-
|
|
117
|
+
Or you can reach for a large library like Apollo or urql. They're built for very large apps with complex data needs. For most products, that's bringing a tank to a chess match, and you spend hours on setup and configuration for features you never use.
|
|
118
|
+
|
|
119
|
+
liaise sits in between. It's the wrapper you'd otherwise hand-roll, already written and tested. Your whole setup is a base URL and your endpoints.
|
|
120
|
+
|
|
121
|
+
**The API client you'd build on your third project, with the edge cases already handled.**
|
|
122
|
+
|
|
123
|
+
## Quick start
|
|
124
|
+
|
|
125
|
+
Define two endpoints, create a client, and make a call.
|
|
126
|
+
|
|
127
|
+
<!-- tested: quick-start -->
|
|
128
|
+
```ts
|
|
129
|
+
import { createApi, defineRequest } from 'liaise'
|
|
130
|
+
|
|
131
|
+
type User = { id: string; name: string; email: string }
|
|
132
|
+
|
|
133
|
+
// 1. Describe your endpoints. The path decides which params are required.
|
|
134
|
+
const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
|
|
135
|
+
const createUser = defineRequest<User, { name: string; email: string }>()({
|
|
68
136
|
method: 'POST',
|
|
69
|
-
path: '/users'
|
|
137
|
+
path: '/users',
|
|
70
138
|
})
|
|
71
139
|
|
|
72
|
-
// 2. Create the client
|
|
140
|
+
// 2. Create the client.
|
|
73
141
|
const api = createApi({
|
|
74
142
|
baseUrl: 'https://api.example.com',
|
|
75
143
|
requests: { getUser, createUser },
|
|
76
|
-
onError: (error) => console.error(`${error.request.method} ${error.request.url}`, error.status)
|
|
77
144
|
})
|
|
78
145
|
|
|
79
|
-
// 3.
|
|
80
|
-
const { data, error
|
|
146
|
+
// 3. Call it. This never throws: you always get { data, error }.
|
|
147
|
+
const { data, error } = await api.getUser({ id: '42' })
|
|
81
148
|
|
|
82
149
|
if (error) {
|
|
83
|
-
|
|
84
|
-
|
|
150
|
+
// error.kind says what went wrong: 'http', 'network', 'timeout', ...
|
|
151
|
+
console.error(error.kind, error.status)
|
|
152
|
+
} else {
|
|
153
|
+
console.log(data.name) // data is a User here
|
|
85
154
|
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### What you just got
|
|
158
|
+
|
|
159
|
+
- The params are checked against the path, so `getUser({ userId: '42' })` is a compile error.
|
|
160
|
+
- `data` is typed from `defineRequest<User>`.
|
|
161
|
+
- Nothing throws, not even when you're offline.
|
|
162
|
+
- Checking `error` first narrows `data` to `User`, so you never write `data!`.
|
|
163
|
+
|
|
164
|
+
### Next
|
|
165
|
+
|
|
166
|
+
- [Handling errors](#handling-errors)
|
|
167
|
+
- [Add an auth header and refresh the token on a 401](#add-an-auth-header-and-refresh-the-token-on-a-401)
|
|
168
|
+
- [Use with TanStack Query](#use-with-tanstack-query)
|
|
169
|
+
- [Use with React](#use-with-react)
|
|
170
|
+
|
|
171
|
+
## How it fits together
|
|
86
172
|
|
|
87
|
-
|
|
88
|
-
|
|
173
|
+
liaise has four pieces, and every call takes the same path through them. Middleware is a function that wraps a call, so it can add a header, retry or log.
|
|
174
|
+
|
|
175
|
+
### Four pieces
|
|
176
|
+
|
|
177
|
+
| Piece | What it is |
|
|
178
|
+
| ----- | ---------- |
|
|
179
|
+
| Endpoint definition (`defineRequest`) | A recipe for one endpoint: its method, its path and the types of its params and response. It does nothing on its own. |
|
|
180
|
+
| Client (`createApi`) | Turns your recipes into typed functions, one per endpoint. |
|
|
181
|
+
| Call | `api.getUser(params, options)`, where `options` can set `signal`, `timeout`, `headers` or `middleware` for this call only. |
|
|
182
|
+
| `Result` | `{ data, error, response, retry }`, which is what every call returns. `data` and `error` are never both set. `retry()` runs the same call again. |
|
|
183
|
+
|
|
184
|
+
Types flow from the endpoint definition through `createApi` to every call, so you never annotate a call. When you need a type by name, it is listed under [Exports](#exports).
|
|
185
|
+
|
|
186
|
+
### The path of one call
|
|
187
|
+
|
|
188
|
+
```text
|
|
189
|
+
params → URL + body → your middleware → fetch → parse → validate → Result
|
|
89
190
|
```
|
|
90
191
|
|
|
91
|
-
|
|
192
|
+
Any step can fail, and the failure lands in `error` instead of being thrown.
|
|
193
|
+
|
|
194
|
+
### Three levels of settings
|
|
92
195
|
|
|
93
|
-
|
|
196
|
+
You can write a setting in three places: on the client, on the endpoint, or on one call. For headers, the most specific one wins. For middleware, every level runs, the client's first.
|
|
94
197
|
|
|
95
|
-
|
|
198
|
+
| Level | Where you write it | `headers` | `middleware` |
|
|
199
|
+
| ----- | ------------------ | --------- | ------------ |
|
|
200
|
+
| Client | `createApi({ headers, middleware })` | Sent with every call | Runs first, around everything else |
|
|
201
|
+
| Endpoint | `defineRequest<T>()({ headers, middleware })` | Replaces the client's value for the same header | Runs second |
|
|
202
|
+
| Call | `api.getUser(params, { headers, middleware })` | Replaces the client's and the endpoint's value for the same header | Runs last, closest to `fetch` |
|
|
203
|
+
|
|
204
|
+
Headers merge by name, so setting one header on a call keeps every other header from the client and the endpoint. A `Content-Type` you set at any level replaces the one liaise picks from the body.
|
|
96
205
|
|
|
97
206
|
```ts
|
|
98
|
-
import {
|
|
207
|
+
import { createApi, defineRequest } from 'liaise'
|
|
99
208
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
209
|
+
type Report = { total: number }
|
|
210
|
+
|
|
211
|
+
const api = createApi({
|
|
212
|
+
baseUrl: 'https://api.example.com',
|
|
213
|
+
headers: { 'x-api-version': '1' }, // every call
|
|
214
|
+
requests: {
|
|
215
|
+
getReport: defineRequest<Report>()({
|
|
216
|
+
method: 'GET',
|
|
217
|
+
path: '/report',
|
|
218
|
+
headers: { 'x-api-version': '2' }, // this endpoint: replaces the client's
|
|
219
|
+
}),
|
|
220
|
+
},
|
|
103
221
|
})
|
|
222
|
+
|
|
223
|
+
await api.getReport() // sends x-api-version: 2
|
|
224
|
+
await api.getReport({}, { headers: { 'x-api-version': '3' } }) // sends x-api-version: 3
|
|
104
225
|
```
|
|
105
226
|
|
|
106
|
-
|
|
227
|
+
## Guide
|
|
107
228
|
|
|
108
|
-
|
|
109
|
-
- `TResponse` -- the shape of the successful response data. This becomes the type of `result.data`.
|
|
229
|
+
Each section starts with the problem it solves, then shows the smallest example, then lists the rules.
|
|
110
230
|
|
|
111
|
-
|
|
231
|
+
### Defining endpoints
|
|
232
|
+
|
|
233
|
+
You describe each endpoint once, with its method, its path, and the types of its params and response. Every call to it is then checked against that description.
|
|
112
234
|
|
|
113
235
|
```ts
|
|
114
|
-
|
|
236
|
+
import { createApi, defineRequest } from 'liaise'
|
|
237
|
+
|
|
238
|
+
type Repo = { id: number; name: string }
|
|
239
|
+
|
|
240
|
+
// The first type is the response. The second is the params the path doesn't name.
|
|
241
|
+
const listRepos = defineRequest<Repo[], { page?: number }>()({
|
|
115
242
|
method: 'GET',
|
|
116
|
-
path: '/
|
|
243
|
+
path: '/orgs/:org/repos',
|
|
117
244
|
})
|
|
118
245
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await api.
|
|
246
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { listRepos } })
|
|
247
|
+
|
|
248
|
+
await api.listRepos({ org: 'acme' }) // GET /orgs/acme/repos
|
|
249
|
+
await api.listRepos({ org: 'acme', page: 2 }) // GET /orgs/acme/repos?page=2
|
|
250
|
+
await api.listRepos({ page: 2 }) // ✗ compile error: org is required
|
|
122
251
|
```
|
|
123
252
|
|
|
124
|
-
|
|
253
|
+
- **The path names the required params.** Each `:name` in the path 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.
|
|
254
|
+
- **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](#sending-data) covers what each one can hold.
|
|
255
|
+
- **`bodyAs` flips that default.** Use it for an API that does it the other way round:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
type Job = { id: string }
|
|
259
|
+
|
|
260
|
+
// A DELETE that takes a JSON body
|
|
261
|
+
const bulkDelete = defineRequest<{ deleted: number }, { ids: string[] }>()({
|
|
262
|
+
method: 'DELETE',
|
|
263
|
+
path: '/items',
|
|
264
|
+
bodyAs: 'body',
|
|
265
|
+
})
|
|
125
266
|
|
|
126
|
-
|
|
267
|
+
// A POST that sends its params in the query string
|
|
268
|
+
const triggerJob = defineRequest<Job, { priority: number }>()({
|
|
269
|
+
method: 'POST',
|
|
270
|
+
path: '/jobs/trigger',
|
|
271
|
+
bodyAs: 'query',
|
|
272
|
+
})
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- **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.
|
|
276
|
+
- 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 instead of fetching `/orgs/undefined/repos`.
|
|
277
|
+
- **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.
|
|
278
|
+
- **`responseType`** says how to read the response body. It defaults to `'json'`. The options are under [Reading responses](#reading-responses).
|
|
279
|
+
- **`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:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
defineRequest<undefined>()({ method: 'POST', path: '/ping', responseType: 'none' }) // ✓
|
|
283
|
+
defineRequest<Repo>()({ method: 'POST', path: '/ping', responseType: 'none' }) // ✗
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- **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](#url-fragments) has the details.
|
|
287
|
+
|
|
288
|
+
**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.
|
|
289
|
+
|
|
290
|
+
#### Without defineRequest
|
|
291
|
+
|
|
292
|
+
`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:
|
|
127
293
|
|
|
128
294
|
```ts
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
295
|
+
import { createApi, Request } from 'liaise'
|
|
296
|
+
|
|
297
|
+
type User = { id: string; name: string }
|
|
298
|
+
|
|
299
|
+
const getUser = new Request<{ userId: string }, User>({ method: 'GET', path: '/users/:id' })
|
|
300
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
|
|
133
301
|
|
|
134
|
-
|
|
135
|
-
|
|
302
|
+
await api.getUser({ userId: '42' })
|
|
303
|
+
// Compiles. The call then returns an error, because the path has no :userId and :id is never filled in.
|
|
136
304
|
```
|
|
137
305
|
|
|
138
|
-
|
|
306
|
+
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`.
|
|
139
307
|
|
|
140
|
-
|
|
308
|
+
### Handling errors
|
|
141
309
|
|
|
142
|
-
|
|
310
|
+
Plain fetch reports failures three different ways. A 500 resolves like a success, being offline throws, and a hung server never answers. In liaise every call returns `{ data, error }`, and `error.kind` names what went wrong.
|
|
143
311
|
|
|
144
312
|
```ts
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
})
|
|
313
|
+
import { createApi, defineRequest } from 'liaise'
|
|
314
|
+
|
|
315
|
+
type User = { id: string; name: string }
|
|
316
|
+
|
|
317
|
+
const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id', timeout: 5000 })
|
|
318
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
|
|
319
|
+
|
|
320
|
+
async function loadUser(id: string) {
|
|
321
|
+
const { data, error } = await api.getUser({ id })
|
|
322
|
+
|
|
323
|
+
if (error) {
|
|
324
|
+
switch (error.kind) {
|
|
325
|
+
case 'http': // the server answered with a non-2xx status
|
|
326
|
+
if (error.status === 401) redirectToLogin()
|
|
327
|
+
else show(`The server said no (${error.status})`)
|
|
328
|
+
break
|
|
329
|
+
case 'network': // no response arrived
|
|
330
|
+
show("You're offline. Try again.")
|
|
331
|
+
break
|
|
332
|
+
case 'timeout': // the 5 second deadline passed
|
|
333
|
+
show('This is taking too long.')
|
|
334
|
+
break
|
|
335
|
+
case 'abort': // you cancelled the call, so there is nothing to show
|
|
336
|
+
break
|
|
337
|
+
case 'parse': // a 2xx body that didn't parse or failed the schema
|
|
338
|
+
case 'middleware': // your own middleware threw
|
|
339
|
+
report(error)
|
|
340
|
+
break
|
|
341
|
+
}
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
show(`Hello, ${data.name}`) // data is a User here
|
|
346
|
+
}
|
|
150
347
|
```
|
|
151
348
|
|
|
152
|
-
|
|
349
|
+
`show`, `redirectToLogin` and `report` stand for your own code.
|
|
350
|
+
|
|
351
|
+
| `kind` | What happened | `status` | What you usually do | Reported to [`onError`](#reporting-errors-with-onerror)? |
|
|
352
|
+
| ------ | ------------- | -------- | ------------------- | ---------------------- |
|
|
353
|
+
| `'http'` | The server answered with a non-2xx status. | The response's status | Handle it by status, or show it | Yes |
|
|
354
|
+
| `'network'` | No response arrived, because you're offline or DNS or CORS failed. Params liaise refuses before sending also land here. | `0` | Show an offline message, or retry | Yes |
|
|
355
|
+
| `'timeout'` | Your [`timeout`](#set-a-deadline-with-timeout) passed. | `0` | Say it's slow | Yes |
|
|
356
|
+
| `'abort'` | The call was cancelled by your signal, or replaced by a newer [`dedupe`](#drop-stale-calls-with-dedupe) call. | `0` | Ignore it | **No** |
|
|
357
|
+
| `'parse'` | A 2xx body didn't parse as its `responseType`, or failed your [schema](#validating-responses). | The response's status | Report it | Yes |
|
|
358
|
+
| `'middleware'` | Your middleware threw. | `0` | Fix your code | Yes |
|
|
153
359
|
|
|
154
|
-
|
|
360
|
+
- **Check `error` first.** After `if (error) return`, `data` has your response type, so you never write `data!`.
|
|
361
|
+
- For an endpoint that sends no body, declare `responseType: 'none'`. Widening the type to `| null` doesn't work, because an empty body is a `'parse'` error. See [Reading responses](#reading-responses).
|
|
362
|
+
- **Branch on `error.kind`.** Four kinds share `status: 0`, and each needs different handling.
|
|
363
|
+
- **A non-2xx response is always `'http'`**, even when its body doesn't parse. liaise checks the status before it reads the body, so a 500 with broken JSON is still a 500, and [`retryMiddleware`](#retry-failed-calls) still retries it.
|
|
364
|
+
- **`response` is for the status and headers.** liaise has already read its body to produce `data` or `error.body`, so `response.json()` throws "Body has already been read". A `Response` you build yourself for `successResult()` in tests keeps its body.
|
|
365
|
+
- Every field of `error` is listed under [`ApiError`](#result-and-apierror).
|
|
155
366
|
|
|
156
|
-
|
|
367
|
+
#### Trying again with retry()
|
|
368
|
+
|
|
369
|
+
Every `Result` carries `retry()`, which runs the same call again. It goes through all your middleware, so an auth header is set again and logging runs again:
|
|
157
370
|
|
|
158
371
|
```ts
|
|
159
|
-
const
|
|
160
|
-
method: 'GET',
|
|
161
|
-
path: '/users/search',
|
|
162
|
-
dedupe: true
|
|
163
|
-
})
|
|
372
|
+
const { error, retry } = await api.getUser({ id: '42' })
|
|
164
373
|
|
|
165
|
-
|
|
166
|
-
await
|
|
167
|
-
|
|
374
|
+
if (error?.status === 401) {
|
|
375
|
+
await refreshToken()
|
|
376
|
+
const second = await retry() // a fresh call through every middleware
|
|
377
|
+
if (!second.error) show(second.data.name)
|
|
378
|
+
}
|
|
168
379
|
```
|
|
169
380
|
|
|
170
|
-
|
|
381
|
+
`refreshToken` stands for your own token refresh.
|
|
382
|
+
|
|
383
|
+
#### Reporting errors with onError
|
|
171
384
|
|
|
172
|
-
|
|
385
|
+
`onError` on `createApi` is one place to send every error to your tracker.
|
|
173
386
|
|
|
174
387
|
```ts
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
388
|
+
const api = createApi({
|
|
389
|
+
baseUrl: 'https://api.example.com',
|
|
390
|
+
requests: { getUser },
|
|
391
|
+
onError: (error) => logToTracker(error),
|
|
179
392
|
})
|
|
180
|
-
|
|
181
|
-
// Both calls join the same network request
|
|
182
|
-
await Promise.all([
|
|
183
|
-
api.getProduct({ id: '42' }),
|
|
184
|
-
api.getProduct({ id: '42' })
|
|
185
|
-
])
|
|
186
393
|
```
|
|
187
394
|
|
|
188
|
-
|
|
395
|
+
`logToTracker` stands for your error tracker, such as Sentry.
|
|
189
396
|
|
|
190
|
-
|
|
397
|
+
- It runs once per call, after all your middleware has finished. A call that a retry middleware rescues from a 500 never reaches it.
|
|
398
|
+
- It isn't called for `'abort'`, because a cancellation isn't a failure. A `'timeout'` is reported, because it's a deadline you missed.
|
|
399
|
+
- It only watches. The caller gets the same `Result` either way.
|
|
400
|
+
|
|
401
|
+
### Sending data
|
|
402
|
+
|
|
403
|
+
You pass one params object, and liaise puts each field where it belongs: in the path, the query string or the body. You never build a URL by hand.
|
|
191
404
|
|
|
192
405
|
```ts
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
path: '/items',
|
|
197
|
-
bodyAs: 'body'
|
|
198
|
-
})
|
|
406
|
+
import { createApi, defineRequest } from 'liaise'
|
|
407
|
+
|
|
408
|
+
type Item = { id: string; name: string }
|
|
199
409
|
|
|
200
|
-
|
|
201
|
-
|
|
410
|
+
const listItems = defineRequest<Item[], { page: number; tags: string[] }>()({
|
|
411
|
+
method: 'GET',
|
|
412
|
+
path: '/orgs/:org/items',
|
|
413
|
+
})
|
|
414
|
+
const createItem = defineRequest<Item, { name: string }>()({
|
|
202
415
|
method: 'POST',
|
|
203
|
-
path: '/
|
|
204
|
-
bodyAs: 'query'
|
|
416
|
+
path: '/orgs/:org/items',
|
|
205
417
|
})
|
|
418
|
+
|
|
419
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { listItems, createItem } })
|
|
420
|
+
|
|
421
|
+
await api.listItems({ org: 'acme', page: 2, tags: ['a', 'b'] })
|
|
422
|
+
// GET /orgs/acme/items?page=2&tags=a&tags=b
|
|
423
|
+
|
|
424
|
+
await api.createItem({ org: 'acme', name: 'Lamp' })
|
|
425
|
+
// POST /orgs/acme/items, with the JSON body {"name":"Lamp"}
|
|
206
426
|
```
|
|
207
427
|
|
|
208
|
-
|
|
428
|
+
Which params go in the query string and which in the body is set per endpoint, under [Defining endpoints](#defining-endpoints).
|
|
209
429
|
|
|
210
|
-
|
|
211
|
-
and nothing checks the two against each other:
|
|
430
|
+
#### Query strings
|
|
212
431
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
432
|
+
| Params | Query string |
|
|
433
|
+
| ------ | ------------ |
|
|
434
|
+
| `{ page: 1, limit: 20 }` | `?page=1&limit=20` |
|
|
435
|
+
| `{ tags: ['a', 'b'] }` | `?tags=a&tags=b` |
|
|
436
|
+
| `{ filter: null }` | _(left out)_ |
|
|
437
|
+
| `{ filter: undefined }` | _(left out)_ |
|
|
438
|
+
| `{ meta: { nested: true } }` | Refused |
|
|
439
|
+
| `{ since: new Date() }` | Refused |
|
|
216
440
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
441
|
+
- **Arrays** become repeated keys (`tags=a&tags=b`), the format most server frameworks read.
|
|
442
|
+
- **`null` and `undefined`** are left out.
|
|
443
|
+
- **A nested object is refused.** There is no standard way to put one in a query string (brackets, dots and JSON are all in use), so liaise doesn't guess. Flatten it first.
|
|
444
|
+
- **A `Date` is refused too.** APIs expect ISO 8601 or epoch milliseconds, so convert it yourself with `date.toISOString()` or `date.getTime()`.
|
|
445
|
+
- A refused param sends nothing. You get an error `Result` with `kind: 'network'` and a `TypeError` in `error.body` that names the param.
|
|
221
446
|
|
|
222
|
-
|
|
447
|
+
#### Request bodies
|
|
223
448
|
|
|
224
|
-
|
|
225
|
-
import { defineRequest } from 'liaise'
|
|
449
|
+
liaise serializes the body from what you pass, and sets `Content-Type` for you unless you set one yourself.
|
|
226
450
|
|
|
227
|
-
|
|
451
|
+
| You pass | Body sent | Content-Type |
|
|
452
|
+
| -------- | --------- | ------------ |
|
|
453
|
+
| `null`/`undefined` | `null` | _(none)_ |
|
|
454
|
+
| `string` | as-is | `text/plain` |
|
|
455
|
+
| `FormData` | as-is | _(the browser sets the multipart boundary)_ |
|
|
456
|
+
| `URLSearchParams` | as-is | `application/x-www-form-urlencoded` |
|
|
457
|
+
| `Blob` | as-is | `application/octet-stream` |
|
|
458
|
+
| `ArrayBuffer` | as-is | `application/octet-stream` |
|
|
459
|
+
| Typed array, `DataView`, `Buffer` | as-is (sent as binary) | `application/octet-stream` |
|
|
460
|
+
| `ReadableStream` | as-is (a streaming upload; `duplex: 'half'` is set for you) | `application/octet-stream` |
|
|
461
|
+
| Plain object | `JSON.stringify()` | `application/json` |
|
|
228
462
|
|
|
229
|
-
|
|
230
|
-
api.getUser({ id: 42 }) // ✓ — numbers are encoded
|
|
231
|
-
api.getUser({ userId: '42' }) // ✗ Object literal may only specify known properties
|
|
232
|
-
```
|
|
463
|
+
A `ReadableStream` body can be sent only once. A retry, from middleware or from `result.retry()`, returns an error telling you to read the stream into a `Blob` or `ArrayBuffer` first ([details](#stream-bodies)).
|
|
233
464
|
|
|
234
|
-
|
|
235
|
-
argument:
|
|
465
|
+
#### What params can be
|
|
236
466
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
467
|
+
| You pass | What happens |
|
|
468
|
+
| -------- | ------------ |
|
|
469
|
+
| Plain object | Split into path params, query string and body |
|
|
470
|
+
| `Map` with string keys | Same as the object it spells |
|
|
471
|
+
| Class instance with fields | Same as a plain object (split by those fields even if the class also defines `toJSON()`; `toJSON()` is used only when there are no own fields) |
|
|
472
|
+
| Class instance with only `toJSON()` | Sent as its JSON (body only; refused on a request whose params go in the query string) |
|
|
473
|
+
| Typed array, `DataView`, `Buffer`, `ReadableStream` | Sent as the body, as in the table above (refused on a request whose params go in the query string) |
|
|
474
|
+
| `Set`, a bare `Date`, a `Map` with non-string keys, a class with no fields | Refused: an error `Result` (`kind: 'network'`) naming the type. Nothing is sent. |
|
|
242
475
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
```
|
|
476
|
+
A `Map`, `Set` or class with private state nested inside a JSON body is sent as `{}`, because that is what `JSON.stringify` does. Convert it first.
|
|
477
|
+
|
|
478
|
+
Headers, including your own `Content-Type`, follow the [three levels of settings](#three-levels-of-settings).
|
|
247
479
|
|
|
248
|
-
|
|
249
|
-
|
|
480
|
+
### Reading responses
|
|
481
|
+
|
|
482
|
+
A response can be JSON, text or a file. You say which with `responseType`, and liaise reads the body for you.
|
|
250
483
|
|
|
251
484
|
```ts
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
485
|
+
import { createApi, defineRequest } from 'liaise'
|
|
486
|
+
|
|
487
|
+
type User = { id: string; name: string }
|
|
488
|
+
|
|
489
|
+
// JSON is the default.
|
|
490
|
+
const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
|
|
491
|
+
|
|
492
|
+
// A file comes back as a Blob.
|
|
493
|
+
const downloadFile = defineRequest<Blob>()({ method: 'GET', path: '/files/:id', responseType: 'blob' })
|
|
494
|
+
|
|
495
|
+
// A 204 No Content has no body, so data is undefined.
|
|
496
|
+
const deleteUser = defineRequest<undefined>()({
|
|
497
|
+
method: 'DELETE',
|
|
498
|
+
path: '/users/:id',
|
|
499
|
+
responseType: 'none',
|
|
500
|
+
})
|
|
255
501
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
502
|
+
const api = createApi({
|
|
503
|
+
baseUrl: 'https://api.example.com',
|
|
504
|
+
requests: { getUser, downloadFile, deleteUser },
|
|
505
|
+
})
|
|
506
|
+
```
|
|
261
507
|
|
|
262
|
-
|
|
263
|
-
|
|
508
|
+
| `responseType` | How the body is read | `data` |
|
|
509
|
+
| -------------- | -------------------- | ------ |
|
|
510
|
+
| `'json'` (default) | `response.text()`, then `JSON.parse()` | the parsed value |
|
|
511
|
+
| `'text'` | `response.text()` | `string` |
|
|
512
|
+
| `'blob'` | `response.blob()` | `Blob` |
|
|
513
|
+
| `'arrayBuffer'` | `response.arrayBuffer()` | `ArrayBuffer` |
|
|
514
|
+
| `'formData'` | `response.formData()` | `FormData` |
|
|
515
|
+
| `'none'` | not read (the stream is cancelled) | `undefined` |
|
|
516
|
+
|
|
517
|
+
- **An empty body under `'json'` is a `'parse'` error.** You declared JSON and the server sent none, so no value could honestly match your type. The error has the response's own status (a 204 reports 204), the `response`, and `''` in `error.body`.
|
|
518
|
+
- **A literal `null` body is not empty.** It is valid JSON, so the call succeeds with `data: null`.
|
|
519
|
+
- **`'none'` is for an endpoint that sends no body on success**, such as a `DELETE` that answers 204, or 200 with an empty body. liaise reads nothing, `data` is `undefined`, and a body the server sends anyway is discarded. Its stream is cancelled, which frees the connection.
|
|
520
|
+
- **Declare `'none'` with the response type `undefined`.** `defineRequest` enforces this ([Defining endpoints](#defining-endpoints)). [Without defineRequest](#without-definerequest) it is only a convention, and `data` is `undefined` at runtime whatever type you wrote.
|
|
521
|
+
- **A non-2xx body is still read into `error.body`**, because an error body usually explains what went wrong. Under `'none'` it is read as JSON, and `error.body` is `null` when it isn't JSON:
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
const { error } = await api.deleteUser({ id: '42' })
|
|
525
|
+
if (error) {
|
|
526
|
+
// A 409 { "error": "already deleted" } lands in error.body,
|
|
527
|
+
// even though deleteUser declares responseType: 'none'.
|
|
528
|
+
console.error(error.status, error.body)
|
|
529
|
+
}
|
|
530
|
+
```
|
|
264
531
|
|
|
265
|
-
###
|
|
532
|
+
### Validating responses
|
|
266
533
|
|
|
267
|
-
|
|
268
|
-
ArkType — and the response is checked before you see it. liaise takes no
|
|
269
|
-
dependency on one; Standard Schema is an interface, not a package.
|
|
534
|
+
TypeScript trusts the type you write, and nothing checks it at runtime. When the backend changes a field, the page crashes three components later. Give the endpoint a schema, and the response is checked before you see it.
|
|
270
535
|
|
|
271
536
|
```ts
|
|
537
|
+
import { createApi, defineRequest } from 'liaise'
|
|
272
538
|
import { z } from 'zod'
|
|
273
539
|
|
|
274
540
|
const getUser = defineRequest()({
|
|
@@ -277,452 +543,401 @@ const getUser = defineRequest()({
|
|
|
277
543
|
schema: z.object({ id: z.string(), name: z.string() }),
|
|
278
544
|
})
|
|
279
545
|
|
|
546
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
|
|
547
|
+
|
|
280
548
|
const { data, error } = await api.getUser({ id: '42' })
|
|
281
549
|
// ^? { id: string; name: string } | null
|
|
282
550
|
```
|
|
283
551
|
|
|
284
|
-
|
|
285
|
-
and no second place for it to drift out of date.
|
|
286
|
-
|
|
287
|
-
**`data` is the schema's output.** A schema that transforms changes what you
|
|
288
|
-
receive:
|
|
552
|
+
Valibot and ArkType work the same way:
|
|
289
553
|
|
|
290
554
|
```ts
|
|
291
|
-
|
|
555
|
+
import * as v from 'valibot'
|
|
556
|
+
import { type } from 'arktype'
|
|
557
|
+
|
|
558
|
+
const withValibot = defineRequest()({
|
|
292
559
|
method: 'GET',
|
|
293
560
|
path: '/users/:id',
|
|
294
|
-
schema:
|
|
295
|
-
id: z.string(),
|
|
296
|
-
createdAt: z.coerce.date(), // the wire sends a string
|
|
297
|
-
role: z.string().default('user'), // absent on the wire
|
|
298
|
-
}),
|
|
561
|
+
schema: v.object({ id: v.string(), name: v.string() }),
|
|
299
562
|
})
|
|
300
563
|
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
564
|
+
const withArkType = defineRequest()({
|
|
565
|
+
method: 'GET',
|
|
566
|
+
path: '/users/:id',
|
|
567
|
+
schema: type({ id: 'string', name: 'string' }),
|
|
568
|
+
})
|
|
304
569
|
```
|
|
305
570
|
|
|
306
|
-
|
|
307
|
-
|
|
571
|
+
- **Any [Standard Schema](https://standardschema.dev) validator works.** liaise doesn't depend on any of them. Standard Schema is only an interface, so you bring the validator you already use.
|
|
572
|
+
- **The schema supplies the response type.** You write no type argument, so there's no second type to keep in sync.
|
|
573
|
+
- **`data` is the schema's output.** A schema that transforms changes what you receive, so `data` can differ from the raw response:
|
|
308
574
|
|
|
309
|
-
|
|
575
|
+
```ts
|
|
576
|
+
const getUser = defineRequest()({
|
|
577
|
+
method: 'GET',
|
|
578
|
+
path: '/users/:id',
|
|
579
|
+
schema: z.object({
|
|
580
|
+
id: z.string(),
|
|
581
|
+
createdAt: z.coerce.date(), // the wire sends a string
|
|
582
|
+
role: z.string().default('user'), // absent on the wire
|
|
583
|
+
}),
|
|
584
|
+
})
|
|
310
585
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
error.status // the response's own status — the server was fine
|
|
316
|
-
}
|
|
317
|
-
```
|
|
586
|
+
const { data } = await api.getUser({ id: '42' })
|
|
587
|
+
data.createdAt // a real Date
|
|
588
|
+
data.role // 'user' when the server left it out
|
|
589
|
+
```
|
|
318
590
|
|
|
319
|
-
|
|
320
|
-
different shape, so it is left alone.
|
|
591
|
+
- **A response the schema refuses is a `'parse'` error.** Nothing is thrown. `error.body` holds the validator's issues, and `error.status` is the response's own status, since the server answered fine:
|
|
321
592
|
|
|
322
|
-
|
|
593
|
+
```ts
|
|
594
|
+
const { error } = await api.getUser({ id: '42' })
|
|
595
|
+
if (error?.kind === 'parse') {
|
|
596
|
+
console.error(error.body) // the validator's issues
|
|
597
|
+
}
|
|
598
|
+
```
|
|
323
599
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
schema: UserSchema,
|
|
328
|
-
})
|
|
329
|
-
```
|
|
600
|
+
- A validator that throws is a `'parse'` error too, with the thrown value in `error.body`.
|
|
601
|
+
- **Only a 2xx body is validated.** A non-2xx body is diagnostic and often a different shape, so it is left alone.
|
|
602
|
+
- **The GraphQL `Operation` ([GraphQL](#graphql)) takes `schema` too**, and validates the response's `data`. There the response type stays explicit, because only `defineRequest` infers it:
|
|
330
603
|
|
|
331
|
-
|
|
604
|
+
```ts
|
|
605
|
+
import { Operation, gql } from 'liaise'
|
|
332
606
|
|
|
333
|
-
|
|
607
|
+
const UserSchema = z.object({ id: z.string(), name: z.string() })
|
|
334
608
|
|
|
335
|
-
|
|
609
|
+
const me = new Operation<Record<string, never>, z.infer<typeof UserSchema>>({
|
|
610
|
+
operation: gql`query { me { id name } }`,
|
|
611
|
+
schema: UserSchema,
|
|
612
|
+
})
|
|
613
|
+
```
|
|
336
614
|
|
|
337
|
-
|
|
338
|
-
import { paginate } from 'liaise'
|
|
615
|
+
### Cancelling, deadlines and stale requests
|
|
339
616
|
|
|
340
|
-
|
|
341
|
-
next: (p, prev) => p.data.cursor ? { ...prev, cursor: p.data.cursor } : undefined,
|
|
342
|
-
})) {
|
|
343
|
-
if (page.error) break
|
|
344
|
-
render(page.data.items)
|
|
345
|
-
}
|
|
346
|
-
```
|
|
617
|
+
Sometimes you no longer need a call, because the user left the page or typed a newer search. Sometimes a call takes too long. You can end a call with a signal, a deadline, or `dedupe`, and each one ends it with an error you can tell apart.
|
|
347
618
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
the common case is a spread, and the same shape covers every scheme:
|
|
619
|
+
#### Cancel with a signal
|
|
620
|
+
|
|
621
|
+
Pass an `AbortSignal` in the call options:
|
|
352
622
|
|
|
353
623
|
```ts
|
|
354
|
-
|
|
355
|
-
next: (p, prev) => p.data.items.length === prev.limit
|
|
356
|
-
? { ...prev, offset: prev.offset + prev.limit }
|
|
357
|
-
: undefined
|
|
358
|
-
|
|
359
|
-
// page number, driven by a Link header
|
|
360
|
-
next: (p, prev) => p.response.headers.get('link')?.includes('rel="next"')
|
|
361
|
-
? { ...prev, page: prev.page + 1 }
|
|
362
|
-
: undefined
|
|
363
|
-
```
|
|
624
|
+
import { createApi, defineRequest } from 'liaise'
|
|
364
625
|
|
|
365
|
-
|
|
626
|
+
type User = { id: string; name: string }
|
|
627
|
+
|
|
628
|
+
const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
|
|
629
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
|
|
366
630
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
failed rather than a loop that quietly stopped.
|
|
631
|
+
const controller = new AbortController()
|
|
632
|
+
const pending = api.getUser({ id: '42' }, { signal: controller.signal })
|
|
370
633
|
|
|
371
|
-
|
|
372
|
-
the library will not invent a number, because a silent truncation at an
|
|
373
|
-
arbitrary limit looks exactly like reaching the last page.
|
|
634
|
+
controller.abort()
|
|
374
635
|
|
|
375
|
-
|
|
376
|
-
|
|
636
|
+
const { error } = await pending
|
|
637
|
+
// error.kind === 'abort', error.status === 0, error.body is a DOMException named 'AbortError'
|
|
377
638
|
```
|
|
378
639
|
|
|
379
|
-
|
|
380
|
-
|
|
640
|
+
A cancellation you asked for isn't reported to [`onError`](#reporting-errors-with-onerror). How liaise tells your cancellation apart from other failures is under [Abort classification](#abort-classification).
|
|
641
|
+
|
|
642
|
+
#### Set a deadline with timeout
|
|
381
643
|
|
|
382
|
-
`
|
|
383
|
-
holds the array, which is the convention-guessing `next` exists to avoid.
|
|
644
|
+
`timeout` is in milliseconds, and you set it on the endpoint or on one call:
|
|
384
645
|
|
|
385
|
-
|
|
646
|
+
```ts
|
|
647
|
+
import { createApi, defineRequest } from 'liaise'
|
|
648
|
+
import { retryMiddleware } from 'liaise/middleware'
|
|
386
649
|
|
|
387
|
-
|
|
650
|
+
type Report = { total: number }
|
|
388
651
|
|
|
389
|
-
|
|
390
|
-
are merged ahead of the call's:
|
|
652
|
+
const getReport = defineRequest<Report>()({ method: 'GET', path: '/report', timeout: 3000 })
|
|
391
653
|
|
|
392
|
-
```ts
|
|
393
654
|
const api = createApi({
|
|
394
|
-
baseUrl: 'https://api.example.com
|
|
395
|
-
requests: {
|
|
655
|
+
baseUrl: 'https://api.example.com',
|
|
656
|
+
requests: { getReport },
|
|
657
|
+
middleware: [retryMiddleware(3)],
|
|
396
658
|
})
|
|
397
659
|
|
|
398
|
-
await api.
|
|
399
|
-
//
|
|
660
|
+
const { error } = await api.getReport()
|
|
661
|
+
// If no answer arrives within 3 seconds, retries included: error.kind === 'timeout', error.status === 0
|
|
400
662
|
```
|
|
401
663
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
param needs to vary per call, set it from middleware rather than the `baseUrl`.
|
|
664
|
+
- **`timeout` is one deadline for the whole call.** It covers every middleware, every retry and every wait between retries. `timeout: 3000` with three retries still answers within three seconds.
|
|
665
|
+
- **A call's `timeout` replaces the endpoint's.** `timeout: 0` on a call turns the endpoint's deadline off. Zero, a negative number or no `timeout` at all means no deadline, which is the default.
|
|
666
|
+
- **`result.retry()` starts a fresh deadline.** The retried call isn't charged for time the first one used.
|
|
667
|
+
- If you want a separate limit for each attempt instead, see [Give each attempt its own timeout](#give-each-attempt-its-own-timeout).
|
|
668
|
+
- Under [`share`](#sharing-identical-requests), the endpoint's `timeout` belongs to the one shared request, and a caller can't extend it.
|
|
408
669
|
|
|
409
|
-
|
|
410
|
-
a `path` or `baseUrl` cannot do what it appears to — and before 4.2.1 it
|
|
411
|
-
silently discarded the query string. It is now an error naming the offending
|
|
412
|
-
value, rather than being stripped, so the dead code does not stay in your
|
|
413
|
-
template.
|
|
670
|
+
#### Drop stale calls with dedupe
|
|
414
671
|
|
|
415
|
-
|
|
416
|
-
is also a compile error, so the endpoint is rejected where it is declared rather
|
|
417
|
-
than on every call:
|
|
672
|
+
`dedupe: true` makes each new call to an endpoint cancel the one still running. Use it for search-as-you-type and fast-changing filters, where only the latest answer matters:
|
|
418
673
|
|
|
419
674
|
```ts
|
|
420
|
-
|
|
421
|
-
// ^ Property '__fragmentInPath' is missing:
|
|
422
|
-
// a URL fragment is never sent to the server
|
|
423
|
-
```
|
|
675
|
+
type User = { id: string; name: string }
|
|
424
676
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
677
|
+
const searchUsers = defineRequest<User[], { q: string }>()({
|
|
678
|
+
method: 'GET',
|
|
679
|
+
path: '/users/search',
|
|
680
|
+
dedupe: true,
|
|
681
|
+
})
|
|
429
682
|
|
|
430
|
-
|
|
431
|
-
escaped to `%23` and sent as ordinary data:
|
|
683
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { searchUsers } })
|
|
432
684
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
685
|
+
// Typing fast: each call cancels the one before it.
|
|
686
|
+
api.searchUsers({ q: 'h' }) // ends with kind 'abort'
|
|
687
|
+
api.searchUsers({ q: 'he' }) // ends with kind 'abort'
|
|
688
|
+
api.searchUsers({ q: 'hel' }) // this one completes
|
|
436
689
|
```
|
|
437
690
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
| ------------------------------ | --------------------------- |
|
|
443
|
-
| `{ page: 1, limit: 20 }` | `?page=1&limit=20` |
|
|
444
|
-
| `{ tags: ['a', 'b'] }` | `?tags=a&tags=b` |
|
|
445
|
-
| `{ filter: null }` | _(omitted)_ |
|
|
446
|
-
| `{ filter: undefined }` | _(omitted)_ |
|
|
447
|
-
| `{ meta: { nested: true } }` | **TypeError** (see below) |
|
|
448
|
-
| `{ since: new Date() }` | **TypeError**: convert it first (`toISOString()` or `getTime()`) |
|
|
691
|
+
- **It works per endpoint.** A call to one endpoint never cancels a call to another.
|
|
692
|
+
- **A replaced call ends with `kind: 'abort'`**, so your code can ignore it.
|
|
693
|
+
- **It works together with your own signal and a `timeout`.** Whichever fires first ends the call.
|
|
694
|
+
- It can't be combined with [`share`](#sharing-identical-requests), which does the opposite.
|
|
449
695
|
|
|
450
|
-
|
|
696
|
+
All three still end the call when a middleware is stuck on work of its own that ignores the signal, such as a token refresh that never settles. [Timeout backstop](#timeout-backstop) explains how.
|
|
451
697
|
|
|
452
|
-
|
|
698
|
+
### Sharing identical requests
|
|
453
699
|
|
|
454
|
-
|
|
700
|
+
Several parts of an app often ask for the same thing at the same moment. Five components load the current user, or five calls get a 401 and each one refreshes the token. `share: true` sends one request and gives every caller its answer.
|
|
455
701
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
### Result
|
|
702
|
+
```ts
|
|
703
|
+
import { createApi, defineRequest } from 'liaise'
|
|
459
704
|
|
|
460
|
-
|
|
705
|
+
type Product = { id: string; name: string }
|
|
461
706
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
retry: () => Promise<Result<TResponse>>
|
|
468
|
-
}
|
|
707
|
+
const getProduct = defineRequest<Product>()({
|
|
708
|
+
method: 'GET',
|
|
709
|
+
path: '/products/:id',
|
|
710
|
+
share: true,
|
|
711
|
+
})
|
|
469
712
|
|
|
470
|
-
|
|
471
|
-
data: null
|
|
472
|
-
error: ApiError // structured error, see below
|
|
473
|
-
response: Response | null // present for HTTP/parse failures, null for network/abort/timeout
|
|
474
|
-
retry: () => Promise<Result<TResponse>>
|
|
475
|
-
}
|
|
713
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getProduct } })
|
|
476
714
|
|
|
477
|
-
|
|
715
|
+
// One network request. Both callers get the same response.
|
|
716
|
+
const [a, b] = await Promise.all([
|
|
717
|
+
api.getProduct({ id: '42' }),
|
|
718
|
+
api.getProduct({ id: '42' }),
|
|
719
|
+
])
|
|
478
720
|
```
|
|
479
721
|
|
|
480
|
-
|
|
481
|
-
|
|
722
|
+
- **`share` joins the call already running. [`dedupe`](#drop-stale-calls-with-dedupe) cancels it.** Setting both on one endpoint throws when you create the client, so you find the mistake straight away.
|
|
723
|
+
- **Identical means the same endpoint and the same params, compared by content.** Key order doesn't matter, and two `Date`s with the same time match. Different params or a different endpoint never share. [Share key and refcount](#share-key-and-refcount) has the full rules.
|
|
724
|
+
- **A per-call `headers` or `middleware` turns sharing off for that call.** Either one can change what is requested, so that call gets a request of its own.
|
|
725
|
+
- **Params that can't be compared safely, such as a `Blob` or a circular structure, turn sharing off** ([full list](#share-key-and-refcount)). That call gets its own request.
|
|
726
|
+
- **A `Date`, `Map`, `Set`, typed array or string param shares normally.**
|
|
727
|
+
- **A per-call `signal` or `timeout` only lets that caller leave.** The caller that gives up gets `kind: 'abort'` or `'timeout'`, reported to [`onError`](#reporting-errors-with-onerror) as an unshared call would be. The request keeps running for the others, and is cancelled once every caller has given up.
|
|
728
|
+
- **The endpoint's own `timeout` bounds the shared request for everyone.** It counts from when the request started. A caller that joins late can't extend it, and `timeout: 0` on one call can't turn it off.
|
|
482
729
|
|
|
483
730
|
```ts
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
if (error) {
|
|
487
|
-
switch (error.kind) {
|
|
488
|
-
case 'network':
|
|
489
|
-
// fetch itself failed -- user is probably offline
|
|
490
|
-
break
|
|
491
|
-
case 'timeout':
|
|
492
|
-
// the whole-operation deadline fired; report it
|
|
493
|
-
reportTimeout(error)
|
|
494
|
-
break
|
|
495
|
-
case 'abort':
|
|
496
|
-
// this call was cancelled (dedupe supersede, or your own signal) -- usually ignore it
|
|
497
|
-
break
|
|
498
|
-
case 'parse':
|
|
499
|
-
// a 2xx response arrived but its body didn't parse as `responseType`
|
|
500
|
-
console.error('unparseable response', error.status, error.body)
|
|
501
|
-
break
|
|
502
|
-
case 'middleware':
|
|
503
|
-
// a middleware threw -- a bug in your own pipeline, not a transient failure
|
|
504
|
-
console.error('middleware threw', error.body)
|
|
505
|
-
break
|
|
506
|
-
case 'http':
|
|
507
|
-
if (error.status === 401) redirectToLogin()
|
|
508
|
-
else console.error(error.status, error.body)
|
|
509
|
-
break
|
|
510
|
-
}
|
|
511
|
-
return
|
|
512
|
-
}
|
|
731
|
+
const impatient = api.getProduct({ id: '42' }, { timeout: 20 }) // gives up quickly
|
|
732
|
+
const patient = api.getProduct({ id: '42' }) // keeps waiting
|
|
513
733
|
|
|
514
|
-
//
|
|
515
|
-
// (this assumes getUser always answers with a body; an endpoint that
|
|
516
|
-
// doesn't -- a DELETE returning 204, most commonly -- should use
|
|
517
|
-
// responseType: 'none' instead, see the empty-body note above)
|
|
518
|
-
console.log(data.name)
|
|
734
|
+
// impatient's timeout doesn't cancel the shared request, so patient still gets the response.
|
|
519
735
|
```
|
|
520
736
|
|
|
521
|
-
|
|
737
|
+
Two rarer cases have their own notes: [`retry()` on a shared result](#retry-on-a-shared-result) and [middleware that replaces the signal](#signal-replacing-middleware).
|
|
522
738
|
|
|
523
|
-
####
|
|
739
|
+
#### On a server
|
|
524
740
|
|
|
525
|
-
|
|
741
|
+
Sharing is decided before middleware runs. A header that a middleware adds, such as the current user's token, is not part of the match. With one client serving every user, one user's call can join another user's call and receive that user's response.
|
|
526
742
|
|
|
527
|
-
|
|
528
|
-
const { data, error, retry } = await api.getUser({ id: '42' })
|
|
743
|
+
Create one client per incoming request, as in [One /me per page view on the server](#one-me-per-page-view-on-the-server). Otherwise, don't set `share` on an endpoint whose answer depends on who is asking.
|
|
529
744
|
|
|
530
|
-
|
|
531
|
-
await refreshToken()
|
|
532
|
-
const retried = await retry()
|
|
533
|
-
// retried goes through the full middleware chain again
|
|
534
|
-
}
|
|
535
|
-
```
|
|
745
|
+
### Retries, caching and logging
|
|
536
746
|
|
|
537
|
-
|
|
747
|
+
Some failures go away when you try again. Some reads repeat often enough to keep. And while you build, you want to see every call. liaise ships a middleware for each, in a separate entry point:
|
|
538
748
|
|
|
539
|
-
|
|
749
|
+
```ts
|
|
750
|
+
import { retryMiddleware, cacheMiddleware, logMiddleware } from 'liaise/middleware'
|
|
751
|
+
```
|
|
540
752
|
|
|
541
|
-
|
|
542
|
-
| ------------ | --------- | ----------------------------------------------------------------- |
|
|
543
|
-
| `status` | `number` | HTTP status code (e.g., 404, 500). `0` for network errors, aborts, and timeouts. |
|
|
544
|
-
| `kind` | `'http' \| 'network' \| 'abort' \| 'timeout' \| 'parse' \| 'middleware'` | What category of failure this is. See below. Required -- constructing an `ApiError` yourself (e.g. in custom middleware) must supply it. |
|
|
545
|
-
| `statusText` | `string` | HTTP status text (e.g., 'Not Found'). `''` for network errors. |
|
|
546
|
-
| `body` | `unknown` | Parsed response body -- but for `'parse'`, one of: the thrown exception (a malformed body), the raw response text (an empty body, or a GraphQL response carrying no data), a schema's issues array (the response failed validation), or a value a schema threw. The native Error for network failures. |
|
|
547
|
-
| `headers` | `Headers` | Response headers. Empty `Headers` for network errors. |
|
|
548
|
-
| `request` | `object` | `{ method, url, params }` -- metadata about the failed request; `url` is the resolved, path-substituted address, falling back to the route template only when it could not be built. |
|
|
549
|
-
| `partialData` | `unknown` (optional) | GraphQL data returned alongside `{ errors }` (partial success). Lives here, not on `Result.data`, so the `Result` stays a clean union: `data` is non-null iff `error` is null. `undefined` for every REST error and for GraphQL responses carrying no data. |
|
|
753
|
+
#### Retry failed calls
|
|
550
754
|
|
|
551
|
-
`
|
|
755
|
+
`retryMiddleware(2)` retries a failed call up to two more times, three attempts in all. By default it retries only 5xx responses. Any 4xx, including a 429, comes back as it is, and so does a network error. To retry 429s and network errors too, pass `retryOn`:
|
|
552
756
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
757
|
+
```ts
|
|
758
|
+
import { createApi, defineRequest } from 'liaise'
|
|
759
|
+
import { retryMiddleware } from 'liaise/middleware'
|
|
556
760
|
|
|
557
|
-
|
|
761
|
+
type Item = { id: string }
|
|
558
762
|
|
|
559
|
-
|
|
763
|
+
const getItems = defineRequest<Item[]>()({ method: 'GET', path: '/items' })
|
|
560
764
|
|
|
561
|
-
|
|
562
|
-
|
|
765
|
+
const api = createApi({
|
|
766
|
+
baseUrl: 'https://api.example.com',
|
|
767
|
+
requests: { getItems },
|
|
768
|
+
middleware: [retryMiddleware(2)], // 5xx only
|
|
769
|
+
})
|
|
563
770
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
771
|
+
// Also retry 429 and network errors. An abort has status 0 too, so check kind.
|
|
772
|
+
const retryMore = retryMiddleware({
|
|
773
|
+
retryOn: (r) =>
|
|
774
|
+
(r.error?.status ?? 0) >= 500 || r.error?.status === 429 || r.error?.kind === 'network',
|
|
775
|
+
})
|
|
567
776
|
```
|
|
568
777
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
The `onError` callback in `createApi` fires after the full middleware chain completes whenever the final result has an error. If a retry middleware recovers a 5xx to a 200, `onError` does not fire.
|
|
778
|
+
Pass an object to tune the waits between attempts:
|
|
572
779
|
|
|
573
780
|
```ts
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (error.status === 401) redirectToLogin()
|
|
579
|
-
Sentry.captureException(error)
|
|
580
|
-
}
|
|
781
|
+
const retry = retryMiddleware({
|
|
782
|
+
max: 5, // up to 5 retries after the first attempt
|
|
783
|
+
delay: 'exponential', // 250 ms, 500 ms, 1 s, ... before jitter
|
|
784
|
+
onRetry: ({ attempt, max, delay }) => console.log(`retry ${attempt}/${max} in ${delay}ms`),
|
|
581
785
|
})
|
|
582
786
|
```
|
|
583
787
|
|
|
584
|
-
|
|
788
|
+
- By default the waits grow exponentially with random jitter, and a `Retry-After` header from the server is honoured. Every option is in [RetryOptions](#retryoptions).
|
|
789
|
+
- **A cancel or a deadline during a wait ends the call with that error.** If your `timeout`, your signal or a newer `dedupe` call fires between attempts, you get `kind: 'timeout'` or `'abort'`. The 503 that caused the retry is dropped.
|
|
585
790
|
|
|
586
|
-
|
|
791
|
+
#### Cache repeated reads
|
|
587
792
|
|
|
588
|
-
|
|
793
|
+
`cacheMiddleware()` keeps successful responses in memory, so a repeated read within the time-to-live skips the network. Each `cacheMiddleware()` call makes its own store. Give it to the endpoints you want cached:
|
|
589
794
|
|
|
590
|
-
```
|
|
591
|
-
|
|
592
|
-
|
|
795
|
+
```ts
|
|
796
|
+
import { createApi, defineRequest } from 'liaise'
|
|
797
|
+
import { cacheMiddleware } from 'liaise/middleware'
|
|
593
798
|
|
|
594
|
-
|
|
799
|
+
type User = { id: string; name: string }
|
|
595
800
|
|
|
596
|
-
|
|
597
|
-
import type { Middleware } from 'liaise'
|
|
801
|
+
const getUserCache = cacheMiddleware({ ttl: 5 * 60_000, maxSize: 100 })
|
|
598
802
|
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
803
|
+
const getUser = defineRequest<User>()({
|
|
804
|
+
method: 'GET',
|
|
805
|
+
path: '/users/:id',
|
|
806
|
+
middleware: [getUserCache],
|
|
807
|
+
})
|
|
602
808
|
|
|
603
|
-
|
|
604
|
-
const result = await next()
|
|
809
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
|
|
605
810
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
811
|
+
// On logout, clear every cached entry:
|
|
812
|
+
getUserCache.clear()
|
|
813
|
+
|
|
814
|
+
// Skip the cache for one call:
|
|
815
|
+
const { data } = await api.getUser({ id: '42' }, { skipMiddleware: [getUserCache] })
|
|
609
816
|
```
|
|
610
817
|
|
|
611
|
-
|
|
818
|
+
- **Only successes are cached.** An error always goes to the network again.
|
|
819
|
+
- **The key includes the URL, the params and the headers.** When your auth header is set before the cache runs, one user never sees another user's entry. [Cache key](#cache-key) has the details.
|
|
820
|
+
- **Put a middleware that adds a unique header to each call after `cacheMiddleware`.** A request ID added before it makes every call look new, and nothing is ever cached. Client middleware always runs before endpoint middleware, so either list the request-ID middleware on the endpoint after the cache, or put the cache on the client before it.
|
|
821
|
+
- The options are `ttl`, `maxSize` and `debug` ([Cache options](#cache-options)).
|
|
612
822
|
|
|
613
|
-
|
|
614
|
-
- **Short-circuit** -- return early without calling `next()` (e.g., serve from cache).
|
|
615
|
-
- **Retry** -- call `next()` multiple times in a loop (e.g., retry on 5xx).
|
|
616
|
-
- **Inspect the result** -- log, report errors, transform response data.
|
|
823
|
+
#### Log every call
|
|
617
824
|
|
|
618
|
-
|
|
825
|
+
`logMiddleware` prints each call's start and end to the console, with its duration:
|
|
826
|
+
|
|
827
|
+
```text
|
|
828
|
+
[liaise] → GET getItems /api/items
|
|
829
|
+
[liaise] ← getItems OK (142ms)
|
|
619
830
|
|
|
620
|
-
|
|
831
|
+
[liaise] → POST createUser /api/users
|
|
832
|
+
[liaise] ← createUser ERROR 422 (89ms)
|
|
833
|
+
```
|
|
621
834
|
|
|
622
835
|
```ts
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
baseUrl: '/api',
|
|
626
|
-
requests: { getUser, createUser },
|
|
627
|
-
middleware: [authMiddleware, logMiddleware]
|
|
628
|
-
})
|
|
836
|
+
import { createApi, defineRequest } from 'liaise'
|
|
837
|
+
import { logMiddleware } from 'liaise/middleware'
|
|
629
838
|
|
|
630
|
-
|
|
631
|
-
const getUser = new Request<{ id: string }, User>({
|
|
632
|
-
method: 'GET',
|
|
633
|
-
path: '/users/:id',
|
|
634
|
-
middleware: [cacheMiddleware]
|
|
635
|
-
})
|
|
839
|
+
const getItems = defineRequest<{ id: string }[]>()({ method: 'GET', path: '/items' })
|
|
636
840
|
|
|
637
|
-
|
|
638
|
-
await api.getUser({ id: '42' }, {
|
|
639
|
-
middleware: [customTraceMiddleware]
|
|
640
|
-
})
|
|
841
|
+
const api = createApi({ baseUrl: '/api', requests: { getItems }, middleware: [logMiddleware] })
|
|
641
842
|
```
|
|
642
843
|
|
|
643
|
-
|
|
844
|
+
It is meant for development. In production, [write a middleware](#writing-middleware) that sends the same facts to your monitoring.
|
|
845
|
+
|
|
846
|
+
### Writing middleware
|
|
644
847
|
|
|
645
|
-
|
|
848
|
+
Some things belong on every call, such as an auth header or tracing, and you don't want to repeat them at each call site. A middleware is a function that wraps a call. It sees the request before it is sent and the `Result` after.
|
|
646
849
|
|
|
647
850
|
```ts
|
|
648
|
-
|
|
851
|
+
import { createApi, defineRequest, type Middleware } from 'liaise'
|
|
852
|
+
|
|
853
|
+
type User = { id: string; name: string }
|
|
854
|
+
|
|
855
|
+
const auth: Middleware = async (ctx, next) => {
|
|
856
|
+
// Before: change the request
|
|
857
|
+
ctx.request.headers.set('Authorization', `Bearer ${getToken()}`)
|
|
858
|
+
|
|
859
|
+
// Run the rest of the chain, ending in fetch
|
|
860
|
+
const result = await next()
|
|
861
|
+
|
|
862
|
+
// After: read or change the result
|
|
863
|
+
return result
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
|
|
649
867
|
|
|
650
868
|
const api = createApi({
|
|
651
|
-
baseUrl: '
|
|
869
|
+
baseUrl: 'https://api.example.com',
|
|
652
870
|
requests: { getUser },
|
|
653
|
-
middleware: [
|
|
871
|
+
middleware: [auth],
|
|
654
872
|
})
|
|
873
|
+
```
|
|
655
874
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
875
|
+
`getToken` stands for wherever you keep the token.
|
|
876
|
+
|
|
877
|
+
Each middleware wraps the next, like the layers of an onion. The request passes in through every layer to `fetch`, and the `Result` passes back out through the same layers:
|
|
878
|
+
|
|
879
|
+
```text
|
|
880
|
+
call → [client middleware → [endpoint middleware → [call middleware → [fetch]]]]
|
|
660
881
|
```
|
|
661
882
|
|
|
662
|
-
|
|
883
|
+
A middleware can:
|
|
663
884
|
|
|
664
|
-
|
|
885
|
+
- **Change the request.** Set headers, change the body, rewrite the URL.
|
|
886
|
+
- **Answer early** without calling `next()`, for example from a cache.
|
|
887
|
+
- **Call `next()` more than once**, for example to retry a 5xx.
|
|
888
|
+
- **Read the result** to log it, report an error or transform `data`.
|
|
665
889
|
|
|
666
|
-
|
|
890
|
+
Middleware runs at three levels, the client's first and the call's last, as [Three levels of settings](#three-levels-of-settings) shows.
|
|
667
891
|
|
|
668
|
-
|
|
669
|
-
| --------------------- | --------- | -------------------------------------------------------- |
|
|
670
|
-
| `request.method` | `string` | HTTP method (GET, POST, etc.) |
|
|
671
|
-
| `request.url` | `string` | Fully resolved URL with path params and query string |
|
|
672
|
-
| `request.path` | `string` | Original path template (e.g., '/users/:id') |
|
|
673
|
-
| `request.params` | `unknown` | Original params object from the caller |
|
|
674
|
-
| `request.headers` | `Headers` | Merged headers -- middleware can add/remove entries |
|
|
675
|
-
| `request.body` | `unknown` | Serialized body, or null for GET/DELETE |
|
|
676
|
-
| `request.signal` | `AbortSignal \| undefined` | The signal handed to `fetch` -- replace it to impose your own cancellation policy |
|
|
677
|
-
| `requestName` | `string` | Key name in the requests object (e.g., 'getUser') |
|
|
892
|
+
#### Skipping a middleware for one call
|
|
678
893
|
|
|
679
|
-
|
|
894
|
+
Pass the middleware itself in `skipMiddleware`:
|
|
680
895
|
|
|
681
896
|
```ts
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
897
|
+
import { createApi, defineRequest } from 'liaise'
|
|
898
|
+
import { retryMiddleware, logMiddleware } from 'liaise/middleware'
|
|
899
|
+
|
|
900
|
+
type User = { id: string; name: string }
|
|
901
|
+
|
|
902
|
+
const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
|
|
903
|
+
const retry = retryMiddleware(3)
|
|
686
904
|
|
|
687
905
|
const api = createApi({
|
|
688
|
-
baseUrl: '
|
|
906
|
+
baseUrl: 'https://api.example.com',
|
|
689
907
|
requests: { getUser },
|
|
690
|
-
middleware: [
|
|
908
|
+
middleware: [retry, logMiddleware],
|
|
691
909
|
})
|
|
910
|
+
|
|
911
|
+
// No retries for this one call
|
|
912
|
+
await api.getUser({ id: '42' }, { skipMiddleware: [retry] })
|
|
692
913
|
```
|
|
693
914
|
|
|
694
|
-
|
|
915
|
+
liaise compares by reference (`===`). Store what a factory such as `retryMiddleware(3)` returns in a variable first. Calling the factory again makes a new function, which won't match.
|
|
695
916
|
|
|
696
|
-
|
|
917
|
+
#### What a middleware sees
|
|
697
918
|
|
|
698
|
-
|
|
919
|
+
`ctx.requestName` is the endpoint's key, such as `'getUser'`. `ctx.request` holds the request as it will be sent, with the URL filled in and the body serialized. Every field is listed under [MiddlewareContext](#middlewarecontext).
|
|
699
920
|
|
|
700
|
-
|
|
921
|
+
#### Signals in middleware
|
|
701
922
|
|
|
702
|
-
|
|
703
|
-
const cacheMiddleware: Middleware = async (ctx, next) => {
|
|
704
|
-
const cached = cache.get(ctx.request.url)
|
|
705
|
-
if (cached) return cached
|
|
923
|
+
`ctx.request.signal` combines the caller's `signal` with the call's `timeout`, and is `undefined` when there is neither. liaise reads it when it calls `fetch`, so a middleware can replace it. [Give each attempt its own timeout](#give-each-attempt-its-own-timeout) does this, and says what happens to a caller's cancel. How a replaced signal works with `share` and `dedupe` is under [Signal-replacing middleware](#signal-replacing-middleware).
|
|
706
924
|
|
|
707
|
-
|
|
925
|
+
**Pass `ctx.request.signal` on to async work your middleware does itself**, such as a token refresh, a lookup or a queue. liaise won't wait for that work past the deadline or the caller's cancel ([Timeout backstop](#timeout-backstop)). A promise can't be stopped from outside, so passing the signal is the only way to end the work. Without it, the work keeps running and its result is thrown away.
|
|
708
926
|
|
|
709
|
-
|
|
710
|
-
cache.set(ctx.request.url, result)
|
|
711
|
-
}
|
|
927
|
+
#### Example: report server errors
|
|
712
928
|
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
```
|
|
716
|
-
|
|
717
|
-
An error reporting middleware:
|
|
929
|
+
This middleware sends every 5xx to Sentry, with the method and URL:
|
|
718
930
|
|
|
719
931
|
```ts
|
|
720
|
-
|
|
932
|
+
import type { Middleware } from 'liaise'
|
|
933
|
+
import * as Sentry from '@sentry/browser'
|
|
934
|
+
|
|
935
|
+
const reportServerErrors: Middleware = async (ctx, next) => {
|
|
721
936
|
const result = await next()
|
|
722
937
|
|
|
723
938
|
if (result.error && result.error.status >= 500) {
|
|
724
939
|
Sentry.captureMessage(`API error: ${ctx.request.method} ${ctx.request.url}`, {
|
|
725
|
-
extra: { status: result.error.status, body: result.error.body }
|
|
940
|
+
extra: { status: result.error.status, body: result.error.body },
|
|
726
941
|
})
|
|
727
942
|
}
|
|
728
943
|
|
|
@@ -730,309 +945,455 @@ const sentryMiddleware: Middleware = async (ctx, next) => {
|
|
|
730
945
|
}
|
|
731
946
|
```
|
|
732
947
|
|
|
733
|
-
|
|
948
|
+
### Pagination
|
|
734
949
|
|
|
735
|
-
|
|
950
|
+
Many list endpoints return one page at a time. `paginate` walks through the pages and gives you one `Result` per page:
|
|
736
951
|
|
|
737
952
|
```ts
|
|
738
|
-
import {
|
|
739
|
-
```
|
|
953
|
+
import { createApi, defineRequest, paginate } from 'liaise'
|
|
740
954
|
|
|
741
|
-
|
|
955
|
+
type Item = { id: string; name: string }
|
|
956
|
+
type Page = { items: Item[]; cursor?: string }
|
|
742
957
|
|
|
743
|
-
|
|
958
|
+
const listItems = defineRequest<Page, { limit: number; cursor?: string }>()({
|
|
959
|
+
method: 'GET',
|
|
960
|
+
path: '/items',
|
|
961
|
+
})
|
|
744
962
|
|
|
745
|
-
|
|
963
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { listItems } })
|
|
746
964
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
}
|
|
965
|
+
for await (const page of paginate(api.listItems, { limit: 50 }, {
|
|
966
|
+
next: (p, prev) => (p.data.cursor ? { ...prev, cursor: p.data.cursor } : undefined),
|
|
967
|
+
})) {
|
|
968
|
+
if (page.error) break
|
|
969
|
+
render(page.data.items)
|
|
970
|
+
}
|
|
753
971
|
```
|
|
754
972
|
|
|
755
|
-
|
|
973
|
+
`render` stands for your own code.
|
|
974
|
+
|
|
975
|
+
- **`next` returns the params for the next page.** It gets the page just loaded and the params that loaded it, so the usual case is a spread. liaise never has to guess whether your API calls it `cursor`, `page_token` or `after`, and the same shape covers every scheme:
|
|
756
976
|
|
|
757
|
-
|
|
977
|
+
```ts
|
|
978
|
+
// offset
|
|
979
|
+
next: (p, prev) => p.data.items.length === prev.limit
|
|
980
|
+
? { ...prev, offset: prev.offset + prev.limit }
|
|
981
|
+
: undefined
|
|
758
982
|
|
|
759
|
-
|
|
983
|
+
// page number, driven by a Link header
|
|
984
|
+
next: (p, prev) => p.response.headers.get('link')?.includes('rel="next"')
|
|
985
|
+
? { ...prev, page: prev.page + 1 }
|
|
986
|
+
: undefined
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
- **Return `undefined` or `null` to stop.**
|
|
990
|
+
- **An error page ends the walk.** You get the error page, and then the loop ends, because there is no data to read the next cursor from. You see what failed. The loop never stops quietly.
|
|
991
|
+
- **`maxPages` has no default.** Set it if you want a ceiling, as in `paginate(api.listItems, { limit: 50 }, { next, maxPages: 100 })`. liaise doesn't pick a number, because a silent cut-off at an arbitrary page looks exactly like reaching the last one.
|
|
992
|
+
- **Every other option applies to every page.** Any of the [`CallOptions`](#calloptions), such as `signal`, `timeout` or `headers`, goes with each request, so one signal cancels the whole walk.
|
|
993
|
+
- **`paginate` yields pages.** Read the items from each page yourself. Flattening them would mean guessing which field holds the array.
|
|
994
|
+
|
|
995
|
+
### GraphQL
|
|
996
|
+
|
|
997
|
+
`createGraphQL` is the client for a GraphQL backend. Its calls return the same `Result`, run the same middleware and report errors the same way. Every operation is sent as a POST with `{ query, variables }`.
|
|
760
998
|
|
|
761
999
|
```ts
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
1000
|
+
import { createGraphQL, Operation, gql } from 'liaise'
|
|
1001
|
+
|
|
1002
|
+
type Category = { id: string; name: string; status: string }
|
|
1003
|
+
|
|
1004
|
+
const GET_CATEGORY = gql`
|
|
1005
|
+
query GetCategory($id: String!) {
|
|
1006
|
+
category(id: $id) {
|
|
1007
|
+
id
|
|
1008
|
+
name
|
|
1009
|
+
status
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
`
|
|
1013
|
+
|
|
1014
|
+
const getCategory = new Operation<{ id: string }, Category>({
|
|
1015
|
+
operation: GET_CATEGORY,
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
const graphql = createGraphQL({
|
|
1019
|
+
endpoint: 'https://api.example.com/graphql',
|
|
1020
|
+
operations: { getCategory },
|
|
1021
|
+
onError: (error) => console.error(error.status, error.body),
|
|
774
1022
|
})
|
|
775
|
-
```
|
|
776
1023
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
| `max` | `number` | `3` | Additional attempts after the first. `retryMiddleware({ max: 2 })` means up to 3 total calls. |
|
|
780
|
-
| `delay` | `'exponential' \| 'linear' \| (attempt: number) => number` | `'exponential'` | The delay curve. Exponential is `baseDelay * 2^(attempt-1)`; linear is `baseDelay * attempt`; a function receives the 1-based attempt number and returns milliseconds. |
|
|
781
|
-
| `baseDelay` | `number` | `250` | The first delay, in milliseconds, before jitter and `Retry-After` are applied. |
|
|
782
|
-
| `maxDelay` | `number` | `30000` | Hard cap applied to every computed delay, including a `Retry-After` value. |
|
|
783
|
-
| `jitter` | `boolean` | `true` | Full jitter: the actual delay is `Math.random() * computed`, per AWS's recommendation for de-synchronizing a thundering herd. Never applied to a `Retry-After` value — a server telling you exactly when to come back should not be randomized. |
|
|
784
|
-
| `respectRetryAfter` | `boolean` | `true` | Honor a `Retry-After` response header (delta-seconds or an HTTP-date) when present, replacing the computed delay outright (still capped by `maxDelay`). |
|
|
785
|
-
| `retryOn` | `(result: Result<unknown>, attempt: number) => boolean` | `r => (r.error?.status ?? 0) >= 500` | Whether to retry. Called with the 1-based *candidate* attempt number, even once `max` is reached, so a predicate that counts attempts sees one call per result. |
|
|
786
|
-
| `onRetry` | `(info: RetryInfo) => void` | — | Observational hook fired before each retry's delay elapses. Its return value is ignored, and a throw cannot fail the request — this is the only way to observe an in-progress retry sequence, since the call site sees nothing until the final result. |
|
|
1024
|
+
const { data, error, response, retry } = await graphql.getCategory({ id: '123' })
|
|
1025
|
+
```
|
|
787
1026
|
|
|
788
|
-
`
|
|
1027
|
+
`Operation<TVariables, TData>` takes the variables type first and the response type second. `gql` marks the string as GraphQL for your editor.
|
|
789
1028
|
|
|
790
|
-
|
|
1029
|
+
An operation with no variables is called without arguments. Write `Record<string, never>` as its variables type:
|
|
791
1030
|
|
|
792
1031
|
```ts
|
|
793
|
-
|
|
794
|
-
retryMiddleware({
|
|
795
|
-
retryOn: r => r.error?.status === 429 || (r.error?.status ?? 0) >= 500
|
|
796
|
-
})
|
|
1032
|
+
type Viewer = { id: string; name: string }
|
|
797
1033
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
retryOn: r => (r.error?.status ?? 0) >= 500 || r.error?.kind === 'network'
|
|
1034
|
+
const getViewer = new Operation<Record<string, never>, Viewer>({
|
|
1035
|
+
operation: gql`query { viewer { id name } }`,
|
|
801
1036
|
})
|
|
1037
|
+
|
|
1038
|
+
const api = createGraphQL({ endpoint: 'https://api.example.com/graphql', operations: { getViewer } })
|
|
1039
|
+
|
|
1040
|
+
const { data } = await api.getViewer() // no arguments
|
|
802
1041
|
```
|
|
803
1042
|
|
|
804
|
-
|
|
1043
|
+
#### Queries and mutations
|
|
1044
|
+
|
|
1045
|
+
To keep queries and mutations apart, use the `queries` and `mutations` keys instead of `operations`. Each client uses one shape or the other, and TypeScript refuses both together.
|
|
805
1046
|
|
|
806
1047
|
```ts
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1048
|
+
const graphql = createGraphQL({
|
|
1049
|
+
endpoint: 'https://api.example.com/graphql',
|
|
1050
|
+
queries: {
|
|
1051
|
+
getCategory: new Operation<{ id: string }, Category>({ operation: GET_CATEGORY }),
|
|
1052
|
+
},
|
|
1053
|
+
mutations: {
|
|
1054
|
+
updateCategory: new Operation<{ id: string; name: string }, Category>({
|
|
1055
|
+
operation: gql`
|
|
1056
|
+
mutation UpdateCategory($id: String!, $name: String!) {
|
|
1057
|
+
updateCategory(id: $id, name: $name) { id name status }
|
|
1058
|
+
}
|
|
1059
|
+
`,
|
|
1060
|
+
}),
|
|
815
1061
|
},
|
|
816
|
-
middleware: [retryMiddleware({ max: 5, baseDelay: 1000 })], // long backoff
|
|
817
1062
|
})
|
|
818
1063
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
// error.status === 0, error.kind === 'timeout' -- not the 503 being retried
|
|
1064
|
+
graphql.query.getCategory({ id: '123' })
|
|
1065
|
+
graphql.mutation.updateCategory({ id: '123', name: 'New Name' })
|
|
822
1066
|
```
|
|
823
1067
|
|
|
824
|
-
|
|
1068
|
+
#### GraphQL errors
|
|
825
1069
|
|
|
826
|
-
|
|
1070
|
+
A 2xx response with `{ errors: [...] }` is an error. It has `kind: 'http'`, the response's own `status`, and the `GraphQLError[]` in `error.body`. The same `if (error)` check covers GraphQL errors, HTTP errors and network errors.
|
|
827
1071
|
|
|
828
|
-
|
|
829
|
-
[liaise] → GET getItems /api/items
|
|
830
|
-
[liaise] ← getItems OK (142ms)
|
|
1072
|
+
GraphQL allows partial success, where one field fails and the rest of the query resolves. That data is kept in `error.partialData`. `result.data` stays `null` whenever `error` is set, so `Result` keeps its two clean branches.
|
|
831
1073
|
|
|
832
|
-
|
|
833
|
-
|
|
1074
|
+
```ts
|
|
1075
|
+
const { error } = await graphql.getCategory({ id: '123' })
|
|
1076
|
+
if (error) {
|
|
1077
|
+
console.log(error.body) // GraphQLError[]
|
|
1078
|
+
console.log(error.partialData) // the data the server sent with the errors, or undefined
|
|
1079
|
+
}
|
|
834
1080
|
```
|
|
835
1081
|
|
|
836
|
-
|
|
1082
|
+
- **A 2xx with neither `data` nor `errors` is a `'parse'` error**, the same rule as an empty body on REST. That covers an empty body, `{}`, `{"data": null}` and a JSON root that isn't an object. `error.body` holds the raw response text, such as `''` or `'{}'`.
|
|
1083
|
+
- **`{"data": null, "errors": [...]}` is still `kind: 'http'`**, because the errors are checked first. Any partial result is in `error.partialData`.
|
|
1084
|
+
|
|
1085
|
+
#### Same as REST, and different
|
|
1086
|
+
|
|
1087
|
+
Most of what the guide says about `createApi` holds for `createGraphQL`.
|
|
1088
|
+
|
|
1089
|
+
**The same as REST**
|
|
1090
|
+
|
|
1091
|
+
- **Every call returns a `Result`**, `{ data, error, response, retry }`, and `error.kind` names the failure.
|
|
1092
|
+
- **`middleware`** runs on the client, the `Operation` and the call, in the same order. The context has the same shape, so the [built-in middleware](#retries-caching-and-logging) and yours work unchanged.
|
|
1093
|
+
- **`headers`** go on the client, the `Operation` and the call, and merge by the [three levels](#three-levels-of-settings).
|
|
1094
|
+
- **`onError`** goes on `createGraphQL` and works as in [Reporting errors with onError](#reporting-errors-with-onerror).
|
|
1095
|
+
- **`retry()`** is on every `Result`.
|
|
1096
|
+
- **`dedupe`** goes on the `Operation`, as in [Drop stale calls with dedupe](#drop-stale-calls-with-dedupe).
|
|
1097
|
+
- **`timeout`** goes on the `Operation` or the call. It is one deadline for the whole call, retries included.
|
|
1098
|
+
- **`schema`** goes on the `Operation` and validates the response's `data`. The response type stays explicit ([Validating responses](#validating-responses)).
|
|
1099
|
+
- **`signal` and `skipMiddleware`** go on the call.
|
|
1100
|
+
|
|
1101
|
+
**Different from REST**
|
|
1102
|
+
|
|
1103
|
+
- **`endpoint`** is the full URL of the GraphQL endpoint. It takes the place of `baseUrl`.
|
|
1104
|
+
- **Variables always go in the JSON body.** There are no path params, no query strings and no `bodyAs`.
|
|
1105
|
+
- **Every operation is a `POST`**, with `Content-Type: application/json` unless you set your own.
|
|
1106
|
+
- **There is no `share`.** An `Operation` can't join identical calls.
|
|
1107
|
+
- **There is no `responseType`.** The response is always read as JSON.
|
|
1108
|
+
|
|
1109
|
+
### Testing your code
|
|
1110
|
+
|
|
1111
|
+
`liaise/testing` gives you a `fetch` stub that matches routes. Your tests then run the real pipeline, from URL building, headers and body to parsing and your own middleware. A stubbed API method that returns a canned `Result` drifts away from what liaise actually does. The stub needs no test runner, so it works in Vitest, Jest or anything else.
|
|
837
1112
|
|
|
838
1113
|
```ts
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1114
|
+
import { mockFetch, jsonResponse } from 'liaise/testing'
|
|
1115
|
+
|
|
1116
|
+
const mock = mockFetch({
|
|
1117
|
+
'GET /api/users/:id': ({ params }) => jsonResponse({ id: params.id, name: 'Ada' }),
|
|
1118
|
+
'POST /api/users': jsonResponse({ id: 'new-user' }, { status: 201 }),
|
|
843
1119
|
})
|
|
1120
|
+
|
|
1121
|
+
mock.install() // replaces globalThis.fetch
|
|
1122
|
+
// ... exercise your code, which calls the real api.getUser(...) ...
|
|
1123
|
+
mock.restore() // puts the original globalThis.fetch back
|
|
844
1124
|
```
|
|
845
1125
|
|
|
846
|
-
|
|
1126
|
+
- **Routes are keyed as `"METHOD /path"`.** Each `:token` segment is captured and passed to a route function as `{ params, request }`.
|
|
1127
|
+
- **A route value is a `Response`, a route function, or an array of either.** `jsonResponse` builds a `Response` with a JSON body, or you can build your own.
|
|
1128
|
+
- **An array is a sequence.** Each matching call takes the next entry, and the last entry repeats once the others are used up. That suits "fail twice, then succeed":
|
|
1129
|
+
|
|
1130
|
+
```ts
|
|
1131
|
+
const mock = mockFetch({
|
|
1132
|
+
'GET /api/flaky': [jsonResponse(null, { status: 503 }), jsonResponse({ ok: true })],
|
|
1133
|
+
})
|
|
1134
|
+
```
|
|
847
1135
|
|
|
848
|
-
|
|
1136
|
+
- **`mock.calls` records every request** as `{ method, url, headers, body }`. `mock.callCount('GET /api/users/:id')` and `mock.lastCall(...)` take the same `"METHOD /path"` keys as the routes.
|
|
849
1137
|
|
|
850
|
-
|
|
1138
|
+
#### Unmatched routes
|
|
1139
|
+
|
|
1140
|
+
A request that matches no route makes the stub throw, so a typo in a path can't pass quietly. liaise catches every `fetch` rejection, so your code sees an ordinary `Result` with `kind: 'network'` and the `Error` in `error.body`. Its message names the method, the URL and every route you defined. Assert on the result, because the call itself never rejects.
|
|
851
1141
|
|
|
852
1142
|
```ts
|
|
853
|
-
const
|
|
1143
|
+
const r = await api.getUser({ id: '42' }) // routes only define 'GET /api/user/:id'
|
|
1144
|
+
expect(r.error?.kind).toBe('network')
|
|
1145
|
+
expect(String(r.error?.body)).toMatch(/no route matched GET \/api\/users\/42/)
|
|
1146
|
+
```
|
|
854
1147
|
|
|
855
|
-
|
|
856
|
-
method: 'GET',
|
|
857
|
-
path: '/users/:id',
|
|
858
|
-
middleware: [getUserCache],
|
|
859
|
-
})
|
|
1148
|
+
`api` is a client created with `baseUrl: '/api'`.
|
|
860
1149
|
|
|
861
|
-
|
|
862
|
-
getUserCache.clear()
|
|
1150
|
+
An empty array for a route behaves the same way, with a descriptive `Error` in `error.body`.
|
|
863
1151
|
|
|
864
|
-
|
|
865
|
-
|
|
1152
|
+
#### Stubbing a Result directly
|
|
1153
|
+
|
|
1154
|
+
To stub at the `Result` level instead of the `fetch` level, `successResult(data)` and `errorResult(status, body)` build a well-formed `Result`. This uses Vitest's `vi.spyOn`, and any runner's equivalent works the same way:
|
|
1155
|
+
|
|
1156
|
+
```ts
|
|
1157
|
+
import { successResult, errorResult } from 'liaise/testing'
|
|
1158
|
+
|
|
1159
|
+
vi.spyOn(api, 'getUser').mockResolvedValue(successResult({ id: '42', name: 'Ada' }))
|
|
1160
|
+
vi.spyOn(api, 'getUser').mockResolvedValue(errorResult(404, { message: 'not found' }))
|
|
866
1161
|
```
|
|
867
1162
|
|
|
868
|
-
|
|
1163
|
+
More of the stub's behaviour, such as how it handles a signal, is under [liaise/testing](#liaisetesting).
|
|
869
1164
|
|
|
870
|
-
|
|
1165
|
+
## Recipes
|
|
871
1166
|
|
|
872
|
-
|
|
1167
|
+
Each recipe below is a complete example that runs against a test, so you can paste it as it is.
|
|
873
1168
|
|
|
874
|
-
|
|
875
|
-
| ----------------- | ------------------ | ------------------------------------- |
|
|
876
|
-
| `null`/`undefined` | `null` | _(none)_ |
|
|
877
|
-
| `string` | as-is | `text/plain` |
|
|
878
|
-
| `FormData` | as-is | _(browser sets multipart boundary)_ |
|
|
879
|
-
| `URLSearchParams` | as-is | `application/x-www-form-urlencoded` |
|
|
880
|
-
| `Blob` | as-is | `application/octet-stream` |
|
|
881
|
-
| `ArrayBuffer` | as-is | `application/octet-stream` |
|
|
882
|
-
| Typed array, `DataView`, `Buffer` | as-is (sent as binary) | `application/octet-stream` |
|
|
883
|
-
| `ReadableStream` | as-is (streaming upload; `duplex: 'half'` is set for you) | `application/octet-stream` |
|
|
884
|
-
| Plain object | `JSON.stringify()` | `application/json` |
|
|
1169
|
+
### Add an auth header and refresh the token on a 401
|
|
885
1170
|
|
|
886
|
-
|
|
1171
|
+
With rotating refresh tokens, five requests that all get a 401 at once must not refresh five times: the first refresh invalidates the token the other four send, and the user is logged out.
|
|
887
1172
|
|
|
888
|
-
|
|
1173
|
+
<!-- tested: auth-refresh -->
|
|
1174
|
+
```ts
|
|
1175
|
+
import { createApi, defineRequest, type Middleware } from 'liaise'
|
|
889
1176
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
| Plain object | Decomposed into path tokens, query string and body |
|
|
893
|
-
| `Map` with string keys | Same as the object it spells |
|
|
894
|
-
| Class instance with fields | Same as a plain object (decomposed by those fields even if the class also defines `toJSON()`; `toJSON()` is used only when there are no own fields) |
|
|
895
|
-
| Class instance with only `toJSON()` | Sent as its JSON (body only; refused on a request whose params go in the query string) |
|
|
896
|
-
| Typed array, `DataView`, `Buffer`, `ReadableStream` | Sent as the body, as in the table above (refused on a request whose params go in the query string) |
|
|
897
|
-
| `Set`, a bare `Date`, a `Map` with non-string keys, a class with no fields | Refused: an error Result (`kind: 'network'`) naming the type. Nothing is sent. |
|
|
1177
|
+
type Tokens = { access: string; refresh: string }
|
|
1178
|
+
type Order = { id: string; total: number }
|
|
898
1179
|
|
|
899
|
-
|
|
1180
|
+
let tokens: Tokens = { access: 'expired', refresh: 'r1' }
|
|
900
1181
|
|
|
901
|
-
|
|
1182
|
+
const auth: Middleware = async (ctx, next) => {
|
|
1183
|
+
if (ctx.requestName === 'refresh') return next()
|
|
902
1184
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1185
|
+
const sentWith = tokens.access
|
|
1186
|
+
ctx.request.headers.set('Authorization', `Bearer ${sentWith}`)
|
|
1187
|
+
const result = await next()
|
|
1188
|
+
if (result.error?.status !== 401) return result
|
|
1189
|
+
|
|
1190
|
+
// Refresh only if nobody has done it since this call was sent. Every call
|
|
1191
|
+
// that gets here at the same moment joins one refresh request (share: true).
|
|
1192
|
+
if (tokens.access === sentWith) {
|
|
1193
|
+
const refreshed = await api.refresh({ token: tokens.refresh })
|
|
1194
|
+
if (refreshed.error) return result // refresh failed: keep the 401
|
|
1195
|
+
tokens = refreshed.data
|
|
1196
|
+
}
|
|
1197
|
+
ctx.request.headers.set('Authorization', `Bearer ${tokens.access}`)
|
|
1198
|
+
return next() // send the call again with the new token
|
|
1199
|
+
}
|
|
906
1200
|
|
|
907
|
-
|
|
1201
|
+
const api = createApi({
|
|
1202
|
+
baseUrl: '/api',
|
|
1203
|
+
middleware: [auth],
|
|
1204
|
+
requests: {
|
|
1205
|
+
refresh: defineRequest<Tokens, { token: string }>()({
|
|
1206
|
+
method: 'POST',
|
|
1207
|
+
path: '/auth/refresh',
|
|
1208
|
+
share: true,
|
|
1209
|
+
}),
|
|
1210
|
+
getOrders: defineRequest<Order[]>()({ method: 'GET', path: '/orders' }),
|
|
1211
|
+
},
|
|
1212
|
+
})
|
|
1213
|
+
```
|
|
908
1214
|
|
|
909
|
-
|
|
1215
|
+
`share: true` on `refresh` is what makes the five calls wait for one refresh. The `requestName` check stops the refresh call from passing through its own middleware.
|
|
910
1216
|
|
|
911
|
-
|
|
1217
|
+
### Search as you type
|
|
912
1218
|
|
|
913
|
-
|
|
914
|
-
| --------------- | ---------------------- | -------------- |
|
|
915
|
-
| `'json'` | `response.text()` then `JSON.parse()` | parsed object |
|
|
916
|
-
| `'text'` | `response.text()` | `string` |
|
|
917
|
-
| `'blob'` | `response.blob()` | `Blob` |
|
|
918
|
-
| `'arrayBuffer'` | `response.arrayBuffer()` | `ArrayBuffer` |
|
|
919
|
-
| `'formData'` | `response.formData()` | `FormData` |
|
|
920
|
-
| `'none'` | *(not read -- stream cancelled)* | `undefined` |
|
|
1219
|
+
Each keystroke starts a request, and a slow answer to an early one can arrive after a fast answer to a later one. `dedupe` makes sure only the latest search is shown.
|
|
921
1220
|
|
|
922
|
-
|
|
923
|
-
`null`: you declared JSON and the server sent none, so there is no value that
|
|
924
|
-
could honestly satisfy `TResponse`. You get `kind: 'parse'` with the
|
|
925
|
-
response's own status (a `204` reports `204`), a non-null `response`, and the
|
|
926
|
-
raw body text -- always `''` for this case -- in `error.body`:
|
|
1221
|
+
`Repo` is your result type; `render` and `showError` stand for your UI code.
|
|
927
1222
|
|
|
1223
|
+
<!-- tested: search-as-you-type -->
|
|
928
1224
|
```ts
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1225
|
+
import { createApi, defineRequest } from 'liaise'
|
|
1226
|
+
|
|
1227
|
+
const search = defineRequest<Repo[], { q: string }>()({
|
|
1228
|
+
method: 'GET',
|
|
1229
|
+
path: '/search',
|
|
1230
|
+
dedupe: true, // a new call cancels the one still in flight
|
|
1231
|
+
})
|
|
1232
|
+
const api = createApi({ baseUrl: '/api', requests: { search } })
|
|
1233
|
+
|
|
1234
|
+
async function onInput(q: string) {
|
|
1235
|
+
const { data, error } = await api.search({ q })
|
|
1236
|
+
if (error?.kind === 'abort') return // a newer search replaced this one
|
|
1237
|
+
if (error) return showError(error)
|
|
1238
|
+
render(data)
|
|
1239
|
+
}
|
|
932
1240
|
```
|
|
933
1241
|
|
|
934
|
-
|
|
935
|
-
and that response still succeeds with `data: null`.
|
|
1242
|
+
The older call settles with an `abort` error, which you skip. Only the newest search reaches `render`.
|
|
936
1243
|
|
|
937
|
-
|
|
938
|
-
body on success -- a `204`, or a `200` with an empty body, most commonly a
|
|
939
|
-
`DELETE`:
|
|
1244
|
+
### Use with TanStack Query
|
|
940
1245
|
|
|
1246
|
+
liaise never throws. TanStack Query expects a failing query function to throw, so `unwrap` converts at that one boundary. `api` is the client from [Quick start](#quick-start).
|
|
1247
|
+
|
|
1248
|
+
<!-- tested: tanstack-query -->
|
|
941
1249
|
```ts
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1250
|
+
import type { Result } from 'liaise'
|
|
1251
|
+
|
|
1252
|
+
// TanStack Query expects a failed query to throw. Do it here, at your edge.
|
|
1253
|
+
async function unwrap<T>(call: Promise<Result<T>>): Promise<T> {
|
|
1254
|
+
const { data, error } = await call
|
|
1255
|
+
if (error) throw error
|
|
1256
|
+
return data
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
export const userQuery = (id: string) => ({
|
|
1260
|
+
queryKey: ['user', id],
|
|
1261
|
+
// TanStack's signal cancels the request when the query is no longer needed.
|
|
1262
|
+
queryFn: ({ signal }: { signal: AbortSignal }) => unwrap(api.getUser({ id }, { signal })),
|
|
946
1263
|
})
|
|
1264
|
+
|
|
1265
|
+
// React: useQuery(userQuery(id))
|
|
1266
|
+
// Vue: useQuery(computed(() => userQuery(id.value)))
|
|
1267
|
+
// Svelte: createQuery(() => userQuery(id))
|
|
1268
|
+
// Solid: useQuery(() => userQuery(id()))
|
|
947
1269
|
```
|
|
948
1270
|
|
|
949
|
-
|
|
1271
|
+
The same options object works with every TanStack adapter; the comments show the call in each. These comment lines aren't executed by the test.
|
|
1272
|
+
|
|
1273
|
+
`error` is the `ApiError` liaise returned, which isn't an `Error` subclass, so it has no `message`. Read `error.kind` and `error.status`. To type it, register `ApiError` as TanStack's `defaultError`.
|
|
1274
|
+
|
|
1275
|
+
### Use with React
|
|
950
1276
|
|
|
951
|
-
|
|
1277
|
+
This hook uses React's `useEffect` and `useState`, imported from `react`. `api` and `User` are from [Quick start](#quick-start).
|
|
952
1278
|
|
|
1279
|
+
<!-- tested: react-effect -->
|
|
953
1280
|
```ts
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1281
|
+
function useUser(id: string) {
|
|
1282
|
+
const [state, setState] = useState<{ user?: User; failed?: boolean }>({})
|
|
1283
|
+
|
|
1284
|
+
useEffect(() => {
|
|
1285
|
+
const controller = new AbortController()
|
|
1286
|
+
api.getUser({ id }, { signal: controller.signal }).then(({ data, error }) => {
|
|
1287
|
+
if (error?.kind === 'abort') return // unmounted, or id changed
|
|
1288
|
+
setState(error ? { failed: true } : { user: data })
|
|
1289
|
+
})
|
|
1290
|
+
return () => controller.abort() // cancel when the component goes away
|
|
1291
|
+
}, [id])
|
|
1292
|
+
|
|
1293
|
+
return state
|
|
959
1294
|
}
|
|
960
1295
|
```
|
|
961
1296
|
|
|
962
|
-
|
|
963
|
-
`| null`. That advice is superseded: `responseType: 'none'` declares "no body"
|
|
964
|
-
rather than "body or null", and since 4.0.0 the `| null` workaround no longer
|
|
965
|
-
works at all -- the empty body is an error before `TResponse` is ever
|
|
966
|
-
consulted. See [MIGRATION.md](./MIGRATION.md#upgrading-to-400).)
|
|
967
|
-
|
|
968
|
-
### Cancellation
|
|
1297
|
+
Aborting on cleanup means a fast navigation never writes stale data into a component that has moved on.
|
|
969
1298
|
|
|
970
|
-
|
|
1299
|
+
### Load the current user into a store
|
|
971
1300
|
|
|
972
|
-
|
|
1301
|
+
The header, sidebar and avatar each check the store on first render, find it empty, and each ask for the user. `User` is your type.
|
|
973
1302
|
|
|
1303
|
+
<!-- tested: store-me -->
|
|
974
1304
|
```ts
|
|
975
|
-
|
|
1305
|
+
import { createApi, defineRequest } from 'liaise'
|
|
976
1306
|
|
|
977
|
-
const
|
|
978
|
-
|
|
979
|
-
})
|
|
1307
|
+
const me = defineRequest<User>()({ method: 'GET', path: '/me', share: true })
|
|
1308
|
+
const api = createApi({ baseUrl: '/api', requests: { me } })
|
|
980
1309
|
|
|
981
|
-
//
|
|
982
|
-
|
|
1310
|
+
// Any store works the same way: Zustand, Pinia, Redux or a plain object.
|
|
1311
|
+
const store = { user: null as User | null }
|
|
983
1312
|
|
|
984
|
-
|
|
985
|
-
|
|
1313
|
+
async function loadUser() {
|
|
1314
|
+
if (store.user) return // empty on first render, for every component
|
|
1315
|
+
const { data } = await api.me() // callers at the same moment join one request
|
|
1316
|
+
if (data) store.user = data
|
|
1317
|
+
}
|
|
986
1318
|
```
|
|
987
1319
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
Aborting settles the call even while a middleware is still awaiting work of its own that ignores the signal -- the same backstop that bounds `timeout`, described under [Timeout](#timeout).
|
|
1320
|
+
[TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) dedupes this case too; this recipe is for apps using a plain store.
|
|
991
1321
|
|
|
992
|
-
|
|
1322
|
+
### One /me per page view on the server
|
|
993
1323
|
|
|
994
|
-
|
|
1324
|
+
On the server there's no store. A page's loaders run in parallel and each one needs the current user. `User` and `IncomingRequest` stand for your types; `cookie` stands for however you pass the user's identity.
|
|
995
1325
|
|
|
1326
|
+
<!-- tested: server-loaders -->
|
|
996
1327
|
```ts
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
}
|
|
1328
|
+
import { createApi, defineRequest } from 'liaise'
|
|
1329
|
+
|
|
1330
|
+
const me = defineRequest<User>()({ method: 'GET', path: '/me', share: true })
|
|
1331
|
+
|
|
1332
|
+
// One client per incoming request: that page's loaders share one /me call,
|
|
1333
|
+
// and one user's call can never join another user's.
|
|
1334
|
+
function apiFor(req: IncomingRequest) {
|
|
1335
|
+
return createApi({
|
|
1336
|
+
baseUrl: 'https://users.internal',
|
|
1337
|
+
headers: { cookie: req.headers.cookie ?? '' },
|
|
1338
|
+
requests: { me },
|
|
1339
|
+
})
|
|
1340
|
+
}
|
|
1007
1341
|
|
|
1008
|
-
|
|
1009
|
-
api
|
|
1010
|
-
|
|
1011
|
-
api.
|
|
1342
|
+
async function renderPage(req: IncomingRequest) {
|
|
1343
|
+
const api = apiFor(req)
|
|
1344
|
+
const [header, cart] = await Promise.all([
|
|
1345
|
+
api.me().then(r => r.data?.name), // header loader
|
|
1346
|
+
api.me().then(r => r.data?.cartId), // cart loader
|
|
1347
|
+
])
|
|
1348
|
+
return { header, cart }
|
|
1349
|
+
}
|
|
1012
1350
|
```
|
|
1013
1351
|
|
|
1014
|
-
|
|
1352
|
+
The client is created per request so that one user's call can never join another's; see [On a server](#on-a-server). Inside React Server Components, React's [`cache()`](https://react.dev/reference/react/cache) does this too.
|
|
1015
1353
|
|
|
1016
|
-
###
|
|
1354
|
+
### Retry a flaky backend within one deadline
|
|
1017
1355
|
|
|
1018
|
-
|
|
1356
|
+
Use this when a backend sometimes fails with a 5xx, a rate limit or a dropped connection, and you still want an answer within a fixed time.
|
|
1019
1357
|
|
|
1358
|
+
<!-- tested: flaky-backend -->
|
|
1020
1359
|
```ts
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1360
|
+
import { createApi, defineRequest } from 'liaise'
|
|
1361
|
+
import { retryMiddleware } from 'liaise/middleware'
|
|
1362
|
+
|
|
1363
|
+
type Report = { rows: number }
|
|
1364
|
+
|
|
1365
|
+
const retry = retryMiddleware({
|
|
1366
|
+
max: 3,
|
|
1367
|
+
// Retry server errors, rate limits and dropped connections. Not 4xx.
|
|
1368
|
+
retryOn: r => {
|
|
1369
|
+
const e = r.error
|
|
1370
|
+
return !!e && (e.status >= 500 || e.status === 429 || e.kind === 'network')
|
|
1371
|
+
},
|
|
1025
1372
|
})
|
|
1026
1373
|
|
|
1027
|
-
const
|
|
1028
|
-
|
|
1374
|
+
const api = createApi({
|
|
1375
|
+
baseUrl: '/api',
|
|
1376
|
+
middleware: [retry],
|
|
1377
|
+
requests: {
|
|
1378
|
+
// timeout covers every attempt and every wait between them.
|
|
1379
|
+
getReport: defineRequest<Report>()({ method: 'GET', path: '/report', timeout: 3000 }),
|
|
1380
|
+
},
|
|
1381
|
+
})
|
|
1029
1382
|
```
|
|
1030
1383
|
|
|
1031
|
-
|
|
1384
|
+
With `timeout: 3000`, the caller gets an answer within three seconds however many retries are left. See [Set a deadline with timeout](#set-a-deadline-with-timeout).
|
|
1032
1385
|
|
|
1033
|
-
|
|
1386
|
+
### Give each attempt its own timeout
|
|
1034
1387
|
|
|
1388
|
+
liaise's `timeout` is one deadline for the whole call. If you want a limit per attempt instead, put a middleware *inside* the retry. `getUser` is the endpoint from [Quick start](#quick-start).
|
|
1389
|
+
|
|
1390
|
+
<!-- tested: per-attempt-timeout -->
|
|
1035
1391
|
```ts
|
|
1392
|
+
import { createApi } from 'liaise'
|
|
1393
|
+
import type { Middleware } from 'liaise'
|
|
1394
|
+
import { retryMiddleware } from 'liaise/middleware'
|
|
1395
|
+
|
|
1396
|
+
// A fresh time limit for each attempt, instead of one for the whole call.
|
|
1036
1397
|
const perAttempt = (ms: number): Middleware => async (ctx, next) => {
|
|
1037
1398
|
ctx.request.signal = AbortSignal.timeout(ms)
|
|
1038
1399
|
return next()
|
|
@@ -1041,385 +1402,539 @@ const perAttempt = (ms: number): Middleware => async (ctx, next) => {
|
|
|
1041
1402
|
const api = createApi({
|
|
1042
1403
|
baseUrl: '/api',
|
|
1043
1404
|
requests: { getUser },
|
|
1044
|
-
|
|
1405
|
+
// Order matters: retry wraps perAttempt, so every attempt gets its own 5 s.
|
|
1406
|
+
middleware: [
|
|
1407
|
+
retryMiddleware({ retryOn: r => r.error?.kind === 'timeout' || (r.error?.status ?? 0) >= 500 }),
|
|
1408
|
+
perAttempt(5_000),
|
|
1409
|
+
],
|
|
1045
1410
|
})
|
|
1046
1411
|
```
|
|
1047
1412
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
A few more details:
|
|
1413
|
+
Timeouts aren't retried by default; the `retryOn` above opts in. Because the middleware replaces `ctx.request.signal`, a caller's own cancel still ends the call as `'abort'`, but the request itself keeps running until the per-attempt signal fires.
|
|
1051
1414
|
|
|
1052
|
-
|
|
1053
|
-
- A timeout produces an error with `status: 0` and `kind: 'timeout'` — distinguishable from a caller-initiated cancellation (`kind: 'abort'`) and from a genuine network failure (`kind: 'network'`).
|
|
1054
|
-
- `result.retry()` always starts a fresh deadline. A retried call is not charged against the original budget.
|
|
1055
|
-
- Non-positive or omitted `timeout` disables it entirely (the default).
|
|
1056
|
-
- `timeout` composes with `dedupe: true` — the deadline is merged with the dedupe signal rather than discarded by it.
|
|
1057
|
-
- Under `share: true` the two timeouts have different owners. `RequestConfig.timeout` belongs to the *operation*: it bounds the one shared request for every caller, measured from when that request started, so a single caller can neither extend it nor disable it with a per-call `timeout: 0`. `CallOptions.timeout` bounds only the caller that passed it — see [Sharing](#sharing).
|
|
1415
|
+
### Report errors to Sentry
|
|
1058
1416
|
|
|
1059
|
-
|
|
1417
|
+
Send every unexpected failure to your error tracker from one place. `Sentry` stands for your error tracker; `getUser` is the [Quick start](#quick-start) endpoint.
|
|
1060
1418
|
|
|
1419
|
+
<!-- tested: report-errors -->
|
|
1061
1420
|
```ts
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
}
|
|
1067
|
-
//
|
|
1421
|
+
import { createApi } from 'liaise'
|
|
1422
|
+
|
|
1423
|
+
const api = createApi({
|
|
1424
|
+
baseUrl: '/api',
|
|
1425
|
+
requests: { getUser },
|
|
1426
|
+
// Called once per failed call, after retries. Never for 'abort': you cancelled it.
|
|
1427
|
+
onError: error => {
|
|
1428
|
+
if (error.kind === 'http' && error.status < 500) return // expected 4xx, not a bug
|
|
1429
|
+
Sentry.captureException(error, {
|
|
1430
|
+
extra: { kind: error.kind, url: error.request.url, status: error.status },
|
|
1431
|
+
})
|
|
1432
|
+
},
|
|
1433
|
+
})
|
|
1068
1434
|
```
|
|
1069
1435
|
|
|
1070
|
-
|
|
1436
|
+
`ApiError` isn't an `Error`, so it carries no stack trace or `message` of its own. Pass the fields you want to see in `extra`, as above.
|
|
1071
1437
|
|
|
1072
|
-
|
|
1438
|
+
Use a middleware instead when you need timing, or the request before it's sent, or want to report for some endpoints only. [Example: report server errors](#example-report-server-errors) has the middleware version.
|
|
1073
1439
|
|
|
1074
|
-
###
|
|
1440
|
+
### Upload and download files
|
|
1075
1441
|
|
|
1076
|
-
|
|
1442
|
+
Send a file with `FormData` and read one back as a `Blob`.
|
|
1077
1443
|
|
|
1444
|
+
<!-- tested: files -->
|
|
1078
1445
|
```ts
|
|
1079
|
-
|
|
1446
|
+
import { createApi, defineRequest } from 'liaise'
|
|
1447
|
+
|
|
1448
|
+
// Upload: pass FormData as the params. liaise sends it as-is, and the
|
|
1449
|
+
// runtime sets the multipart Content-Type with its boundary.
|
|
1450
|
+
const uploadAvatar = defineRequest<{ url: string }, FormData>()({
|
|
1451
|
+
method: 'POST',
|
|
1452
|
+
path: '/avatar',
|
|
1453
|
+
})
|
|
1454
|
+
|
|
1455
|
+
// Download: ask for a Blob instead of JSON.
|
|
1456
|
+
const downloadFile = defineRequest<Blob>()({
|
|
1080
1457
|
method: 'GET',
|
|
1081
|
-
path: '/
|
|
1082
|
-
|
|
1458
|
+
path: '/files/:id',
|
|
1459
|
+
responseType: 'blob',
|
|
1083
1460
|
})
|
|
1084
1461
|
|
|
1085
|
-
|
|
1086
|
-
const [a, b] = await Promise.all([
|
|
1087
|
-
api.getProduct({ id: '42' }),
|
|
1088
|
-
api.getProduct({ id: '42' })
|
|
1089
|
-
])
|
|
1462
|
+
const api = createApi({ baseUrl: '/api', requests: { uploadAvatar, downloadFile } })
|
|
1090
1463
|
```
|
|
1091
1464
|
|
|
1092
|
-
|
|
1465
|
+
Call it with `api.uploadAvatar(form)` and `api.downloadFile({ id })`. Other body types (a `Blob`, a `ReadableStream`) are listed under [Sending data](#sending-data).
|
|
1093
1466
|
|
|
1094
|
-
|
|
1467
|
+
## Choosing liaise
|
|
1095
1468
|
|
|
1096
|
-
|
|
1469
|
+
This section helps you decide whether liaise fits your project. It covers when to pick something else, how liaise compares with other fetch clients, and where it runs.
|
|
1097
1470
|
|
|
1098
|
-
|
|
1099
|
-
- Params that cannot be keyed soundly, at any depth: a BigInt, an `ArrayBuffer`, `Blob`, `FormData` or `URLSearchParams`, a circular structure, or an object with no enumerable state (a class instance keeping its state in private fields, an `Error`). Two different values of these kinds would otherwise risk one key, and one caller could receive the response meant for the other's payload. Declining to share is always safe; handing back the wrong response never is. A `Date`, `Map`, `Set` or typed array is keyed by its content and shares normally, and a raw `string` keys distinguishably, so a string-param endpoint is coalesced like any other.
|
|
1471
|
+
### When it fits, and when it doesn't
|
|
1100
1472
|
|
|
1101
|
-
|
|
1473
|
+
Need a normalized cache (update one user, and every screen showing that user updates), optimistic updates or subscriptions? Use Apollo or urql. Apollo's [caching overview](https://www.apollographql.com/docs/react/caching/overview) and urql's [Graphcache docs](https://nearform.com/open-source/urql/docs/graphcache/) explain how each one caches. [Why another API client?](#why-another-api-client) covers the trade-off.
|
|
1102
1474
|
|
|
1103
|
-
|
|
1475
|
+
For UI caching and refetching, use TanStack Query *with* liaise; see the [recipe](#use-with-tanstack-query).
|
|
1104
1476
|
|
|
1105
|
-
|
|
1106
|
-
const impatient = api.getProduct({ id: '42' }, { timeout: 20 }) // gives up quickly
|
|
1107
|
-
const patient = api.getProduct({ id: '42' }) // keeps waiting
|
|
1477
|
+
For two or three calls, plain fetch is fine.
|
|
1108
1478
|
|
|
1109
|
-
|
|
1110
|
-
// patient still gets a real response.
|
|
1111
|
-
```
|
|
1479
|
+
liaise is a good fit when you have:
|
|
1112
1480
|
|
|
1113
|
-
|
|
1481
|
+
- **Many endpoints with one auth setup.** Each endpoint is one [`defineRequest`](#defining-endpoints), and one [auth middleware](#add-an-auth-header-and-refresh-the-token-on-a-401) covers them all.
|
|
1482
|
+
- **Failure handling that matters**, such as a checkout or a form. Every failure comes back as a value with a [kind you can switch on](#handling-errors).
|
|
1483
|
+
- **One API layer shared across frameworks, servers and scripts.** See [Where it runs](#where-it-runs).
|
|
1114
1484
|
|
|
1115
|
-
|
|
1485
|
+
### How it compares
|
|
1116
1486
|
|
|
1117
|
-
|
|
1487
|
+
[`compare/`](https://github.com/iremlopsum/liaise/blob/main/compare) runs fetch, axios, ky, ofetch and liaise through ten failure scenarios against a local server, and records what the calling code gets back. [compare/README.md](https://github.com/iremlopsum/liaise/blob/main/compare/README.md) explains the fairness rules. If you maintain one of these libraries and think its setup is unfair, a pull request is welcome.
|
|
1118
1488
|
|
|
1119
|
-
|
|
1489
|
+
<!-- compare:start -->
|
|
1120
1490
|
|
|
1121
|
-
|
|
1122
|
-
// 1. Types are declared on the Request
|
|
1123
|
-
const getUser = new Request<{ id: string }, User>({
|
|
1124
|
-
method: 'GET',
|
|
1125
|
-
path: '/users/:id'
|
|
1126
|
-
})
|
|
1491
|
+
Measured on 4 October 2026 against axios 1.20.0, ky 2.1.0 and ofetch 1.5.1. Other libraries change. Rerun it with `npm run build` in the repo root, then `npm install && npm run compare` in `compare/`. Run in Node 22.18.0 against a local server. In browsers axios uses XHR, so its results there can differ.
|
|
1127
1492
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1493
|
+
| Scenario | fetch | axios | ky | ofetch | liaise |
|
|
1494
|
+
| --- | :-- | :-- | :-- | :-- | :-- |
|
|
1495
|
+
| Server answers 500 | throws Error* | throws AxiosError | throws HTTPError | throws FetchError | error result (http) |
|
|
1496
|
+
| Server unreachable | throws TypeError* | throws AxiosError (name: Error) | throws NetworkError | throws FetchError | error result (network) |
|
|
1497
|
+
| Server never answers | throws TimeoutError, 3002 ms* | throws AxiosError, 3005 ms | throws TimeoutError, 3005 ms | throws FetchError, 3003 ms | error result (timeout), 3003 ms |
|
|
1498
|
+
| 200 with broken JSON | throws SyntaxError* | throws AxiosError (name: SyntaxError) | throws SyntaxError | throws SyntaxError | error result (parse) |
|
|
1499
|
+
| 204 with no body, on a JSON call | resolves with undefined* | resolves with "" | resolves with undefined | resolves with undefined | resolves with undefined |
|
|
1500
|
+
| Search as you type: which results stay on screen | shows "rea"* | shows "rea"* | shows "rea"* | shows "rea"* | shows "rea" |
|
|
1501
|
+
| Five requests get a 401 at once | 1 refresh call, 5/5 succeed* | 1 refresh call, 5/5 succeed* | 1 refresh call, 5/5 succeed* | 1 refresh call, 5/5 succeed* | 1 refresh call, 5/5 succeed* |
|
|
1502
|
+
| Slow 503s, 3 s deadline, 3 retries: when does the caller hear back | after 3.0s: throws TimeoutError, 3 attempts* | after 3.0s: throws CanceledError, 3 attempts* | after 3.0s: throws TimeoutError, 3 attempts | after 3.0s: throws FetchError, 3 attempts* | after 3.0s: error result (timeout), 3 attempts |
|
|
1503
|
+
| Path param is undefined | requests /s/users/undefined | requests /s/users/undefined | requests /s/users/undefined | requests /s/users/undefined | refused before sending: error result (network) |
|
|
1504
|
+
| Response is missing a field the type promises | — | — | throws SchemaValidationError | — | error result (parse) |
|
|
1133
1505
|
|
|
1134
|
-
|
|
1135
|
-
const { data, error } = await api.getUser({ id: '42' })
|
|
1136
|
-
// ^? User | null
|
|
1137
|
-
```
|
|
1506
|
+
\* needed hand-written code, described in the [notes](https://github.com/iremlopsum/liaise/blob/main/compare/results.md#notes). — means the library has no built-in option.
|
|
1138
1507
|
|
|
1139
|
-
|
|
1508
|
+
Out of the box, liaise has no timeout (only ky has one by default) and treats a 204 on a JSON call as a parse error. See Table B in [compare/results.md](https://github.com/iremlopsum/liaise/blob/main/compare/results.md).
|
|
1140
1509
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1510
|
+
| Library | gzip (kB) | brotli (kB) |
|
|
1511
|
+
| --- | :-- | :-- |
|
|
1512
|
+
| fetch | 0.1 | 0.1 |
|
|
1513
|
+
| axios | 19.1 | 17.3 |
|
|
1514
|
+
| ky | 9.6 | 8.5 |
|
|
1515
|
+
| ofetch | 4.0 | 3.6 |
|
|
1516
|
+
| liaise | 5.8 | 5.2 |
|
|
1517
|
+
| liaise + retryMiddleware | 6.3 | 5.7 |
|
|
1145
1518
|
|
|
1146
|
-
|
|
1519
|
+
fetch is built into the runtime; its row is the call site only, the floor rather than a library.
|
|
1147
1520
|
|
|
1148
|
-
|
|
1149
|
-
import type {
|
|
1150
|
-
Result,
|
|
1151
|
-
CallOptions,
|
|
1152
|
-
Middleware,
|
|
1153
|
-
MiddlewareContext,
|
|
1154
|
-
MiddlewareNext,
|
|
1155
|
-
RequestConfig,
|
|
1156
|
-
ApiConfig
|
|
1157
|
-
} from 'liaise'
|
|
1158
|
-
```
|
|
1521
|
+
Request overhead on localhost, sequential (median requests per second): fetch 16,797, axios 13,691, ky 13,742, ofetch 16,230, liaise 16,414.
|
|
1159
1522
|
|
|
1160
|
-
|
|
1523
|
+
Out-of-the-box results, request overhead in full and the notes: [compare/results.md](https://github.com/iremlopsum/liaise/blob/main/compare/results.md).
|
|
1161
1524
|
|
|
1162
|
-
|
|
1525
|
+
<!-- compare:end -->
|
|
1163
1526
|
|
|
1164
|
-
|
|
1527
|
+
With enough of your own code, every library gets the right result in almost every row. The difference is how much you write. Counting the cells marked `*`, fetch needs 8, axios 3, ofetch 3, ky 2 and liaise 1. liaise needs code only for the token refresh, and returns each failure as a value instead of throwing. It is the only one that refuses an undefined path param before sending the request.
|
|
1165
1528
|
|
|
1166
|
-
|
|
1167
|
-
import { createGraphQL, Operation, gql } from 'liaise'
|
|
1529
|
+
ky is the closest alternative. Apart from throwing instead of returning errors, it differs from liaise in two rows of the table. Its search as you type needs code, and it sends the undefined path param. Out of the box, ky is the only one that times out, and it retries, as ofetch does. In size, liaise is larger than ofetch and smaller than ky and axios.
|
|
1168
1530
|
|
|
1169
|
-
|
|
1170
|
-
id: string
|
|
1171
|
-
name: string
|
|
1172
|
-
status: string
|
|
1173
|
-
}
|
|
1531
|
+
In request overhead, liaise ties fetch and ofetch. axios and ky handle about 16% fewer requests per second than liaise. Overhead is measured in microseconds; on a real network each request takes milliseconds.
|
|
1174
1532
|
|
|
1175
|
-
|
|
1176
|
-
query GetCategory($id: String!) {
|
|
1177
|
-
category(id: $id) {
|
|
1178
|
-
id
|
|
1179
|
-
name
|
|
1180
|
-
status
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
`
|
|
1533
|
+
### Where it runs
|
|
1184
1534
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1535
|
+
| Runtime | Status |
|
|
1536
|
+
| ------- | ------ |
|
|
1537
|
+
| Node 20, 22, 24 | Tested in CI |
|
|
1538
|
+
| Browsers, Bun, Deno, Cloudflare Workers | Should work (standard `fetch`), not tested in CI |
|
|
1539
|
+
| React Native | Uses its built-in `fetch`, not tested in CI |
|
|
1188
1540
|
|
|
1189
|
-
|
|
1190
|
-
endpoint: 'https://api.example.com/graphql',
|
|
1191
|
-
operations: { getCategory },
|
|
1192
|
-
onError: (error) => console.error(error.status, error.body),
|
|
1193
|
-
})
|
|
1541
|
+
On React Native, where `AbortSignal.timeout` is missing, `timeout` falls back to a timer. If the runtime drops abort reasons, a timeout may report as `'abort'` instead of `'timeout'`.
|
|
1194
1542
|
|
|
1195
|
-
|
|
1196
|
-
```
|
|
1543
|
+
No hooks, no framework code: a client is a plain object of functions returning promises. It works in React, Vue, Svelte, Solid, Angular, server loaders, workers and scripts. The [recipes](#recipes) show it with TanStack Query, React and a store.
|
|
1197
1544
|
|
|
1198
|
-
|
|
1545
|
+
## Design principles
|
|
1199
1546
|
|
|
1200
|
-
|
|
1201
|
-
const getViewer = new Operation<Record<string, never>, ViewerData>({ operation: GET_VIEWER })
|
|
1202
|
-
const graphql = createGraphQL({ endpoint, operations: { getViewer } })
|
|
1547
|
+
### Never throws
|
|
1203
1548
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1549
|
+
A thrown error doesn't show up in a function's type, so nothing reminds you to catch it, and one missed `try` breaks the page. A returned error is part of the type. TypeScript makes you check `error` before you can read `data`, and every failure arrives in the same shape.
|
|
1550
|
+
|
|
1551
|
+
### Zero dependencies
|
|
1552
|
+
|
|
1553
|
+
Every dependency of liaise would also be a dependency of your app, with more to download, audit and update. liaise uses only what the runtime already has: `fetch`, `Headers`, `AbortController` and the other web types. Validators plug in through Standard Schema, which is only an interface, so schema support adds no package either. The size of each entry point is under [Exports](#exports).
|
|
1554
|
+
|
|
1555
|
+
### Middleware over interceptors
|
|
1556
|
+
|
|
1557
|
+
Separate request and response interceptors split one job in two. State both halves need rides on the request config, and resending means calling the client again from inside a hook. A middleware wraps the whole call, so one function can set a header, read the result, retry, or answer from a cache. The built-in retry, cache and log are ordinary middleware, so anything they do, yours can do too.
|
|
1558
|
+
|
|
1559
|
+
### Types by inference
|
|
1560
|
+
|
|
1561
|
+
Types you write at each call site drift away from the endpoint they describe. In liaise you write the types once, on the endpoint, and the path supplies the path params. `createApi` carries them to every call, so a renamed param or a changed response is a compile error where it is used.
|
|
1562
|
+
|
|
1563
|
+
### Any runtime
|
|
1564
|
+
|
|
1565
|
+
liaise needs only `fetch` and the standard web types, so one client works in a browser, on a server, in a worker or in a script. Code that moves between them needs no second client and no adapter. [Where it runs](#where-it-runs) lists what CI tests.
|
|
1566
|
+
|
|
1567
|
+
### Any framework
|
|
1568
|
+
|
|
1569
|
+
A client is a plain object of functions that return promises. That fits every UI framework, and code with no framework at all, and there is nothing to rewrite when you switch. Query libraries expect a failed call to throw, so you convert at that one edge, as the [TanStack Query recipe](#use-with-tanstack-query) does.
|
|
1570
|
+
|
|
1571
|
+
## Reference
|
|
1206
1572
|
|
|
1207
|
-
|
|
1573
|
+
Every option, type and export, read from the source. The guide explains when to use each one.
|
|
1208
1574
|
|
|
1209
|
-
|
|
1575
|
+
### createApi options
|
|
1576
|
+
|
|
1577
|
+
`createApi(config)` takes an `ApiConfig`.
|
|
1578
|
+
|
|
1579
|
+
| Option | Type | Default | What it does |
|
|
1580
|
+
| ------ | ---- | ------- | ------------ |
|
|
1581
|
+
| `baseUrl` | `string` | required | Goes in front of every endpoint's path. It may carry a query string ([details](#baseurl-query-merging)). |
|
|
1582
|
+
| `requests` | an object of endpoints | required | Each key becomes a method on the client, such as `api.getUser`. |
|
|
1583
|
+
| `middleware` | `Middleware[]` | — | Runs on every call, before endpoint and call middleware. |
|
|
1584
|
+
| `headers` | `HeadersInit` | — | Sent with every call. An endpoint or a call can replace a header ([three levels](#three-levels-of-settings)). |
|
|
1585
|
+
| `onError` | `(error: ApiError) => void` | — | Called once per failed call, after all middleware. Never for `'abort'` ([details](#reporting-errors-with-onerror)). |
|
|
1586
|
+
|
|
1587
|
+
### createGraphQL options
|
|
1588
|
+
|
|
1589
|
+
`createGraphQL(config)` takes a `GraphQLBaseConfig`, plus either `operations` or `queries` and `mutations` ([Queries and mutations](#queries-and-mutations)).
|
|
1590
|
+
|
|
1591
|
+
| Option | Type | Default | What it does |
|
|
1592
|
+
| ------ | ---- | ------- | ------------ |
|
|
1593
|
+
| `endpoint` | `string` | required | The full URL of the GraphQL endpoint. |
|
|
1594
|
+
| `middleware` | `Middleware[]` | — | Runs on every operation, before operation and call middleware. |
|
|
1595
|
+
| `headers` | `HeadersInit` | — | Sent with every operation. An operation or a call can replace a header. |
|
|
1596
|
+
| `onError` | `(error: ApiError) => void` | — | Called once per failed call, GraphQL errors included. Never for `'abort'`. |
|
|
1597
|
+
|
|
1598
|
+
### Endpoint options
|
|
1599
|
+
|
|
1600
|
+
`defineRequest<T>()(config)` and `new Request(config)` take a `RequestConfig`.
|
|
1601
|
+
|
|
1602
|
+
| Option | Type | Default | What it does |
|
|
1603
|
+
| ------ | ---- | ------- | ------------ |
|
|
1604
|
+
| `method` | `'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'` | required | The HTTP method. |
|
|
1605
|
+
| `path` | `string` | required | The path after `baseUrl`. Each `:name` is filled from the params ([Defining endpoints](#defining-endpoints)). |
|
|
1606
|
+
| `middleware` | `Middleware[]` | — | Runs on every call to this endpoint, after client middleware. |
|
|
1607
|
+
| `headers` | `HeadersInit` | — | Sent with every call to this endpoint. Replaces the client's value for the same header. |
|
|
1608
|
+
| `responseType` | `'json' \| 'text' \| 'blob' \| 'arrayBuffer' \| 'formData' \| 'none'` | `'json'` | How the response body is read ([Reading responses](#reading-responses)). |
|
|
1609
|
+
| `schema` | `StandardSchemaV1` | — | Checks a 2xx body, and `data` becomes the schema's output ([Validating responses](#validating-responses)). |
|
|
1610
|
+
| `dedupe` | `boolean` | `false` | A new call cancels the one still running ([details](#drop-stale-calls-with-dedupe)). |
|
|
1611
|
+
| `share` | `boolean` | `false` | Identical calls join one request ([details](#sharing-identical-requests)). Can't be combined with `dedupe`. |
|
|
1612
|
+
| `bodyAs` | `'query' \| 'body'` | `'query'` for `GET` and `DELETE`, `'body'` for the rest | Where the params that aren't in the path go. |
|
|
1613
|
+
| `timeout` | `number` (ms) | no deadline | One deadline for the whole call, retries included ([details](#set-a-deadline-with-timeout)). |
|
|
1614
|
+
|
|
1615
|
+
### Operation options
|
|
1616
|
+
|
|
1617
|
+
`new Operation(config)` takes an `OperationConfig`.
|
|
1618
|
+
|
|
1619
|
+
| Option | Type | Default | What it does |
|
|
1620
|
+
| ------ | ---- | ------- | ------------ |
|
|
1621
|
+
| `operation` | `string` | required | The GraphQL document, sent as `query` in the body. |
|
|
1622
|
+
| `middleware` | `Middleware[]` | — | Runs on every call to this operation, after client middleware. |
|
|
1623
|
+
| `headers` | `HeadersInit` | — | Sent with every call to this operation. Replaces the client's value for the same header. |
|
|
1624
|
+
| `dedupe` | `boolean` | `false` | A new call cancels the one still running. |
|
|
1625
|
+
| `schema` | `StandardSchemaV1` | — | Checks the response's `data`. The response type stays explicit ([Validating responses](#validating-responses)). |
|
|
1626
|
+
| `timeout` | `number` (ms) | no deadline | One deadline for the whole call, retries included. |
|
|
1627
|
+
|
|
1628
|
+
### CallOptions
|
|
1629
|
+
|
|
1630
|
+
The second argument of every call, as in `api.getUser(params, options)`. [`paginate`](#pagination) takes the same options and applies them to every page.
|
|
1631
|
+
|
|
1632
|
+
| Option | Type | Default | What it does |
|
|
1633
|
+
| ------ | ---- | ------- | ------------ |
|
|
1634
|
+
| `middleware` | `Middleware[]` | — | Runs after the client's and the endpoint's middleware. Turns off `share` for this call. |
|
|
1635
|
+
| `skipMiddleware` | `Middleware[]` | — | Middleware to leave out of this call, matched by reference ([details](#skipping-a-middleware-for-one-call)). |
|
|
1636
|
+
| `headers` | `HeadersInit` | — | Replaces the client's and the endpoint's value for the same header. Turns off `share` for this call. |
|
|
1637
|
+
| `signal` | `AbortSignal` | — | Cancels the call, which ends with `kind: 'abort'` ([details](#cancel-with-a-signal)). |
|
|
1638
|
+
| `timeout` | `number` (ms) | the endpoint's `timeout` | Replaces the endpoint's deadline for this call. `0` turns it off. Under `share`, it bounds only this caller's wait ([details](#sharing-identical-requests)). |
|
|
1639
|
+
|
|
1640
|
+
A fractional `timeout` is rounded down to whole milliseconds, with a minimum of 1 ms. A value above the timer limit of 2³¹ − 1 ms is capped there. Zero or a negative number means no deadline.
|
|
1641
|
+
|
|
1642
|
+
### Result and ApiError
|
|
1643
|
+
|
|
1644
|
+
Every call returns a `Result`. `data` and `error` are never both set, so checking `error` narrows `data`.
|
|
1210
1645
|
|
|
1211
1646
|
```ts
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
updateCategory: new Operation<{ id: string; name: string }, Category>({
|
|
1219
|
-
operation: gql`
|
|
1220
|
-
mutation UpdateCategory($id: String!, $name: String!) {
|
|
1221
|
-
updateCategory(id: $id, name: $name) { id name status }
|
|
1222
|
-
}
|
|
1223
|
-
`,
|
|
1224
|
-
}),
|
|
1225
|
-
},
|
|
1226
|
-
})
|
|
1647
|
+
interface SuccessResult<TResponse> {
|
|
1648
|
+
data: TResponse
|
|
1649
|
+
error: null
|
|
1650
|
+
response: Response // always present on success
|
|
1651
|
+
retry: () => Promise<Result<TResponse>>
|
|
1652
|
+
}
|
|
1227
1653
|
|
|
1228
|
-
|
|
1229
|
-
|
|
1654
|
+
interface ErrorResult<TResponse> {
|
|
1655
|
+
data: null
|
|
1656
|
+
error: ApiError
|
|
1657
|
+
response: Response | null // null when no response arrived
|
|
1658
|
+
retry: () => Promise<Result<TResponse>>
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
type Result<TResponse> = SuccessResult<TResponse> | ErrorResult<TResponse>
|
|
1230
1662
|
```
|
|
1231
1663
|
|
|
1232
|
-
|
|
1664
|
+
`response` is set for `'http'` and `'parse'` errors, where the server answered. It is `null` for `'network'`, `'abort'`, `'timeout'` and `'middleware'`.
|
|
1233
1665
|
|
|
1234
|
-
|
|
1666
|
+
`ApiError` is the `error` of a failed call. It is a plain class and doesn't extend `Error`.
|
|
1235
1667
|
|
|
1236
|
-
|
|
1668
|
+
| Property | Type | What it holds |
|
|
1669
|
+
| -------- | ---- | ------------- |
|
|
1670
|
+
| `kind` | `ApiErrorKind` | What went wrong. The [kinds table](#handling-errors) says what each one means. If you build an `ApiError` yourself, for example in a middleware, `kind` is required. |
|
|
1671
|
+
| `status` | `number` | The response's status for `'http'` and `'parse'`. `0` for `'network'`, `'abort'`, `'timeout'` and `'middleware'`. |
|
|
1672
|
+
| `statusText` | `string` | The response's status text, `'GraphQL Error'` for a GraphQL error, and `''` when no response arrived. |
|
|
1673
|
+
| `body` | `unknown` | For `'http'`, the error body, or `null` when it is empty or doesn't parse. For `'parse'`, what the [Validating responses](#validating-responses) and [Reading responses](#reading-responses) rules say. Otherwise, the thrown value. |
|
|
1674
|
+
| `headers` | `Headers` | The response headers. Empty when no response arrived. |
|
|
1675
|
+
| `request` | `{ method, url, params }` | The failed request. `url` is the address with the params filled in. It is the path template only when the URL couldn't be built. |
|
|
1676
|
+
| `partialData` | `unknown` (optional) | For a GraphQL error, the data the server sent with the errors. `undefined` for every REST error. |
|
|
1237
1677
|
|
|
1238
1678
|
```ts
|
|
1239
|
-
|
|
1240
|
-
if (error) {
|
|
1241
|
-
console.log(error.body) // GraphQLError[]
|
|
1242
|
-
console.log(error.partialData) // whatever `data` the server sent alongside the errors, or undefined
|
|
1243
|
-
}
|
|
1679
|
+
type ApiErrorKind = 'http' | 'network' | 'abort' | 'timeout' | 'parse' | 'middleware'
|
|
1244
1680
|
```
|
|
1245
1681
|
|
|
1246
|
-
|
|
1682
|
+
You can check for an `ApiError` with `instanceof`:
|
|
1247
1683
|
|
|
1248
1684
|
```ts
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
console.
|
|
1685
|
+
import { ApiError } from 'liaise'
|
|
1686
|
+
|
|
1687
|
+
if (error instanceof ApiError) {
|
|
1688
|
+
console.error(error.kind, error.status)
|
|
1253
1689
|
}
|
|
1254
1690
|
```
|
|
1255
1691
|
|
|
1256
|
-
|
|
1692
|
+
### MiddlewareContext
|
|
1257
1693
|
|
|
1258
|
-
|
|
1694
|
+
The first argument of every middleware, `ctx`:
|
|
1259
1695
|
|
|
1260
|
-
|
|
1696
|
+
| Property | Type | What it holds |
|
|
1697
|
+
| -------- | ---- | ------------- |
|
|
1698
|
+
| `request.method` | `string` | The HTTP method, such as `'GET'`. |
|
|
1699
|
+
| `request.url` | `string` | The full URL, with path params and query string filled in. |
|
|
1700
|
+
| `request.path` | `string` | The path template, such as `'/users/:id'`. |
|
|
1701
|
+
| `request.params` | `unknown` | The params as the caller passed them. |
|
|
1702
|
+
| `request.headers` | `Headers` | The merged headers. A middleware can add, change or remove them. |
|
|
1703
|
+
| `request.body` | `unknown` | The serialized body, or `null` when there is none. |
|
|
1704
|
+
| `request.signal` | `AbortSignal \| undefined` | The signal `fetch` receives. A middleware can replace it ([Signals in middleware](#signals-in-middleware)). |
|
|
1705
|
+
| `requestName` | `string` | The endpoint's key, such as `'getUser'`. |
|
|
1261
1706
|
|
|
1262
|
-
|
|
1707
|
+
How a replaced signal works with `share` and `dedupe` is under [Signal-replacing middleware](#signal-replacing-middleware).
|
|
1263
1708
|
|
|
1264
|
-
|
|
1265
|
-
const graphql = createGraphQL({
|
|
1266
|
-
endpoint: 'https://api.example.com/graphql',
|
|
1267
|
-
operations: { getCategory },
|
|
1268
|
-
middleware: [authMiddleware],
|
|
1269
|
-
})
|
|
1270
|
-
```
|
|
1709
|
+
### Built-in middleware options
|
|
1271
1710
|
|
|
1272
|
-
|
|
1711
|
+
#### RetryOptions
|
|
1273
1712
|
|
|
1274
|
-
|
|
1275
|
-
| ------------------- | -------- | ---------------------------------------------------------------------- |
|
|
1276
|
-
| `createGraphQL` | function | Creates a typed GraphQL client from a config of Operation definitions |
|
|
1277
|
-
| `Operation` | class | Typed operation definition -- one instance per GraphQL operation |
|
|
1278
|
-
| `gql` | const | Tagged template literal for GraphQL documents (editor tooling support) |
|
|
1279
|
-
| `OperationConfig` | type | Config object for the `Operation` constructor |
|
|
1280
|
-
| `GraphQLBaseConfig` | type | Config object for `createGraphQL` |
|
|
1281
|
-
| `GraphQLError` | type | Shape of a single GraphQL error from `{ errors: [...] }` |
|
|
1713
|
+
`retryMiddleware(n)` is short for `retryMiddleware({ max: n })`, and `retryMiddleware()` means `{ max: 3 }`.
|
|
1282
1714
|
|
|
1283
|
-
|
|
1715
|
+
| Option | Type | Default | What it does |
|
|
1716
|
+
| ------ | ---- | ------- | ------------ |
|
|
1717
|
+
| `max` | `number` | `3` | Retries after the first attempt. `max: 2` means up to 3 calls in all. |
|
|
1718
|
+
| `delay` | `'exponential' \| 'linear' \| (attempt: number) => number` | `'exponential'` | The wait before each retry. Exponential is `baseDelay * 2^(attempt-1)` and linear is `baseDelay * attempt`. A function gets the 1-based attempt and returns milliseconds. |
|
|
1719
|
+
| `baseDelay` | `number` (ms) | `250` | The first wait, before jitter and `Retry-After`. |
|
|
1720
|
+
| `maxDelay` | `number` (ms) | `30000` | The longest any wait can be, a `Retry-After` value included. |
|
|
1721
|
+
| `jitter` | `boolean` | `true` | Waits a random time between 0 and the computed delay, so many clients don't retry at the same moment. Never applied to a `Retry-After` value. |
|
|
1722
|
+
| `respectRetryAfter` | `boolean` | `true` | Uses the server's `Retry-After` header, in seconds or as a date, in place of the computed wait. `maxDelay` still caps it. |
|
|
1723
|
+
| `retryOn` | `(result: Result<unknown>, attempt: number) => boolean` | `r => (r.error?.status ?? 0) >= 500` | Whether to retry. It gets the 1-based number of the attempt it would start, and is called even once `max` is reached. |
|
|
1724
|
+
| `onRetry` | `(info: RetryInfo) => void` | — | Called before each wait. Its return value is ignored, and if it throws, the call goes on. |
|
|
1284
1725
|
|
|
1285
|
-
`
|
|
1726
|
+
`RetryInfo`, the argument to `onRetry`:
|
|
1286
1727
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1728
|
+
| Field | What it holds |
|
|
1729
|
+
| ----- | ------------- |
|
|
1730
|
+
| `attempt` | The retry about to run, counting from 1. |
|
|
1731
|
+
| `max` | The configured `max`. |
|
|
1732
|
+
| `delay` | The wait about to start, in milliseconds, after jitter and `Retry-After`. |
|
|
1733
|
+
| `result` | The `Result` that caused this retry. |
|
|
1289
1734
|
|
|
1290
|
-
|
|
1291
|
-
'GET /api/users/:id': ({ params }) => jsonResponse({ id: params.id, name: 'Ada' }),
|
|
1292
|
-
'POST /api/users': jsonResponse({ id: 'new-user' }, { status: 201 }),
|
|
1293
|
-
})
|
|
1735
|
+
#### Cache options
|
|
1294
1736
|
|
|
1295
|
-
|
|
1296
|
-
// ... exercise your code, which calls the real api.getUser(...) ...
|
|
1297
|
-
mock.restore() // puts the original globalThis.fetch back
|
|
1298
|
-
```
|
|
1737
|
+
`cacheMiddleware(options)` returns a middleware with a `clear()` method, typed `CacheMiddleware`.
|
|
1299
1738
|
|
|
1300
|
-
|
|
1739
|
+
| Option | Type | Default | What it does |
|
|
1740
|
+
| ------ | ---- | ------- | ------------ |
|
|
1741
|
+
| `ttl` | `number` (ms) | `300000` (5 minutes) | How long an entry is served. An expired entry is removed when it is next read. |
|
|
1742
|
+
| `maxSize` | `number` | `50` | How many entries the store keeps. When it is full, the oldest entry is dropped. |
|
|
1743
|
+
| `debug` | `boolean` | `false` | Logs `[liaise cache] HIT` or `MISS`, with the endpoint and its params, to the console. |
|
|
1301
1744
|
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1745
|
+
What goes into the key is under [Cache key](#cache-key).
|
|
1746
|
+
|
|
1747
|
+
#### Log output
|
|
1748
|
+
|
|
1749
|
+
`logMiddleware` logs one line when a call starts and one when it ends:
|
|
1750
|
+
|
|
1751
|
+
```text
|
|
1752
|
+
[liaise] → <method> <endpoint> <url>
|
|
1753
|
+
[liaise] ← <endpoint> OK (<ms>ms)
|
|
1754
|
+
[liaise] ← <endpoint> ERROR <status> (<ms>ms)
|
|
1306
1755
|
```
|
|
1307
1756
|
|
|
1308
|
-
|
|
1757
|
+
The time covers everything that runs inside it: the middleware after it and the request. An error with no response logs status `0`.
|
|
1758
|
+
|
|
1759
|
+
### liaise/testing
|
|
1760
|
+
|
|
1761
|
+
| Export | Kind | What it is |
|
|
1762
|
+
| ------ | ---- | ---------- |
|
|
1763
|
+
| `mockFetch` | function | Builds a `fetch` stub that matches routes, with `install()`, `restore()`, call recording and response sequences. |
|
|
1764
|
+
| `jsonResponse` | function | Builds a `Response` with a JSON body and a `content-type` header. |
|
|
1765
|
+
| `successResult` | function | Builds a success `Result`, for stubbing at the `Result` level. |
|
|
1766
|
+
| `errorResult` | function | Builds an error `Result` with a given HTTP status, for stubbing at the `Result` level. |
|
|
1767
|
+
| `RouteContext` | type | `{ params, request }`, passed to a route function. |
|
|
1768
|
+
| `RouteHandler` | type | `(ctx: RouteContext) => Response \| Promise<Response>`, a route value that builds its response. |
|
|
1769
|
+
| `RouteValue` | type | `Response \| RouteHandler \| Array<Response \| RouteHandler>`, anything a route key can map to. |
|
|
1770
|
+
| `RecordedCall` | type | `{ method, url, headers, body }`, one entry in `mock.calls`. |
|
|
1309
1771
|
|
|
1310
|
-
|
|
1772
|
+
How the stub behaves, beyond [Testing your code](#testing-your-code):
|
|
1773
|
+
|
|
1774
|
+
- **It honours `init.signal`, like real `fetch`.** A signal that is already aborted, or aborts while a route function is still pending, rejects with its `reason`. So a route that never answers, `() => new Promise(() => {})`, lets you test your own `timeout` and cancel handling.
|
|
1775
|
+
- **An aborted call is still recorded** in `calls` and counted by `callCount`, but it doesn't use up a response from a sequence.
|
|
1776
|
+
- **`restore()` puts back whatever `globalThis.fetch` was when `install()` ran.** If it was `undefined`, `restore()` puts back `undefined`.
|
|
1777
|
+
- **A route key without a space**, such as `'/users'`, throws when you call `mockFetch`, and the message names the key.
|
|
1778
|
+
- **Routes are tried in the order you wrote them**, and the first match wins. Put the more specific route first when two could match the same path.
|
|
1779
|
+
- **Trailing and doubled slashes are ignored on both sides.** `/a/b/`, `/a//b` and `/a/b` all match the same route.
|
|
1780
|
+
|
|
1781
|
+
### Exports
|
|
1782
|
+
|
|
1783
|
+
Each entry point is a separate import, and your bundler leaves out what you don't import. Gzipped, as measured by `npm run size`: about 5.8 kB for a REST-only import, 6.9 kB for the whole core entry, and 8.0 kB with all the middleware.
|
|
1784
|
+
|
|
1785
|
+
**`liaise`**
|
|
1786
|
+
|
|
1787
|
+
| Export | Kind | What it is |
|
|
1788
|
+
| ------ | ---- | ---------- |
|
|
1789
|
+
| `createApi` | function | Creates a REST client from your endpoints. |
|
|
1790
|
+
| `defineRequest` | function | Defines an endpoint, with params checked against the path. |
|
|
1791
|
+
| `Request` | class | The endpoint class that `defineRequest` builds ([Without defineRequest](#without-definerequest)). |
|
|
1792
|
+
| `paginate` | function | Walks a paginated endpoint, one `Result` per page. |
|
|
1793
|
+
| `ApiError` | class | The `error` of a failed call. |
|
|
1794
|
+
| `createGraphQL` | function | Creates a GraphQL client from your operations. |
|
|
1795
|
+
| `Operation` | class | Defines one GraphQL operation. |
|
|
1796
|
+
| `gql` | function | Marks a template string as GraphQL for your editor, and returns it as a plain string. |
|
|
1797
|
+
| `Result`, `SuccessResult`, `ErrorResult` | type | What every call returns, and its two branches. |
|
|
1798
|
+
| `ApiErrorKind` | type | The union of `error.kind` values. |
|
|
1799
|
+
| `CallOptions` | type | The second argument of a call. |
|
|
1800
|
+
| `PaginateOptions` | type | `next`, `maxPages`, and any `CallOptions`. |
|
|
1801
|
+
| `ApiConfig`, `RequestConfig` | type | The configs of `createApi` and an endpoint. |
|
|
1802
|
+
| `GraphQLBaseConfig`, `OperationConfig` | type | The configs of `createGraphQL` and an `Operation`. |
|
|
1803
|
+
| `GraphQLError` | type | One entry of a GraphQL `errors` array. |
|
|
1804
|
+
| `Middleware`, `MiddlewareContext`, `MiddlewareNext` | type | A middleware, its `ctx`, and its `next`. |
|
|
1805
|
+
| `StandardSchemaV1`, `InferOutput`, `StandardIssue` | type | The Standard Schema interface, the type a schema produces, and one validation issue. |
|
|
1806
|
+
|
|
1807
|
+
**`liaise/middleware`**
|
|
1808
|
+
|
|
1809
|
+
| Export | Kind | What it is |
|
|
1810
|
+
| ------ | ---- | ---------- |
|
|
1811
|
+
| `retryMiddleware` | function | Returns a middleware that retries failed calls ([RetryOptions](#retryoptions)). |
|
|
1812
|
+
| `cacheMiddleware` | function | Returns a middleware that caches successes in memory ([Cache options](#cache-options)). |
|
|
1813
|
+
| `logMiddleware` | middleware | Logs each call to the console ([Log output](#log-output)). |
|
|
1814
|
+
| `RetryOptions`, `RetryInfo` | type | The options of `retryMiddleware`, and the argument to `onRetry`. |
|
|
1815
|
+
| `CacheMiddleware` | type | What `cacheMiddleware()` returns, a `Middleware` with `clear()`. |
|
|
1816
|
+
|
|
1817
|
+
**`liaise/testing`** exports are listed under [liaise/testing](#liaisetesting).
|
|
1818
|
+
|
|
1819
|
+
### Behaviour in detail
|
|
1820
|
+
|
|
1821
|
+
Edge cases the guide links to.
|
|
1822
|
+
|
|
1823
|
+
#### Cache key
|
|
1824
|
+
|
|
1825
|
+
`cacheMiddleware` keys each entry on the endpoint name, the method, the full URL, the params and every request header except `Content-Type`. A different `Authorization`, base URL or query value gets its own entry.
|
|
1826
|
+
|
|
1827
|
+
The query string is sorted by name before it goes in the key, so `?a=1&b=2` and `?b=2&a=1` are one entry. It is sorted by the raw, undecoded name, and repeated names keep their order. A query param that a middleware adds before the cache, such as `?lang=de`, is part of the key.
|
|
1828
|
+
|
|
1829
|
+
Params are keyed by content, by the same rules as [`share`](#share-key-and-refcount). Params that `share` can't compare are never cached and never served from the cache. Each `cacheMiddleware()` call makes its own store, so two stores never share entries.
|
|
1830
|
+
|
|
1831
|
+
#### Share key and refcount
|
|
1832
|
+
|
|
1833
|
+
Two calls share when they go to the same endpoint and their params have the same key. The key is built from content. Object keys are sorted, and an `undefined` member is dropped, so `{ a: undefined }` and `{}` match. A value with a `toJSON` method is keyed by what it returns, so a `Date` is its ISO string. A `Map`, `Set` or typed array is keyed by its entries.
|
|
1834
|
+
|
|
1835
|
+
A call can't share when its params hold any of these, at any depth:
|
|
1836
|
+
|
|
1837
|
+
- a BigInt
|
|
1838
|
+
- a function or a symbol
|
|
1839
|
+
- an `ArrayBuffer`, `Blob`, `FormData` or `URLSearchParams`
|
|
1840
|
+
- a boxed primitive, such as `new String('a')`
|
|
1841
|
+
- a circular structure
|
|
1842
|
+
- an object with no enumerable keys that isn't a plain object, such as an `Error`, a `Promise`, or a class instance that keeps its state in private fields
|
|
1843
|
+
|
|
1844
|
+
Per-call `headers: {}` and `middleware: []` are empty, so they don't turn sharing off. Only a header or a middleware that is actually there does.
|
|
1845
|
+
|
|
1846
|
+
Each caller holds a place in the shared request, counted by a refcount. A caller whose own `signal` or `timeout` fires gives up its place. What happens when every caller has given up, and what the caller who leaves receives, is under [Sharing identical requests](#sharing-identical-requests).
|
|
1847
|
+
|
|
1848
|
+
The endpoint's `timeout` counts from when the shared request started. If each new caller restarted it, a steady stream of callers could keep one request open forever.
|
|
1849
|
+
|
|
1850
|
+
#### `retry()` on a shared result
|
|
1851
|
+
|
|
1852
|
+
Every caller that waits to the end gets the same `Result` object. Its `retry()` uses the per-call options of the caller that started the shared request (its headers, signal and timeout), whoever calls it. A retry is never shared. It sends a request of its own.
|
|
1853
|
+
|
|
1854
|
+
#### Signal-replacing middleware
|
|
1855
|
+
|
|
1856
|
+
A middleware can replace `ctx.request.signal`, as [Give each attempt its own timeout](#give-each-attempt-its-own-timeout) shows. Under `share`, liaise merges the signal that tracks the callers back in before `fetch`. The shared request is still cancelled once every caller has given up.
|
|
1857
|
+
|
|
1858
|
+
Under `dedupe`, liaise merges the dedupe signal into yours the same way, so the request ends when either one fires. The dedupe signal is added when `fetch` is called. A middleware that reads `ctx.request.signal` before `next()` sees only the caller's signal and the deadline.
|
|
1859
|
+
|
|
1860
|
+
#### Timeout backstop
|
|
1861
|
+
|
|
1862
|
+
A middleware may wait on work of its own before it calls `next()`, such as a token refresh. If that work never settles, the call still ends at its deadline. Here `user` stands for your auth client:
|
|
1311
1863
|
|
|
1312
1864
|
```ts
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1865
|
+
const auth: Middleware = async (ctx, next) => {
|
|
1866
|
+
const token = await user.getIdToken() // stalls on a bad network
|
|
1867
|
+
ctx.request.headers.set('Authorization', `Bearer ${token}`)
|
|
1868
|
+
return next()
|
|
1869
|
+
}
|
|
1870
|
+
// With timeout: 45_000, the call still settles after about 45 s: kind 'timeout', status 0.
|
|
1316
1871
|
```
|
|
1317
1872
|
|
|
1318
|
-
|
|
1873
|
+
When the deadline passes, the chain gets one macrotask to answer on its own. That is enough for anything that already reacts to the abort: `fetch` rejecting, a middleware rethrowing the reason, or a fallback that turns a timeout into a cached response. Each of those keeps its own `Result`.
|
|
1319
1874
|
|
|
1320
|
-
|
|
1875
|
+
A chain still waiting after that is stuck on something the signal doesn't reach. The call then settles with `kind: 'timeout'` and `status: 0`, and `onError` is called once. A caller's `signal` works the same way, with `kind: 'abort'`, which `onError` never sees.
|
|
1321
1876
|
|
|
1322
|
-
|
|
1323
|
-
import { successResult, errorResult } from 'liaise/testing'
|
|
1877
|
+
The stuck middleware keeps running, because a promise can't be cancelled. Whatever it returns or throws later is discarded, with no second `Result` and no second `onError`. If it calls `next()` after the call has settled, nothing is sent, and `next()` returns the `Result` the caller already has.
|
|
1324
1878
|
|
|
1325
|
-
|
|
1326
|
-
vi.spyOn(api, 'getUser').mockResolvedValue(errorResult(404, { message: 'not found' }))
|
|
1327
|
-
```
|
|
1879
|
+
#### Abort classification
|
|
1328
1880
|
|
|
1329
|
-
A
|
|
1881
|
+
liaise decides whether a failure is a cancellation by checking whether this call's own signal aborted. The thrown value's name and type don't matter. A custom reason, such as `controller.abort(new Error('unmounted'))` or a plain string, still gives `kind: 'abort'`, and a deadline gives `'timeout'`.
|
|
1330
1882
|
|
|
1331
|
-
|
|
1332
|
-
- **`restore()` assumes `globalThis.fetch` was defined when `install()` ran** — true on Node 20+ (and in every browser), since `fetch` is a global there. If you somehow call `install()` in an environment where `globalThis.fetch` is `undefined` beforehand, `restore()` puts back that `undefined` rather than inventing a real `fetch`.
|
|
1333
|
-
- **A route key must be `"METHOD /path"`.** A key with no space (`'/users'`) throws at `mockFetch(...)` time, naming the offending key, rather than silently registering a route that can never match.
|
|
1334
|
-
- **Declaration order decides when two same-length routes could both match.** Routes are matched in the order they appear in the object you pass to `mockFetch`, and the first structural match wins — put more specific routes first if two patterns could both match the same path.
|
|
1335
|
-
- **Trailing and duplicate slashes are normalized away on both sides.** `/a/b/`, `/a//b`, and `/a/b` all match the same route, whether the extra slash is in the route key or in the URL the library actually built.
|
|
1883
|
+
A middleware that rethrows liaise's own abort or timeout reason, as it is or wrapped once as the `cause` of another error, gives `'abort'` or `'timeout'` too. Anything else a middleware throws is `'middleware'`.
|
|
1336
1884
|
|
|
1337
|
-
|
|
1885
|
+
#### baseUrl query merging
|
|
1338
1886
|
|
|
1339
|
-
|
|
1887
|
+
A `baseUrl` can carry its own query string, such as a fixed `api-version`. Its params go in front of the call's:
|
|
1340
1888
|
|
|
1341
|
-
|
|
1889
|
+
```ts
|
|
1890
|
+
import { createApi, defineRequest } from 'liaise'
|
|
1342
1891
|
|
|
1343
|
-
|
|
1892
|
+
type Hit = { id: string }
|
|
1344
1893
|
|
|
1345
|
-
|
|
1894
|
+
const search = defineRequest<Hit[], { q: string }>()({ method: 'GET', path: '/search' })
|
|
1895
|
+
const api = createApi({ baseUrl: 'https://api.example.com/v1?api-version=2', requests: { search } })
|
|
1346
1896
|
|
|
1347
|
-
|
|
1897
|
+
await api.search({ q: 'hello' })
|
|
1898
|
+
// GET https://api.example.com/v1/search?api-version=2&q=hello
|
|
1899
|
+
```
|
|
1348
1900
|
|
|
1349
|
-
|
|
1901
|
+
A key that appears in both is sent twice. A call param named `api-version` gives `?api-version=2&api-version=3`, and the server decides which one counts. Headers merge by name, but query params can't, because an array param is already sent as repeated keys (`tags=a&tags=b`). To change a base param on each call, set it in a middleware.
|
|
1350
1902
|
|
|
1351
|
-
|
|
1903
|
+
The full URL, base query included, appears in `ctx.request.url`, `error.request.url` and `logMiddleware`'s output, so anything that logs or reports it sends that query too. Put a secret in a header instead.
|
|
1352
1904
|
|
|
1353
|
-
|
|
1905
|
+
#### URL fragments
|
|
1354
1906
|
|
|
1355
|
-
|
|
1907
|
+
A `#` written into a `path` or a `baseUrl` is refused, because a fragment is never sent to the server. The call returns an error with `kind: 'network'` and a `TypeError` in `error.body` that names the value. Nothing is sent. liaise refuses the fragment instead of stripping it, so the dead part doesn't stay in your code.
|
|
1356
1908
|
|
|
1357
|
-
|
|
1909
|
+
`defineRequest` also refuses a fragment in a `path` literal, at compile time:
|
|
1358
1910
|
|
|
1359
|
-
|
|
1911
|
+
```ts
|
|
1912
|
+
defineRequest<Doc>()({ method: 'GET', path: '/docs#section' })
|
|
1913
|
+
// ^ Property '__fragmentInPath' is missing:
|
|
1914
|
+
// a URL fragment is never sent to the server
|
|
1915
|
+
```
|
|
1360
1916
|
|
|
1361
|
-
The
|
|
1917
|
+
The check reads the literal. A path built at runtime, or a variable typed as `RequestConfig`, still compiles and is refused when it is called. `new Request` has no compile-time check.
|
|
1362
1918
|
|
|
1363
|
-
|
|
1919
|
+
A `#` inside a param value is data. It is escaped to `%23` and sent:
|
|
1364
1920
|
|
|
1365
|
-
|
|
1921
|
+
```ts
|
|
1922
|
+
await api.getDoc({ id: 'a#b' }) // GET /docs/a%23b
|
|
1923
|
+
await api.search({ tag: 'a#b' }) // GET /search?tag=a%23b
|
|
1924
|
+
```
|
|
1366
1925
|
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
| `Result` | type | Discriminated union of every API call's outcome: `SuccessResult<T> \| ErrorResult<T>` |
|
|
1383
|
-
| `SuccessResult` | type | The success branch of `Result`: `{ data: T, error: null, response: Response, retry }` |
|
|
1384
|
-
| `ErrorResult` | type | The error branch of `Result`: `{ data: null, error: ApiError, response: Response \| null, retry }` |
|
|
1385
|
-
| `Middleware` | type | Middleware function signature: `(ctx, next) => Promise<Result>` |
|
|
1386
|
-
| `MiddlewareContext` | type | Request context passed to middleware |
|
|
1387
|
-
| `MiddlewareNext` | type | The `next` function passed to middleware |
|
|
1388
|
-
| `createGraphQL` | function | Creates a typed GraphQL client from a config of Operation definitions |
|
|
1389
|
-
| `Operation` | class | Typed operation definition -- one instance per GraphQL operation |
|
|
1390
|
-
| `gql` | const | Tagged template literal for GraphQL documents (editor tooling support) |
|
|
1391
|
-
| `OperationConfig` | type | Config object for the `Operation` constructor |
|
|
1392
|
-
| `GraphQLBaseConfig` | type | Config object for `createGraphQL` |
|
|
1393
|
-
| `GraphQLError` | type | Shape of a single GraphQL error from `{ errors: [...] }` |
|
|
1394
|
-
|
|
1395
|
-
### Built-in middleware (`liaise/middleware`)
|
|
1396
|
-
|
|
1397
|
-
| Export | Kind | Description |
|
|
1398
|
-
| ----------------- | -------- | ------------------------------------------------------------- |
|
|
1399
|
-
| `retryMiddleware` | function | Factory that returns middleware retrying on 5xx by default, with a configurable backoff policy |
|
|
1400
|
-
| `RetryOptions` | type | Options object accepted by `retryMiddleware` (`max`, `delay`, `baseDelay`, `maxDelay`, `jitter`, `respectRetryAfter`, `retryOn`, `onRetry`) |
|
|
1401
|
-
| `RetryInfo` | type | Shape of the argument passed to `RetryOptions.onRetry` |
|
|
1402
|
-
| `logMiddleware` | const | Middleware that logs request lifecycle to the console |
|
|
1403
|
-
| `cacheMiddleware` | function | Factory that returns a per-request in-memory cache with `clear()` |
|
|
1404
|
-
| `CacheMiddleware` | type | Return type of `cacheMiddleware()` -- a `Middleware` with an attached `clear()` |
|
|
1405
|
-
|
|
1406
|
-
### Testing (`liaise/testing`)
|
|
1407
|
-
|
|
1408
|
-
| Export | Kind | Description |
|
|
1409
|
-
| --------------- | -------- | ------------------------------------------------------------------ |
|
|
1410
|
-
| `mockFetch` | function | Builds a route-matching `fetch` stub, with `install()`/`restore()`, call recording, and response sequencing |
|
|
1411
|
-
| `jsonResponse` | function | Builds a `Response` with a JSON body and a `content-type` header, for use as a route value |
|
|
1412
|
-
| `successResult` | function | Builds a well-formed success `Result<T>` directly, for stubbing at the `Result` level |
|
|
1413
|
-
| `errorResult` | function | Builds a well-formed error `Result<T>` with a given HTTP status, for stubbing at the `Result` level |
|
|
1414
|
-
| `RouteContext` | type | `{ params, request }` passed to a route handler function |
|
|
1415
|
-
| `RouteHandler` | type | `(ctx: RouteContext) => Response \| Promise<Response>` -- a route value that computes its response |
|
|
1416
|
-
| `RouteValue` | type | `Response \| RouteHandler \| Array<Response \| RouteHandler>` -- anything a route key can map to |
|
|
1417
|
-
| `RecordedCall` | type | `{ method, url, headers, body }` -- shape of each entry in `mock.calls` |
|
|
1418
|
-
|
|
1419
|
-
## Contributing
|
|
1420
|
-
|
|
1421
|
-
Bug reports, fixes and ideas are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to report a bug, run the tests, and the few rules a pull request is checked against.
|
|
1422
|
-
|
|
1423
|
-
## License
|
|
1424
|
-
|
|
1425
|
-
MIT
|
|
1926
|
+
#### Stream bodies
|
|
1927
|
+
|
|
1928
|
+
A `ReadableStream` body is used up as it is sent, so liaise can't send it again. A second attempt, from `retryMiddleware` or `result.retry()`, returns `kind: 'network'` and `status: 0`, with a `TypeError` that tells you to read the stream into a `Blob` or `ArrayBuffer` first. Under `retryMiddleware` that error is the final `Result`, so the 5xx that caused the retry isn't in it.
|
|
1929
|
+
|
|
1930
|
+
#### Empty bodies
|
|
1931
|
+
|
|
1932
|
+
- **A 2xx with an empty body under `'json'`** is a `'parse'` error ([Reading responses](#reading-responses)), and its `Result` still has `retry()`.
|
|
1933
|
+
- **A non-2xx with an empty body** is an ordinary `'http'` error with `error.body` set to `null`. So is a non-2xx whose JSON body doesn't parse, such as an HTML page from a gateway. Nobody promised a body on failure, so neither is a `'parse'` error.
|
|
1934
|
+
- **On GraphQL**, a 2xx with neither `data` nor `errors` is a `'parse'` error with the raw text in `error.body` ([GraphQL errors](#graphql-errors)). That includes a root of `null`, a number or an array. Each is valid JSON, but GraphQL requires an object.
|
|
1935
|
+
|
|
1936
|
+
## Upgrading, contributing, licence
|
|
1937
|
+
|
|
1938
|
+
- **Upgrading.** [MIGRATION.md](./MIGRATION.md) says what to change when an upgrade needs it. [CHANGELOG.md](./CHANGELOG.md) lists every release.
|
|
1939
|
+
- **Contributing.** Bug reports, fixes and ideas are welcome. [CONTRIBUTING.md](./CONTRIBUTING.md) explains how to report a bug, run the tests, and the few rules a pull request is checked against.
|
|
1940
|
+
- **Licence.** MIT, in [LICENSE](./LICENSE).
|