liaise 5.0.1 → 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 +60 -0
- package/MIGRATION.md +16 -0
- package/README.md +1465 -948
- package/dist/types.d.ts +29 -32
- package/dist/utils/path-params.d.ts +6 -4
- package/dist/utils/path-params.js +32 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -2,271 +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.6 kB gzipped** for a REST-only import, 6.7 kB for the core entry, 7.8 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
|
|
309
|
+
|
|
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.
|
|
141
311
|
|
|
142
312
|
```ts
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
})
|
|
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
|
+
}
|
|
148
347
|
```
|
|
149
348
|
|
|
150
|
-
|
|
349
|
+
`show`, `redirectToLogin` and `report` stand for your own code.
|
|
151
350
|
|
|
152
|
-
|
|
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).
|
|
366
|
+
|
|
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:
|
|
155
370
|
|
|
156
371
|
```ts
|
|
157
|
-
const
|
|
158
|
-
method: 'GET',
|
|
159
|
-
path: '/users/search',
|
|
160
|
-
dedupe: true
|
|
161
|
-
})
|
|
372
|
+
const { error, retry } = await api.getUser({ id: '42' })
|
|
162
373
|
|
|
163
|
-
|
|
164
|
-
await
|
|
165
|
-
|
|
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
|
+
}
|
|
166
379
|
```
|
|
167
380
|
|
|
168
|
-
|
|
381
|
+
`refreshToken` stands for your own token refresh.
|
|
169
382
|
|
|
170
|
-
|
|
383
|
+
#### Reporting errors with onError
|
|
384
|
+
|
|
385
|
+
`onError` on `createApi` is one place to send every error to your tracker.
|
|
171
386
|
|
|
172
387
|
```ts
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
388
|
+
const api = createApi({
|
|
389
|
+
baseUrl: 'https://api.example.com',
|
|
390
|
+
requests: { getUser },
|
|
391
|
+
onError: (error) => logToTracker(error),
|
|
177
392
|
})
|
|
178
|
-
|
|
179
|
-
// Both calls join the same network request
|
|
180
|
-
await Promise.all([
|
|
181
|
-
api.getProduct({ id: '42' }),
|
|
182
|
-
api.getProduct({ id: '42' })
|
|
183
|
-
])
|
|
184
393
|
```
|
|
185
394
|
|
|
186
|
-
|
|
395
|
+
`logToTracker` stands for your error tracker, such as Sentry.
|
|
396
|
+
|
|
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.
|
|
187
400
|
|
|
188
|
-
|
|
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.
|
|
189
404
|
|
|
190
405
|
```ts
|
|
191
|
-
|
|
192
|
-
const bulkDelete = new Request<{ ids: string[] }, { deleted: number }>({
|
|
193
|
-
method: 'DELETE',
|
|
194
|
-
path: '/items',
|
|
195
|
-
bodyAs: 'body'
|
|
196
|
-
})
|
|
406
|
+
import { createApi, defineRequest } from 'liaise'
|
|
197
407
|
|
|
198
|
-
|
|
199
|
-
|
|
408
|
+
type Item = { id: string; name: string }
|
|
409
|
+
|
|
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 }>()({
|
|
200
415
|
method: 'POST',
|
|
201
|
-
path: '/
|
|
202
|
-
bodyAs: 'query'
|
|
416
|
+
path: '/orgs/:org/items',
|
|
203
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"}
|
|
204
426
|
```
|
|
205
427
|
|
|
206
|
-
|
|
428
|
+
Which params go in the query string and which in the body is set per endpoint, under [Defining endpoints](#defining-endpoints).
|
|
207
429
|
|
|
208
|
-
|
|
209
|
-
and nothing checks the two against each other:
|
|
430
|
+
#### Query strings
|
|
210
431
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 |
|
|
214
440
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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.
|
|
219
446
|
|
|
220
|
-
|
|
447
|
+
#### Request bodies
|
|
221
448
|
|
|
222
|
-
|
|
223
|
-
import { defineRequest } from 'liaise'
|
|
449
|
+
liaise serializes the body from what you pass, and sets `Content-Type` for you unless you set one yourself.
|
|
224
450
|
|
|
225
|
-
|
|
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` |
|
|
226
462
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
```
|
|
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)).
|
|
464
|
+
|
|
465
|
+
#### What params can be
|
|
231
466
|
|
|
232
|
-
|
|
233
|
-
|
|
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. |
|
|
234
475
|
|
|
235
|
-
|
|
236
|
-
const listRepos = defineRequest<Repo[], { page?: number }>()({
|
|
237
|
-
method: 'GET',
|
|
238
|
-
path: '/orgs/:org/repos',
|
|
239
|
-
})
|
|
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.
|
|
240
477
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
```
|
|
478
|
+
Headers, including your own `Content-Type`, follow the [three levels of settings](#three-levels-of-settings).
|
|
479
|
+
|
|
480
|
+
### Reading responses
|
|
245
481
|
|
|
246
|
-
|
|
247
|
-
only document:
|
|
482
|
+
A response can be JSON, text or a file. You say which with `responseType`, and liaise reads the body for you.
|
|
248
483
|
|
|
249
484
|
```ts
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
485
|
+
import { createApi, defineRequest } from 'liaise'
|
|
486
|
+
|
|
487
|
+
type User = { id: string; name: string }
|
|
253
488
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
+
})
|
|
501
|
+
|
|
502
|
+
const api = createApi({
|
|
503
|
+
baseUrl: 'https://api.example.com',
|
|
504
|
+
requests: { getUser, downloadFile, deleteUser },
|
|
505
|
+
})
|
|
506
|
+
```
|
|
259
507
|
|
|
260
|
-
|
|
261
|
-
|
|
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
|
+
```
|
|
262
531
|
|
|
263
|
-
###
|
|
532
|
+
### Validating responses
|
|
264
533
|
|
|
265
|
-
|
|
266
|
-
ArkType — and the response is checked before you see it. liaise takes no
|
|
267
|
-
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.
|
|
268
535
|
|
|
269
536
|
```ts
|
|
537
|
+
import { createApi, defineRequest } from 'liaise'
|
|
270
538
|
import { z } from 'zod'
|
|
271
539
|
|
|
272
540
|
const getUser = defineRequest()({
|
|
@@ -275,452 +543,401 @@ const getUser = defineRequest()({
|
|
|
275
543
|
schema: z.object({ id: z.string(), name: z.string() }),
|
|
276
544
|
})
|
|
277
545
|
|
|
546
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
|
|
547
|
+
|
|
278
548
|
const { data, error } = await api.getUser({ id: '42' })
|
|
279
549
|
// ^? { id: string; name: string } | null
|
|
280
550
|
```
|
|
281
551
|
|
|
282
|
-
|
|
283
|
-
and no second place for it to drift out of date.
|
|
284
|
-
|
|
285
|
-
**`data` is the schema's output.** A schema that transforms changes what you
|
|
286
|
-
receive:
|
|
552
|
+
Valibot and ArkType work the same way:
|
|
287
553
|
|
|
288
554
|
```ts
|
|
289
|
-
|
|
555
|
+
import * as v from 'valibot'
|
|
556
|
+
import { type } from 'arktype'
|
|
557
|
+
|
|
558
|
+
const withValibot = defineRequest()({
|
|
290
559
|
method: 'GET',
|
|
291
560
|
path: '/users/:id',
|
|
292
|
-
schema:
|
|
293
|
-
id: z.string(),
|
|
294
|
-
createdAt: z.coerce.date(), // the wire sends a string
|
|
295
|
-
role: z.string().default('user'), // absent on the wire
|
|
296
|
-
}),
|
|
561
|
+
schema: v.object({ id: v.string(), name: v.string() }),
|
|
297
562
|
})
|
|
298
563
|
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
564
|
+
const withArkType = defineRequest()({
|
|
565
|
+
method: 'GET',
|
|
566
|
+
path: '/users/:id',
|
|
567
|
+
schema: type({ id: 'string', name: 'string' }),
|
|
568
|
+
})
|
|
302
569
|
```
|
|
303
570
|
|
|
304
|
-
|
|
305
|
-
|
|
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:
|
|
306
574
|
|
|
307
|
-
|
|
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
|
+
})
|
|
308
585
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
error.status // the response's own status — the server was fine
|
|
314
|
-
}
|
|
315
|
-
```
|
|
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
|
+
```
|
|
316
590
|
|
|
317
|
-
|
|
318
|
-
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:
|
|
319
592
|
|
|
320
|
-
|
|
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
|
+
```
|
|
321
599
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
schema: UserSchema,
|
|
326
|
-
})
|
|
327
|
-
```
|
|
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:
|
|
328
603
|
|
|
329
|
-
|
|
604
|
+
```ts
|
|
605
|
+
import { Operation, gql } from 'liaise'
|
|
330
606
|
|
|
331
|
-
|
|
607
|
+
const UserSchema = z.object({ id: z.string(), name: z.string() })
|
|
332
608
|
|
|
333
|
-
|
|
609
|
+
const me = new Operation<Record<string, never>, z.infer<typeof UserSchema>>({
|
|
610
|
+
operation: gql`query { me { id name } }`,
|
|
611
|
+
schema: UserSchema,
|
|
612
|
+
})
|
|
613
|
+
```
|
|
334
614
|
|
|
335
|
-
|
|
336
|
-
import { paginate } from 'liaise'
|
|
615
|
+
### Cancelling, deadlines and stale requests
|
|
337
616
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (page.error) break
|
|
342
|
-
render(page.data.items)
|
|
343
|
-
}
|
|
344
|
-
```
|
|
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.
|
|
618
|
+
|
|
619
|
+
#### Cancel with a signal
|
|
345
620
|
|
|
346
|
-
|
|
347
|
-
library out of the business of guessing where a cursor goes — `cursor`?
|
|
348
|
-
`page_token`? `after`? The previous params arrive as the second argument, so
|
|
349
|
-
the common case is a spread, and the same shape covers every scheme:
|
|
621
|
+
Pass an `AbortSignal` in the call options:
|
|
350
622
|
|
|
351
623
|
```ts
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
: undefined
|
|
356
|
-
|
|
357
|
-
// page number, driven by a Link header
|
|
358
|
-
next: (p, prev) => p.response.headers.get('link')?.includes('rel="next"')
|
|
359
|
-
? { ...prev, page: prev.page + 1 }
|
|
360
|
-
: undefined
|
|
361
|
-
```
|
|
624
|
+
import { createApi, defineRequest } from 'liaise'
|
|
625
|
+
|
|
626
|
+
type User = { id: string; name: string }
|
|
362
627
|
|
|
363
|
-
|
|
628
|
+
const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
|
|
629
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
|
|
364
630
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
failed rather than a loop that quietly stopped.
|
|
631
|
+
const controller = new AbortController()
|
|
632
|
+
const pending = api.getUser({ id: '42' }, { signal: controller.signal })
|
|
368
633
|
|
|
369
|
-
|
|
370
|
-
the library will not invent a number, because a silent truncation at an
|
|
371
|
-
arbitrary limit looks exactly like reaching the last page.
|
|
634
|
+
controller.abort()
|
|
372
635
|
|
|
373
|
-
|
|
374
|
-
|
|
636
|
+
const { error } = await pending
|
|
637
|
+
// error.kind === 'abort', error.status === 0, error.body is a DOMException named 'AbortError'
|
|
375
638
|
```
|
|
376
639
|
|
|
377
|
-
|
|
378
|
-
to every request, so one signal cancels the whole crawl.
|
|
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).
|
|
379
641
|
|
|
380
|
-
|
|
381
|
-
holds the array, which is the convention-guessing `next` exists to avoid.
|
|
642
|
+
#### Set a deadline with timeout
|
|
382
643
|
|
|
383
|
-
|
|
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
|
-
|
|
388
|
-
|
|
650
|
+
type Report = { total: number }
|
|
651
|
+
|
|
652
|
+
const getReport = defineRequest<Report>()({ method: 'GET', path: '/report', timeout: 3000 })
|
|
389
653
|
|
|
390
|
-
```ts
|
|
391
654
|
const api = createApi({
|
|
392
|
-
baseUrl: 'https://api.example.com
|
|
393
|
-
requests: {
|
|
655
|
+
baseUrl: 'https://api.example.com',
|
|
656
|
+
requests: { getReport },
|
|
657
|
+
middleware: [retryMiddleware(3)],
|
|
394
658
|
})
|
|
395
659
|
|
|
396
|
-
await api.
|
|
397
|
-
//
|
|
660
|
+
const { error } = await api.getReport()
|
|
661
|
+
// If no answer arrives within 3 seconds, retries included: error.kind === 'timeout', error.status === 0
|
|
398
662
|
```
|
|
399
663
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
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.
|
|
406
669
|
|
|
407
|
-
|
|
408
|
-
a `path` or `baseUrl` cannot do what it appears to — and before 4.2.1 it
|
|
409
|
-
silently discarded the query string. It is now an error naming the offending
|
|
410
|
-
value, rather than being stripped, so the dead code does not stay in your
|
|
411
|
-
template.
|
|
670
|
+
#### Drop stale calls with dedupe
|
|
412
671
|
|
|
413
|
-
|
|
414
|
-
is also a compile error, so the endpoint is rejected where it is declared rather
|
|
415
|
-
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:
|
|
416
673
|
|
|
417
674
|
```ts
|
|
418
|
-
|
|
419
|
-
// ^ Property '__fragmentInPath' is missing:
|
|
420
|
-
// a URL fragment is never sent to the server
|
|
421
|
-
```
|
|
675
|
+
type User = { id: string; name: string }
|
|
422
676
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
677
|
+
const searchUsers = defineRequest<User[], { q: string }>()({
|
|
678
|
+
method: 'GET',
|
|
679
|
+
path: '/users/search',
|
|
680
|
+
dedupe: true,
|
|
681
|
+
})
|
|
427
682
|
|
|
428
|
-
|
|
429
|
-
escaped to `%23` and sent as ordinary data:
|
|
683
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { searchUsers } })
|
|
430
684
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
434
689
|
```
|
|
435
690
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
| ------------------------------ | --------------------------- |
|
|
441
|
-
| `{ page: 1, limit: 20 }` | `?page=1&limit=20` |
|
|
442
|
-
| `{ tags: ['a', 'b'] }` | `?tags=a&tags=b` |
|
|
443
|
-
| `{ filter: null }` | _(omitted)_ |
|
|
444
|
-
| `{ filter: undefined }` | _(omitted)_ |
|
|
445
|
-
| `{ meta: { nested: true } }` | **TypeError** (see below) |
|
|
446
|
-
| `{ since: new Date() }` | **TypeError**: convert it first (`toISOString()` or `getTime()`) |
|
|
447
|
-
|
|
448
|
-
**Arrays** use repeated keys (`tags=a&tags=b`), which is the most widely supported format across server frameworks.
|
|
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
|
-
|
|
702
|
+
```ts
|
|
703
|
+
import { createApi, defineRequest } from 'liaise'
|
|
457
704
|
|
|
458
|
-
|
|
705
|
+
type Product = { id: string; name: string }
|
|
459
706
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
retry: () => Promise<Result<TResponse>>
|
|
466
|
-
}
|
|
707
|
+
const getProduct = defineRequest<Product>()({
|
|
708
|
+
method: 'GET',
|
|
709
|
+
path: '/products/:id',
|
|
710
|
+
share: true,
|
|
711
|
+
})
|
|
467
712
|
|
|
468
|
-
|
|
469
|
-
data: null
|
|
470
|
-
error: ApiError // structured error, see below
|
|
471
|
-
response: Response | null // present for HTTP/parse failures, null for network/abort/timeout
|
|
472
|
-
retry: () => Promise<Result<TResponse>>
|
|
473
|
-
}
|
|
713
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getProduct } })
|
|
474
714
|
|
|
475
|
-
|
|
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
|
+
])
|
|
476
720
|
```
|
|
477
721
|
|
|
478
|
-
|
|
479
|
-
|
|
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.
|
|
480
729
|
|
|
481
730
|
```ts
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
if (error) {
|
|
485
|
-
switch (error.kind) {
|
|
486
|
-
case 'network':
|
|
487
|
-
// fetch itself failed -- user is probably offline
|
|
488
|
-
break
|
|
489
|
-
case 'timeout':
|
|
490
|
-
// the whole-operation deadline fired; report it
|
|
491
|
-
reportTimeout(error)
|
|
492
|
-
break
|
|
493
|
-
case 'abort':
|
|
494
|
-
// this call was cancelled (dedupe supersede, or your own signal) -- usually ignore it
|
|
495
|
-
break
|
|
496
|
-
case 'parse':
|
|
497
|
-
// a 2xx response arrived but its body didn't parse as `responseType`
|
|
498
|
-
console.error('unparseable response', error.status, error.body)
|
|
499
|
-
break
|
|
500
|
-
case 'middleware':
|
|
501
|
-
// a middleware threw -- a bug in your own pipeline, not a transient failure
|
|
502
|
-
console.error('middleware threw', error.body)
|
|
503
|
-
break
|
|
504
|
-
case 'http':
|
|
505
|
-
if (error.status === 401) redirectToLogin()
|
|
506
|
-
else console.error(error.status, error.body)
|
|
507
|
-
break
|
|
508
|
-
}
|
|
509
|
-
return
|
|
510
|
-
}
|
|
731
|
+
const impatient = api.getProduct({ id: '42' }, { timeout: 20 }) // gives up quickly
|
|
732
|
+
const patient = api.getProduct({ id: '42' }) // keeps waiting
|
|
511
733
|
|
|
512
|
-
//
|
|
513
|
-
// (this assumes getUser always answers with a body; an endpoint that
|
|
514
|
-
// doesn't -- a DELETE returning 204, most commonly -- should use
|
|
515
|
-
// responseType: 'none' instead, see the empty-body note above)
|
|
516
|
-
console.log(data.name)
|
|
734
|
+
// impatient's timeout doesn't cancel the shared request, so patient still gets the response.
|
|
517
735
|
```
|
|
518
736
|
|
|
519
|
-
|
|
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).
|
|
520
738
|
|
|
521
|
-
####
|
|
739
|
+
#### On a server
|
|
522
740
|
|
|
523
|
-
|
|
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.
|
|
524
742
|
|
|
525
|
-
|
|
526
|
-
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.
|
|
527
744
|
|
|
528
|
-
|
|
529
|
-
await refreshToken()
|
|
530
|
-
const retried = await retry()
|
|
531
|
-
// retried goes through the full middleware chain again
|
|
532
|
-
}
|
|
533
|
-
```
|
|
745
|
+
### Retries, caching and logging
|
|
534
746
|
|
|
535
|
-
|
|
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:
|
|
536
748
|
|
|
537
|
-
|
|
749
|
+
```ts
|
|
750
|
+
import { retryMiddleware, cacheMiddleware, logMiddleware } from 'liaise/middleware'
|
|
751
|
+
```
|
|
538
752
|
|
|
539
|
-
|
|
540
|
-
| ------------ | --------- | ----------------------------------------------------------------- |
|
|
541
|
-
| `status` | `number` | HTTP status code (e.g., 404, 500). `0` for network errors, aborts, and timeouts. |
|
|
542
|
-
| `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. |
|
|
543
|
-
| `statusText` | `string` | HTTP status text (e.g., 'Not Found'). `''` for network errors. |
|
|
544
|
-
| `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. |
|
|
545
|
-
| `headers` | `Headers` | Response headers. Empty `Headers` for network errors. |
|
|
546
|
-
| `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. |
|
|
547
|
-
| `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
|
|
548
754
|
|
|
549
|
-
`
|
|
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`:
|
|
550
756
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
757
|
+
```ts
|
|
758
|
+
import { createApi, defineRequest } from 'liaise'
|
|
759
|
+
import { retryMiddleware } from 'liaise/middleware'
|
|
554
760
|
|
|
555
|
-
|
|
761
|
+
type Item = { id: string }
|
|
556
762
|
|
|
557
|
-
|
|
763
|
+
const getItems = defineRequest<Item[]>()({ method: 'GET', path: '/items' })
|
|
558
764
|
|
|
559
|
-
|
|
560
|
-
|
|
765
|
+
const api = createApi({
|
|
766
|
+
baseUrl: 'https://api.example.com',
|
|
767
|
+
requests: { getItems },
|
|
768
|
+
middleware: [retryMiddleware(2)], // 5xx only
|
|
769
|
+
})
|
|
561
770
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
+
})
|
|
565
776
|
```
|
|
566
777
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
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:
|
|
570
779
|
|
|
571
780
|
```ts
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if (error.status === 401) redirectToLogin()
|
|
577
|
-
Sentry.captureException(error)
|
|
578
|
-
}
|
|
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`),
|
|
579
785
|
})
|
|
580
786
|
```
|
|
581
787
|
|
|
582
|
-
|
|
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.
|
|
583
790
|
|
|
584
|
-
|
|
791
|
+
#### Cache repeated reads
|
|
585
792
|
|
|
586
|
-
|
|
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:
|
|
587
794
|
|
|
588
|
-
```
|
|
589
|
-
|
|
590
|
-
|
|
795
|
+
```ts
|
|
796
|
+
import { createApi, defineRequest } from 'liaise'
|
|
797
|
+
import { cacheMiddleware } from 'liaise/middleware'
|
|
591
798
|
|
|
592
|
-
|
|
799
|
+
type User = { id: string; name: string }
|
|
593
800
|
|
|
594
|
-
|
|
595
|
-
import type { Middleware } from 'liaise'
|
|
801
|
+
const getUserCache = cacheMiddleware({ ttl: 5 * 60_000, maxSize: 100 })
|
|
596
802
|
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
803
|
+
const getUser = defineRequest<User>()({
|
|
804
|
+
method: 'GET',
|
|
805
|
+
path: '/users/:id',
|
|
806
|
+
middleware: [getUserCache],
|
|
807
|
+
})
|
|
600
808
|
|
|
601
|
-
|
|
602
|
-
const result = await next()
|
|
809
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
|
|
603
810
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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] })
|
|
607
816
|
```
|
|
608
817
|
|
|
609
|
-
|
|
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)).
|
|
610
822
|
|
|
611
|
-
|
|
612
|
-
- **Short-circuit** -- return early without calling `next()` (e.g., serve from cache).
|
|
613
|
-
- **Retry** -- call `next()` multiple times in a loop (e.g., retry on 5xx).
|
|
614
|
-
- **Inspect the result** -- log, report errors, transform response data.
|
|
823
|
+
#### Log every call
|
|
615
824
|
|
|
616
|
-
|
|
825
|
+
`logMiddleware` prints each call's start and end to the console, with its duration:
|
|
617
826
|
|
|
618
|
-
|
|
827
|
+
```text
|
|
828
|
+
[liaise] → GET getItems /api/items
|
|
829
|
+
[liaise] ← getItems OK (142ms)
|
|
619
830
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
baseUrl: '/api',
|
|
624
|
-
requests: { getUser, createUser },
|
|
625
|
-
middleware: [authMiddleware, logMiddleware]
|
|
626
|
-
})
|
|
831
|
+
[liaise] → POST createUser /api/users
|
|
832
|
+
[liaise] ← createUser ERROR 422 (89ms)
|
|
833
|
+
```
|
|
627
834
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
path: '/users/:id',
|
|
632
|
-
middleware: [cacheMiddleware]
|
|
633
|
-
})
|
|
835
|
+
```ts
|
|
836
|
+
import { createApi, defineRequest } from 'liaise'
|
|
837
|
+
import { logMiddleware } from 'liaise/middleware'
|
|
634
838
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
})
|
|
839
|
+
const getItems = defineRequest<{ id: string }[]>()({ method: 'GET', path: '/items' })
|
|
840
|
+
|
|
841
|
+
const api = createApi({ baseUrl: '/api', requests: { getItems }, middleware: [logMiddleware] })
|
|
639
842
|
```
|
|
640
843
|
|
|
641
|
-
|
|
844
|
+
It is meant for development. In production, [write a middleware](#writing-middleware) that sends the same facts to your monitoring.
|
|
642
845
|
|
|
643
|
-
|
|
846
|
+
### Writing middleware
|
|
847
|
+
|
|
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.
|
|
644
849
|
|
|
645
850
|
```ts
|
|
646
|
-
|
|
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' })
|
|
647
867
|
|
|
648
868
|
const api = createApi({
|
|
649
|
-
baseUrl: '
|
|
869
|
+
baseUrl: 'https://api.example.com',
|
|
650
870
|
requests: { getUser },
|
|
651
|
-
middleware: [
|
|
871
|
+
middleware: [auth],
|
|
652
872
|
})
|
|
873
|
+
```
|
|
653
874
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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]]]]
|
|
658
881
|
```
|
|
659
882
|
|
|
660
|
-
|
|
883
|
+
A middleware can:
|
|
661
884
|
|
|
662
|
-
|
|
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`.
|
|
663
889
|
|
|
664
|
-
|
|
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.
|
|
665
891
|
|
|
666
|
-
|
|
667
|
-
| --------------------- | --------- | -------------------------------------------------------- |
|
|
668
|
-
| `request.method` | `string` | HTTP method (GET, POST, etc.) |
|
|
669
|
-
| `request.url` | `string` | Fully resolved URL with path params and query string |
|
|
670
|
-
| `request.path` | `string` | Original path template (e.g., '/users/:id') |
|
|
671
|
-
| `request.params` | `unknown` | Original params object from the caller |
|
|
672
|
-
| `request.headers` | `Headers` | Merged headers -- middleware can add/remove entries |
|
|
673
|
-
| `request.body` | `unknown` | Serialized body, or null for GET/DELETE |
|
|
674
|
-
| `request.signal` | `AbortSignal \| undefined` | The signal handed to `fetch` -- replace it to impose your own cancellation policy |
|
|
675
|
-
| `requestName` | `string` | Key name in the requests object (e.g., 'getUser') |
|
|
892
|
+
#### Skipping a middleware for one call
|
|
676
893
|
|
|
677
|
-
|
|
894
|
+
Pass the middleware itself in `skipMiddleware`:
|
|
678
895
|
|
|
679
896
|
```ts
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
}
|
|
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)
|
|
684
904
|
|
|
685
905
|
const api = createApi({
|
|
686
|
-
baseUrl: '
|
|
906
|
+
baseUrl: 'https://api.example.com',
|
|
687
907
|
requests: { getUser },
|
|
688
|
-
middleware: [
|
|
908
|
+
middleware: [retry, logMiddleware],
|
|
689
909
|
})
|
|
690
|
-
```
|
|
691
910
|
|
|
692
|
-
|
|
911
|
+
// No retries for this one call
|
|
912
|
+
await api.getUser({ id: '42' }, { skipMiddleware: [retry] })
|
|
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
|
-
|
|
701
|
-
const cacheMiddleware: Middleware = async (ctx, next) => {
|
|
702
|
-
const cached = cache.get(ctx.request.url)
|
|
703
|
-
if (cached) return cached
|
|
921
|
+
#### Signals in middleware
|
|
704
922
|
|
|
705
|
-
|
|
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
|
-
|
|
708
|
-
cache.set(ctx.request.url, result)
|
|
709
|
-
}
|
|
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.
|
|
710
926
|
|
|
711
|
-
|
|
712
|
-
}
|
|
713
|
-
```
|
|
927
|
+
#### Example: report server errors
|
|
714
928
|
|
|
715
|
-
|
|
929
|
+
This middleware sends every 5xx to Sentry, with the method and URL:
|
|
716
930
|
|
|
717
931
|
```ts
|
|
718
|
-
|
|
932
|
+
import type { Middleware } from 'liaise'
|
|
933
|
+
import * as Sentry from '@sentry/browser'
|
|
934
|
+
|
|
935
|
+
const reportServerErrors: Middleware = async (ctx, next) => {
|
|
719
936
|
const result = await next()
|
|
720
937
|
|
|
721
938
|
if (result.error && result.error.status >= 500) {
|
|
722
939
|
Sentry.captureMessage(`API error: ${ctx.request.method} ${ctx.request.url}`, {
|
|
723
|
-
extra: { status: result.error.status, body: result.error.body }
|
|
940
|
+
extra: { status: result.error.status, body: result.error.body },
|
|
724
941
|
})
|
|
725
942
|
}
|
|
726
943
|
|
|
@@ -728,309 +945,455 @@ const sentryMiddleware: Middleware = async (ctx, next) => {
|
|
|
728
945
|
}
|
|
729
946
|
```
|
|
730
947
|
|
|
731
|
-
|
|
948
|
+
### Pagination
|
|
732
949
|
|
|
733
|
-
|
|
950
|
+
Many list endpoints return one page at a time. `paginate` walks through the pages and gives you one `Result` per page:
|
|
734
951
|
|
|
735
952
|
```ts
|
|
736
|
-
import {
|
|
737
|
-
```
|
|
953
|
+
import { createApi, defineRequest, paginate } from 'liaise'
|
|
738
954
|
|
|
739
|
-
|
|
955
|
+
type Item = { id: string; name: string }
|
|
956
|
+
type Page = { items: Item[]; cursor?: string }
|
|
740
957
|
|
|
741
|
-
|
|
958
|
+
const listItems = defineRequest<Page, { limit: number; cursor?: string }>()({
|
|
959
|
+
method: 'GET',
|
|
960
|
+
path: '/items',
|
|
961
|
+
})
|
|
742
962
|
|
|
743
|
-
|
|
963
|
+
const api = createApi({ baseUrl: 'https://api.example.com', requests: { listItems } })
|
|
744
964
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
}
|
|
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
|
+
}
|
|
751
971
|
```
|
|
752
972
|
|
|
753
|
-
|
|
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:
|
|
976
|
+
|
|
977
|
+
```ts
|
|
978
|
+
// offset
|
|
979
|
+
next: (p, prev) => p.data.items.length === prev.limit
|
|
980
|
+
? { ...prev, offset: prev.offset + prev.limit }
|
|
981
|
+
: undefined
|
|
982
|
+
|
|
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
|
+
```
|
|
754
988
|
|
|
755
|
-
**
|
|
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.
|
|
756
994
|
|
|
757
|
-
|
|
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 }`.
|
|
758
998
|
|
|
759
999
|
```ts
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
}
|
|
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),
|
|
772
1022
|
})
|
|
773
|
-
```
|
|
774
1023
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
| `max` | `number` | `3` | Additional attempts after the first. `retryMiddleware({ max: 2 })` means up to 3 total calls. |
|
|
778
|
-
| `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. |
|
|
779
|
-
| `baseDelay` | `number` | `250` | The first delay, in milliseconds, before jitter and `Retry-After` are applied. |
|
|
780
|
-
| `maxDelay` | `number` | `30000` | Hard cap applied to every computed delay, including a `Retry-After` value. |
|
|
781
|
-
| `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. |
|
|
782
|
-
| `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`). |
|
|
783
|
-
| `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. |
|
|
784
|
-
| `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
|
+
```
|
|
785
1026
|
|
|
786
|
-
`
|
|
1027
|
+
`Operation<TVariables, TData>` takes the variables type first and the response type second. `gql` marks the string as GraphQL for your editor.
|
|
787
1028
|
|
|
788
|
-
|
|
1029
|
+
An operation with no variables is called without arguments. Write `Record<string, never>` as its variables type:
|
|
789
1030
|
|
|
790
1031
|
```ts
|
|
791
|
-
|
|
792
|
-
retryMiddleware({
|
|
793
|
-
retryOn: r => r.error?.status === 429 || (r.error?.status ?? 0) >= 500
|
|
794
|
-
})
|
|
1032
|
+
type Viewer = { id: string; name: string }
|
|
795
1033
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
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 } }`,
|
|
799
1036
|
})
|
|
1037
|
+
|
|
1038
|
+
const api = createGraphQL({ endpoint: 'https://api.example.com/graphql', operations: { getViewer } })
|
|
1039
|
+
|
|
1040
|
+
const { data } = await api.getViewer() // no arguments
|
|
800
1041
|
```
|
|
801
1042
|
|
|
802
|
-
|
|
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.
|
|
803
1046
|
|
|
804
1047
|
```ts
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
+
}),
|
|
813
1061
|
},
|
|
814
|
-
middleware: [retryMiddleware({ max: 5, baseDelay: 1000 })], // long backoff
|
|
815
1062
|
})
|
|
816
1063
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
// 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' })
|
|
820
1066
|
```
|
|
821
1067
|
|
|
822
|
-
|
|
1068
|
+
#### GraphQL errors
|
|
823
1069
|
|
|
824
|
-
|
|
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.
|
|
825
1071
|
|
|
826
|
-
|
|
827
|
-
[liaise] → GET getItems /api/items
|
|
828
|
-
[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.
|
|
829
1073
|
|
|
830
|
-
|
|
831
|
-
|
|
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
|
+
}
|
|
832
1080
|
```
|
|
833
1081
|
|
|
834
|
-
|
|
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.
|
|
835
1112
|
|
|
836
1113
|
```ts
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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 }),
|
|
841
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
|
|
842
1124
|
```
|
|
843
1125
|
|
|
844
|
-
|
|
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
|
+
```
|
|
845
1135
|
|
|
846
|
-
|
|
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.
|
|
847
1137
|
|
|
848
|
-
|
|
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.
|
|
849
1141
|
|
|
850
1142
|
```ts
|
|
851
|
-
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
|
+
```
|
|
852
1147
|
|
|
853
|
-
|
|
854
|
-
method: 'GET',
|
|
855
|
-
path: '/users/:id',
|
|
856
|
-
middleware: [getUserCache],
|
|
857
|
-
})
|
|
1148
|
+
`api` is a client created with `baseUrl: '/api'`.
|
|
858
1149
|
|
|
859
|
-
|
|
860
|
-
getUserCache.clear()
|
|
1150
|
+
An empty array for a route behaves the same way, with a descriptive `Error` in `error.body`.
|
|
861
1151
|
|
|
862
|
-
|
|
863
|
-
|
|
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' }))
|
|
864
1161
|
```
|
|
865
1162
|
|
|
866
|
-
|
|
1163
|
+
More of the stub's behaviour, such as how it handles a signal, is under [liaise/testing](#liaisetesting).
|
|
867
1164
|
|
|
868
|
-
|
|
1165
|
+
## Recipes
|
|
869
1166
|
|
|
870
|
-
|
|
1167
|
+
Each recipe below is a complete example that runs against a test, so you can paste it as it is.
|
|
871
1168
|
|
|
872
|
-
|
|
873
|
-
| ----------------- | ------------------ | ------------------------------------- |
|
|
874
|
-
| `null`/`undefined` | `null` | _(none)_ |
|
|
875
|
-
| `string` | as-is | `text/plain` |
|
|
876
|
-
| `FormData` | as-is | _(browser sets multipart boundary)_ |
|
|
877
|
-
| `URLSearchParams` | as-is | `application/x-www-form-urlencoded` |
|
|
878
|
-
| `Blob` | as-is | `application/octet-stream` |
|
|
879
|
-
| `ArrayBuffer` | as-is | `application/octet-stream` |
|
|
880
|
-
| Typed array, `DataView`, `Buffer` | as-is (sent as binary) | `application/octet-stream` |
|
|
881
|
-
| `ReadableStream` | as-is (streaming upload; `duplex: 'half'` is set for you) | `application/octet-stream` |
|
|
882
|
-
| Plain object | `JSON.stringify()` | `application/json` |
|
|
1169
|
+
### Add an auth header and refresh the token on a 401
|
|
883
1170
|
|
|
884
|
-
|
|
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.
|
|
885
1172
|
|
|
886
|
-
|
|
1173
|
+
<!-- tested: auth-refresh -->
|
|
1174
|
+
```ts
|
|
1175
|
+
import { createApi, defineRequest, type Middleware } from 'liaise'
|
|
887
1176
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
| Plain object | Decomposed into path tokens, query string and body |
|
|
891
|
-
| `Map` with string keys | Same as the object it spells |
|
|
892
|
-
| 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) |
|
|
893
|
-
| Class instance with only `toJSON()` | Sent as its JSON (body only; refused on a request whose params go in the query string) |
|
|
894
|
-
| 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) |
|
|
895
|
-
| `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 }
|
|
896
1179
|
|
|
897
|
-
|
|
1180
|
+
let tokens: Tokens = { access: 'expired', refresh: 'r1' }
|
|
898
1181
|
|
|
899
|
-
|
|
1182
|
+
const auth: Middleware = async (ctx, next) => {
|
|
1183
|
+
if (ctx.requestName === 'refresh') return next()
|
|
900
1184
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
+
}
|
|
904
1200
|
|
|
905
|
-
|
|
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
|
+
```
|
|
906
1214
|
|
|
907
|
-
|
|
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.
|
|
908
1216
|
|
|
909
|
-
|
|
1217
|
+
### Search as you type
|
|
910
1218
|
|
|
911
|
-
|
|
912
|
-
| --------------- | ---------------------- | -------------- |
|
|
913
|
-
| `'json'` | `response.text()` then `JSON.parse()` | parsed object |
|
|
914
|
-
| `'text'` | `response.text()` | `string` |
|
|
915
|
-
| `'blob'` | `response.blob()` | `Blob` |
|
|
916
|
-
| `'arrayBuffer'` | `response.arrayBuffer()` | `ArrayBuffer` |
|
|
917
|
-
| `'formData'` | `response.formData()` | `FormData` |
|
|
918
|
-
| `'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.
|
|
919
1220
|
|
|
920
|
-
|
|
921
|
-
`null`: you declared JSON and the server sent none, so there is no value that
|
|
922
|
-
could honestly satisfy `TResponse`. You get `kind: 'parse'` with the
|
|
923
|
-
response's own status (a `204` reports `204`), a non-null `response`, and the
|
|
924
|
-
raw body text -- always `''` for this case -- in `error.body`:
|
|
1221
|
+
`Repo` is your result type; `render` and `showError` stand for your UI code.
|
|
925
1222
|
|
|
1223
|
+
<!-- tested: search-as-you-type -->
|
|
926
1224
|
```ts
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
+
}
|
|
930
1240
|
```
|
|
931
1241
|
|
|
932
|
-
|
|
933
|
-
|
|
1242
|
+
The older call settles with an `abort` error, which you skip. Only the newest search reaches `render`.
|
|
1243
|
+
|
|
1244
|
+
### Use with TanStack Query
|
|
934
1245
|
|
|
935
|
-
|
|
936
|
-
body on success -- a `204`, or a `200` with an empty body, most commonly a
|
|
937
|
-
`DELETE`:
|
|
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).
|
|
938
1247
|
|
|
1248
|
+
<!-- tested: tanstack-query -->
|
|
939
1249
|
```ts
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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 })),
|
|
944
1263
|
})
|
|
1264
|
+
|
|
1265
|
+
// React: useQuery(userQuery(id))
|
|
1266
|
+
// Vue: useQuery(computed(() => userQuery(id.value)))
|
|
1267
|
+
// Svelte: createQuery(() => userQuery(id))
|
|
1268
|
+
// Solid: useQuery(() => userQuery(id()))
|
|
945
1269
|
```
|
|
946
1270
|
|
|
947
|
-
|
|
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.
|
|
948
1272
|
|
|
949
|
-
`
|
|
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`.
|
|
950
1274
|
|
|
1275
|
+
### Use with React
|
|
1276
|
+
|
|
1277
|
+
This hook uses React's `useEffect` and `useState`, imported from `react`. `api` and `User` are from [Quick start](#quick-start).
|
|
1278
|
+
|
|
1279
|
+
<!-- tested: react-effect -->
|
|
951
1280
|
```ts
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
|
957
1294
|
}
|
|
958
1295
|
```
|
|
959
1296
|
|
|
960
|
-
|
|
961
|
-
`| null`. That advice is superseded: `responseType: 'none'` declares "no body"
|
|
962
|
-
rather than "body or null", and since 4.0.0 the `| null` workaround no longer
|
|
963
|
-
works at all -- the empty body is an error before `TResponse` is ever
|
|
964
|
-
consulted. See [MIGRATION.md](./MIGRATION.md#upgrading-to-400).)
|
|
965
|
-
|
|
966
|
-
### Cancellation
|
|
1297
|
+
Aborting on cleanup means a fast navigation never writes stale data into a component that has moved on.
|
|
967
1298
|
|
|
968
|
-
|
|
1299
|
+
### Load the current user into a store
|
|
969
1300
|
|
|
970
|
-
|
|
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.
|
|
971
1302
|
|
|
1303
|
+
<!-- tested: store-me -->
|
|
972
1304
|
```ts
|
|
973
|
-
|
|
1305
|
+
import { createApi, defineRequest } from 'liaise'
|
|
974
1306
|
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
})
|
|
1307
|
+
const me = defineRequest<User>()({ method: 'GET', path: '/me', share: true })
|
|
1308
|
+
const api = createApi({ baseUrl: '/api', requests: { me } })
|
|
978
1309
|
|
|
979
|
-
//
|
|
980
|
-
|
|
1310
|
+
// Any store works the same way: Zustand, Pinia, Redux or a plain object.
|
|
1311
|
+
const store = { user: null as User | null }
|
|
981
1312
|
|
|
982
|
-
|
|
983
|
-
|
|
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
|
+
}
|
|
984
1318
|
```
|
|
985
1319
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
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.
|
|
989
1321
|
|
|
990
|
-
|
|
1322
|
+
### One /me per page view on the server
|
|
991
1323
|
|
|
992
|
-
|
|
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.
|
|
993
1325
|
|
|
1326
|
+
<!-- tested: server-loaders -->
|
|
994
1327
|
```ts
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
}
|
|
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
|
+
}
|
|
1005
1341
|
|
|
1006
|
-
|
|
1007
|
-
api
|
|
1008
|
-
|
|
1009
|
-
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
|
+
}
|
|
1010
1350
|
```
|
|
1011
1351
|
|
|
1012
|
-
|
|
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.
|
|
1013
1353
|
|
|
1014
|
-
###
|
|
1354
|
+
### Retry a flaky backend within one deadline
|
|
1015
1355
|
|
|
1016
|
-
|
|
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.
|
|
1017
1357
|
|
|
1358
|
+
<!-- tested: flaky-backend -->
|
|
1018
1359
|
```ts
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
+
},
|
|
1023
1372
|
})
|
|
1024
1373
|
|
|
1025
|
-
const
|
|
1026
|
-
|
|
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
|
+
})
|
|
1027
1382
|
```
|
|
1028
1383
|
|
|
1029
|
-
|
|
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).
|
|
1030
1385
|
|
|
1031
|
-
|
|
1386
|
+
### Give each attempt its own timeout
|
|
1032
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 -->
|
|
1033
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.
|
|
1034
1397
|
const perAttempt = (ms: number): Middleware => async (ctx, next) => {
|
|
1035
1398
|
ctx.request.signal = AbortSignal.timeout(ms)
|
|
1036
1399
|
return next()
|
|
@@ -1039,385 +1402,539 @@ const perAttempt = (ms: number): Middleware => async (ctx, next) => {
|
|
|
1039
1402
|
const api = createApi({
|
|
1040
1403
|
baseUrl: '/api',
|
|
1041
1404
|
requests: { getUser },
|
|
1042
|
-
|
|
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
|
+
],
|
|
1043
1410
|
})
|
|
1044
1411
|
```
|
|
1045
1412
|
|
|
1046
|
-
|
|
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.
|
|
1047
1414
|
|
|
1048
|
-
|
|
1415
|
+
### Report errors to Sentry
|
|
1049
1416
|
|
|
1050
|
-
|
|
1051
|
-
- 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'`).
|
|
1052
|
-
- `result.retry()` always starts a fresh deadline. A retried call is not charged against the original budget.
|
|
1053
|
-
- Non-positive or omitted `timeout` disables it entirely (the default).
|
|
1054
|
-
- `timeout` composes with `dedupe: true` — the deadline is merged with the dedupe signal rather than discarded by it.
|
|
1055
|
-
- 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).
|
|
1056
|
-
|
|
1057
|
-
**The deadline bounds middleware that never looks at the signal, too.** A middleware that awaits something of its own before calling `next()` — a token refresh, say — cannot hold the call past its `timeout`, even if that work never settles:
|
|
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.
|
|
1058
1418
|
|
|
1419
|
+
<!-- tested: report-errors -->
|
|
1059
1420
|
```ts
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
}
|
|
1065
|
-
//
|
|
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
|
+
})
|
|
1066
1434
|
```
|
|
1067
1435
|
|
|
1068
|
-
|
|
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.
|
|
1069
1437
|
|
|
1070
|
-
|
|
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.
|
|
1071
1439
|
|
|
1072
|
-
###
|
|
1440
|
+
### Upload and download files
|
|
1073
1441
|
|
|
1074
|
-
|
|
1442
|
+
Send a file with `FormData` and read one back as a `Blob`.
|
|
1075
1443
|
|
|
1444
|
+
<!-- tested: files -->
|
|
1076
1445
|
```ts
|
|
1077
|
-
|
|
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>()({
|
|
1078
1457
|
method: 'GET',
|
|
1079
|
-
path: '/
|
|
1080
|
-
|
|
1458
|
+
path: '/files/:id',
|
|
1459
|
+
responseType: 'blob',
|
|
1081
1460
|
})
|
|
1082
1461
|
|
|
1083
|
-
|
|
1084
|
-
const [a, b] = await Promise.all([
|
|
1085
|
-
api.getProduct({ id: '42' }),
|
|
1086
|
-
api.getProduct({ id: '42' })
|
|
1087
|
-
])
|
|
1462
|
+
const api = createApi({ baseUrl: '/api', requests: { uploadAvatar, downloadFile } })
|
|
1088
1463
|
```
|
|
1089
1464
|
|
|
1090
|
-
|
|
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).
|
|
1091
1466
|
|
|
1092
|
-
|
|
1467
|
+
## Choosing liaise
|
|
1093
1468
|
|
|
1094
|
-
|
|
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.
|
|
1095
1470
|
|
|
1096
|
-
|
|
1097
|
-
- 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
|
|
1098
1472
|
|
|
1099
|
-
|
|
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.
|
|
1100
1474
|
|
|
1101
|
-
|
|
1475
|
+
For UI caching and refetching, use TanStack Query *with* liaise; see the [recipe](#use-with-tanstack-query).
|
|
1102
1476
|
|
|
1103
|
-
|
|
1104
|
-
const impatient = api.getProduct({ id: '42' }, { timeout: 20 }) // gives up quickly
|
|
1105
|
-
const patient = api.getProduct({ id: '42' }) // keeps waiting
|
|
1477
|
+
For two or three calls, plain fetch is fine.
|
|
1106
1478
|
|
|
1107
|
-
|
|
1108
|
-
// patient still gets a real response.
|
|
1109
|
-
```
|
|
1479
|
+
liaise is a good fit when you have:
|
|
1110
1480
|
|
|
1111
|
-
|
|
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).
|
|
1112
1484
|
|
|
1113
|
-
|
|
1485
|
+
### How it compares
|
|
1114
1486
|
|
|
1115
|
-
|
|
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.
|
|
1116
1488
|
|
|
1117
|
-
|
|
1489
|
+
<!-- compare:start -->
|
|
1118
1490
|
|
|
1119
|
-
|
|
1120
|
-
// 1. Types are declared on the Request
|
|
1121
|
-
const getUser = new Request<{ id: string }, User>({
|
|
1122
|
-
method: 'GET',
|
|
1123
|
-
path: '/users/:id'
|
|
1124
|
-
})
|
|
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.
|
|
1125
1492
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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) |
|
|
1131
1505
|
|
|
1132
|
-
|
|
1133
|
-
const { data, error } = await api.getUser({ id: '42' })
|
|
1134
|
-
// ^? User | null
|
|
1135
|
-
```
|
|
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.
|
|
1136
1507
|
|
|
1137
|
-
|
|
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).
|
|
1138
1509
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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 |
|
|
1143
1518
|
|
|
1144
|
-
|
|
1519
|
+
fetch is built into the runtime; its row is the call site only, the floor rather than a library.
|
|
1145
1520
|
|
|
1146
|
-
|
|
1147
|
-
import type {
|
|
1148
|
-
Result,
|
|
1149
|
-
CallOptions,
|
|
1150
|
-
Middleware,
|
|
1151
|
-
MiddlewareContext,
|
|
1152
|
-
MiddlewareNext,
|
|
1153
|
-
RequestConfig,
|
|
1154
|
-
ApiConfig
|
|
1155
|
-
} from 'liaise'
|
|
1156
|
-
```
|
|
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.
|
|
1157
1522
|
|
|
1158
|
-
|
|
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).
|
|
1159
1524
|
|
|
1160
|
-
|
|
1525
|
+
<!-- compare:end -->
|
|
1161
1526
|
|
|
1162
|
-
|
|
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.
|
|
1163
1528
|
|
|
1164
|
-
|
|
1165
|
-
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.
|
|
1166
1530
|
|
|
1167
|
-
|
|
1168
|
-
id: string
|
|
1169
|
-
name: string
|
|
1170
|
-
status: string
|
|
1171
|
-
}
|
|
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.
|
|
1172
1532
|
|
|
1173
|
-
|
|
1174
|
-
query GetCategory($id: String!) {
|
|
1175
|
-
category(id: $id) {
|
|
1176
|
-
id
|
|
1177
|
-
name
|
|
1178
|
-
status
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
`
|
|
1533
|
+
### Where it runs
|
|
1182
1534
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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 |
|
|
1186
1540
|
|
|
1187
|
-
|
|
1188
|
-
endpoint: 'https://api.example.com/graphql',
|
|
1189
|
-
operations: { getCategory },
|
|
1190
|
-
onError: (error) => console.error(error.status, error.body),
|
|
1191
|
-
})
|
|
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'`.
|
|
1192
1542
|
|
|
1193
|
-
|
|
1194
|
-
```
|
|
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.
|
|
1195
1544
|
|
|
1196
|
-
|
|
1545
|
+
## Design principles
|
|
1197
1546
|
|
|
1198
|
-
|
|
1199
|
-
const getViewer = new Operation<Record<string, never>, ViewerData>({ operation: GET_VIEWER })
|
|
1200
|
-
const graphql = createGraphQL({ endpoint, operations: { getViewer } })
|
|
1547
|
+
### Never throws
|
|
1201
1548
|
|
|
1202
|
-
|
|
1203
|
-
|
|
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.
|
|
1204
1570
|
|
|
1205
|
-
|
|
1571
|
+
## Reference
|
|
1206
1572
|
|
|
1207
|
-
|
|
1573
|
+
Every option, type and export, read from the source. The guide explains when to use each one.
|
|
1574
|
+
|
|
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`.
|
|
1208
1645
|
|
|
1209
1646
|
```ts
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
updateCategory: new Operation<{ id: string; name: string }, Category>({
|
|
1217
|
-
operation: gql`
|
|
1218
|
-
mutation UpdateCategory($id: String!, $name: String!) {
|
|
1219
|
-
updateCategory(id: $id, name: $name) { id name status }
|
|
1220
|
-
}
|
|
1221
|
-
`,
|
|
1222
|
-
}),
|
|
1223
|
-
},
|
|
1224
|
-
})
|
|
1647
|
+
interface SuccessResult<TResponse> {
|
|
1648
|
+
data: TResponse
|
|
1649
|
+
error: null
|
|
1650
|
+
response: Response // always present on success
|
|
1651
|
+
retry: () => Promise<Result<TResponse>>
|
|
1652
|
+
}
|
|
1225
1653
|
|
|
1226
|
-
|
|
1227
|
-
|
|
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>
|
|
1228
1662
|
```
|
|
1229
1663
|
|
|
1230
|
-
|
|
1664
|
+
`response` is set for `'http'` and `'parse'` errors, where the server answered. It is `null` for `'network'`, `'abort'`, `'timeout'` and `'middleware'`.
|
|
1231
1665
|
|
|
1232
|
-
|
|
1666
|
+
`ApiError` is the `error` of a failed call. It is a plain class and doesn't extend `Error`.
|
|
1233
1667
|
|
|
1234
|
-
|
|
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. |
|
|
1235
1677
|
|
|
1236
1678
|
```ts
|
|
1237
|
-
|
|
1238
|
-
if (error) {
|
|
1239
|
-
console.log(error.body) // GraphQLError[]
|
|
1240
|
-
console.log(error.partialData) // whatever `data` the server sent alongside the errors, or undefined
|
|
1241
|
-
}
|
|
1679
|
+
type ApiErrorKind = 'http' | 'network' | 'abort' | 'timeout' | 'parse' | 'middleware'
|
|
1242
1680
|
```
|
|
1243
1681
|
|
|
1244
|
-
|
|
1682
|
+
You can check for an `ApiError` with `instanceof`:
|
|
1245
1683
|
|
|
1246
1684
|
```ts
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
console.
|
|
1685
|
+
import { ApiError } from 'liaise'
|
|
1686
|
+
|
|
1687
|
+
if (error instanceof ApiError) {
|
|
1688
|
+
console.error(error.kind, error.status)
|
|
1251
1689
|
}
|
|
1252
1690
|
```
|
|
1253
1691
|
|
|
1254
|
-
|
|
1692
|
+
### MiddlewareContext
|
|
1255
1693
|
|
|
1256
|
-
|
|
1694
|
+
The first argument of every middleware, `ctx`:
|
|
1257
1695
|
|
|
1258
|
-
|
|
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'`. |
|
|
1259
1706
|
|
|
1260
|
-
|
|
1707
|
+
How a replaced signal works with `share` and `dedupe` is under [Signal-replacing middleware](#signal-replacing-middleware).
|
|
1261
1708
|
|
|
1262
|
-
|
|
1263
|
-
const graphql = createGraphQL({
|
|
1264
|
-
endpoint: 'https://api.example.com/graphql',
|
|
1265
|
-
operations: { getCategory },
|
|
1266
|
-
middleware: [authMiddleware],
|
|
1267
|
-
})
|
|
1268
|
-
```
|
|
1709
|
+
### Built-in middleware options
|
|
1269
1710
|
|
|
1270
|
-
|
|
1711
|
+
#### RetryOptions
|
|
1271
1712
|
|
|
1272
|
-
|
|
1273
|
-
| ------------------- | -------- | ---------------------------------------------------------------------- |
|
|
1274
|
-
| `createGraphQL` | function | Creates a typed GraphQL client from a config of Operation definitions |
|
|
1275
|
-
| `Operation` | class | Typed operation definition -- one instance per GraphQL operation |
|
|
1276
|
-
| `gql` | const | Tagged template literal for GraphQL documents (editor tooling support) |
|
|
1277
|
-
| `OperationConfig` | type | Config object for the `Operation` constructor |
|
|
1278
|
-
| `GraphQLBaseConfig` | type | Config object for `createGraphQL` |
|
|
1279
|
-
| `GraphQLError` | type | Shape of a single GraphQL error from `{ errors: [...] }` |
|
|
1713
|
+
`retryMiddleware(n)` is short for `retryMiddleware({ max: n })`, and `retryMiddleware()` means `{ max: 3 }`.
|
|
1280
1714
|
|
|
1281
|
-
|
|
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. |
|
|
1282
1725
|
|
|
1283
|
-
`
|
|
1726
|
+
`RetryInfo`, the argument to `onRetry`:
|
|
1284
1727
|
|
|
1285
|
-
|
|
1286
|
-
|
|
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. |
|
|
1287
1734
|
|
|
1288
|
-
|
|
1289
|
-
'GET /api/users/:id': ({ params }) => jsonResponse({ id: params.id, name: 'Ada' }),
|
|
1290
|
-
'POST /api/users': jsonResponse({ id: 'new-user' }, { status: 201 }),
|
|
1291
|
-
})
|
|
1735
|
+
#### Cache options
|
|
1292
1736
|
|
|
1293
|
-
|
|
1294
|
-
// ... exercise your code, which calls the real api.getUser(...) ...
|
|
1295
|
-
mock.restore() // puts the original globalThis.fetch back
|
|
1296
|
-
```
|
|
1737
|
+
`cacheMiddleware(options)` returns a middleware with a `clear()` method, typed `CacheMiddleware`.
|
|
1297
1738
|
|
|
1298
|
-
|
|
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. |
|
|
1299
1744
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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)
|
|
1304
1755
|
```
|
|
1305
1756
|
|
|
1306
|
-
|
|
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`. |
|
|
1771
|
+
|
|
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`**
|
|
1307
1786
|
|
|
1308
|
-
|
|
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:
|
|
1309
1863
|
|
|
1310
1864
|
```ts
|
|
1311
|
-
const
|
|
1312
|
-
|
|
1313
|
-
|
|
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.
|
|
1314
1871
|
```
|
|
1315
1872
|
|
|
1316
|
-
|
|
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`.
|
|
1317
1874
|
|
|
1318
|
-
|
|
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.
|
|
1319
1876
|
|
|
1320
|
-
|
|
1321
|
-
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.
|
|
1322
1878
|
|
|
1323
|
-
|
|
1324
|
-
vi.spyOn(api, 'getUser').mockResolvedValue(errorResult(404, { message: 'not found' }))
|
|
1325
|
-
```
|
|
1879
|
+
#### Abort classification
|
|
1326
1880
|
|
|
1327
|
-
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'`.
|
|
1328
1882
|
|
|
1329
|
-
|
|
1330
|
-
- **`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`.
|
|
1331
|
-
- **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.
|
|
1332
|
-
- **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.
|
|
1333
|
-
- **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'`.
|
|
1334
1884
|
|
|
1335
|
-
|
|
1885
|
+
#### baseUrl query merging
|
|
1336
1886
|
|
|
1337
|
-
|
|
1887
|
+
A `baseUrl` can carry its own query string, such as a fixed `api-version`. Its params go in front of the call's:
|
|
1338
1888
|
|
|
1339
|
-
|
|
1889
|
+
```ts
|
|
1890
|
+
import { createApi, defineRequest } from 'liaise'
|
|
1340
1891
|
|
|
1341
|
-
|
|
1892
|
+
type Hit = { id: string }
|
|
1342
1893
|
|
|
1343
|
-
|
|
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 } })
|
|
1344
1896
|
|
|
1345
|
-
|
|
1897
|
+
await api.search({ q: 'hello' })
|
|
1898
|
+
// GET https://api.example.com/v1/search?api-version=2&q=hello
|
|
1899
|
+
```
|
|
1346
1900
|
|
|
1347
|
-
|
|
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.
|
|
1348
1902
|
|
|
1349
|
-
|
|
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.
|
|
1350
1904
|
|
|
1351
|
-
|
|
1905
|
+
#### URL fragments
|
|
1352
1906
|
|
|
1353
|
-
|
|
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.
|
|
1354
1908
|
|
|
1355
|
-
|
|
1909
|
+
`defineRequest` also refuses a fragment in a `path` literal, at compile time:
|
|
1356
1910
|
|
|
1357
|
-
|
|
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
|
+
```
|
|
1358
1916
|
|
|
1359
|
-
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.
|
|
1360
1918
|
|
|
1361
|
-
|
|
1919
|
+
A `#` inside a param value is data. It is escaped to `%23` and sent:
|
|
1362
1920
|
|
|
1363
|
-
|
|
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
|
+
```
|
|
1364
1925
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
| `Result` | type | Discriminated union of every API call's outcome: `SuccessResult<T> \| ErrorResult<T>` |
|
|
1381
|
-
| `SuccessResult` | type | The success branch of `Result`: `{ data: T, error: null, response: Response, retry }` |
|
|
1382
|
-
| `ErrorResult` | type | The error branch of `Result`: `{ data: null, error: ApiError, response: Response \| null, retry }` |
|
|
1383
|
-
| `Middleware` | type | Middleware function signature: `(ctx, next) => Promise<Result>` |
|
|
1384
|
-
| `MiddlewareContext` | type | Request context passed to middleware |
|
|
1385
|
-
| `MiddlewareNext` | type | The `next` function passed to middleware |
|
|
1386
|
-
| `createGraphQL` | function | Creates a typed GraphQL client from a config of Operation definitions |
|
|
1387
|
-
| `Operation` | class | Typed operation definition -- one instance per GraphQL operation |
|
|
1388
|
-
| `gql` | const | Tagged template literal for GraphQL documents (editor tooling support) |
|
|
1389
|
-
| `OperationConfig` | type | Config object for the `Operation` constructor |
|
|
1390
|
-
| `GraphQLBaseConfig` | type | Config object for `createGraphQL` |
|
|
1391
|
-
| `GraphQLError` | type | Shape of a single GraphQL error from `{ errors: [...] }` |
|
|
1392
|
-
|
|
1393
|
-
### Built-in middleware (`liaise/middleware`)
|
|
1394
|
-
|
|
1395
|
-
| Export | Kind | Description |
|
|
1396
|
-
| ----------------- | -------- | ------------------------------------------------------------- |
|
|
1397
|
-
| `retryMiddleware` | function | Factory that returns middleware retrying on 5xx by default, with a configurable backoff policy |
|
|
1398
|
-
| `RetryOptions` | type | Options object accepted by `retryMiddleware` (`max`, `delay`, `baseDelay`, `maxDelay`, `jitter`, `respectRetryAfter`, `retryOn`, `onRetry`) |
|
|
1399
|
-
| `RetryInfo` | type | Shape of the argument passed to `RetryOptions.onRetry` |
|
|
1400
|
-
| `logMiddleware` | const | Middleware that logs request lifecycle to the console |
|
|
1401
|
-
| `cacheMiddleware` | function | Factory that returns a per-request in-memory cache with `clear()` |
|
|
1402
|
-
| `CacheMiddleware` | type | Return type of `cacheMiddleware()` -- a `Middleware` with an attached `clear()` |
|
|
1403
|
-
|
|
1404
|
-
### Testing (`liaise/testing`)
|
|
1405
|
-
|
|
1406
|
-
| Export | Kind | Description |
|
|
1407
|
-
| --------------- | -------- | ------------------------------------------------------------------ |
|
|
1408
|
-
| `mockFetch` | function | Builds a route-matching `fetch` stub, with `install()`/`restore()`, call recording, and response sequencing |
|
|
1409
|
-
| `jsonResponse` | function | Builds a `Response` with a JSON body and a `content-type` header, for use as a route value |
|
|
1410
|
-
| `successResult` | function | Builds a well-formed success `Result<T>` directly, for stubbing at the `Result` level |
|
|
1411
|
-
| `errorResult` | function | Builds a well-formed error `Result<T>` with a given HTTP status, for stubbing at the `Result` level |
|
|
1412
|
-
| `RouteContext` | type | `{ params, request }` passed to a route handler function |
|
|
1413
|
-
| `RouteHandler` | type | `(ctx: RouteContext) => Response \| Promise<Response>` -- a route value that computes its response |
|
|
1414
|
-
| `RouteValue` | type | `Response \| RouteHandler \| Array<Response \| RouteHandler>` -- anything a route key can map to |
|
|
1415
|
-
| `RecordedCall` | type | `{ method, url, headers, body }` -- shape of each entry in `mock.calls` |
|
|
1416
|
-
|
|
1417
|
-
## Contributing
|
|
1418
|
-
|
|
1419
|
-
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.
|
|
1420
|
-
|
|
1421
|
-
## License
|
|
1422
|
-
|
|
1423
|
-
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).
|