@vahidkaargar/customized-api-client 0.2.1 → 0.2.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/LICENSE +21 -0
- package/README.md +271 -410
- package/dist/index.cjs +24 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +24 -30
- package/dist/index.js.map +1 -1
- package/package.json +14 -15
package/README.md
CHANGED
|
@@ -1,37 +1,26 @@
|
|
|
1
1
|
# `@vahidkaargar/customized-api-client`
|
|
2
2
|
|
|
3
|
-
TypeScript **Axios** client for **JSON:API v1.1** APIs
|
|
3
|
+
TypeScript **Axios** client for **JSON:API v1.1** APIs — Bearer auth, mandatory mutation idempotency, optimistic concurrency, retries, and **typed success/error results** so you rarely touch raw Axios responses.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
| | |
|
|
6
|
+
|---|---|
|
|
7
|
+
| **Node** | ≥ 20 |
|
|
8
|
+
| **Modules** | ESM + CJS |
|
|
9
|
+
| **Changelog** | [CHANGELOG.md](./CHANGELOG.md) |
|
|
10
|
+
| **License** | MIT |
|
|
6
11
|
|
|
7
12
|
---
|
|
8
13
|
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
9. [Optimistic concurrency](#optimistic-concurrency)
|
|
20
|
-
10. [Retries](#retries)
|
|
21
|
-
11. [Pagination and query parameters](#pagination-and-query-parameters)
|
|
22
|
-
12. [Included resources](#included-resources)
|
|
23
|
-
13. [Async jobs (202) and polling](#async-jobs-202-and-polling)
|
|
24
|
-
14. [Bulk operations (207)](#bulk-operations-207)
|
|
25
|
-
15. [Response key transformation](#response-key-transformation)
|
|
26
|
-
16. [Guards and validation helpers](#guards-and-validation-helpers)
|
|
27
|
-
17. [Security and logging](#security-and-logging)
|
|
28
|
-
18. [Health checks](#health-checks)
|
|
29
|
-
19. [Typing your API](#typing-your-api)
|
|
30
|
-
20. [Advanced / low-level exports](#advanced--low-level-exports)
|
|
31
|
-
21. [API reference (exports)](#api-reference-exports)
|
|
32
|
-
22. [Development](#development-this-repository)
|
|
33
|
-
23. [Supply chain](#supply-chain)
|
|
34
|
-
24. [Publishing](#publishing-maintainers)
|
|
14
|
+
## What you get
|
|
15
|
+
|
|
16
|
+
- **JSON:API headers** — `Content-Type` / `Accept: application/vnd.api+json` on requests
|
|
17
|
+
- **Normalized responses** — narrow on `res.kind` (`jsonapi-success`, `no-content`, `accepted`, `multi-status`)
|
|
18
|
+
- **Errors** — `ApiClientError` with `status`, `primaryCode`, `errors[]`, safe `toJSON()` for logs
|
|
19
|
+
- **Idempotency** — `Idempotency-Key` on POST/PATCH/PUT/DELETE (ULID by default)
|
|
20
|
+
- **Concurrency** — `If-Match: "v=<n>"` via `patchWithVersion` or `ifMatchVersion`
|
|
21
|
+
- **Retries** — explicit policy (safe GET/HEAD vs mutations); honors `Retry-After`
|
|
22
|
+
- **Two ergonomics** — throwing verbs **or** `safe*` methods returning `{ ok, value \| error }`
|
|
23
|
+
- **Cancellation** — optional `AbortSignal` per request
|
|
35
24
|
|
|
36
25
|
---
|
|
37
26
|
|
|
@@ -49,13 +38,10 @@ import { createApiClient } from '@vahidkaargar/customized-api-client';
|
|
|
49
38
|
|
|
50
39
|
## Quick start
|
|
51
40
|
|
|
41
|
+
**1. Create a client** (Mode B: `baseURL` already includes `/api/v1`):
|
|
42
|
+
|
|
52
43
|
```typescript
|
|
53
|
-
import {
|
|
54
|
-
createApiClient,
|
|
55
|
-
ApiClientError,
|
|
56
|
-
getNextPageUrl,
|
|
57
|
-
readResourceVersion,
|
|
58
|
-
} from '@vahidkaargar/customized-api-client';
|
|
44
|
+
import { createApiClient, ApiClientError } from '@vahidkaargar/customized-api-client';
|
|
59
45
|
|
|
60
46
|
const client = createApiClient({
|
|
61
47
|
baseURL: 'https://api.example.com/api/v1',
|
|
@@ -64,13 +50,15 @@ const client = createApiClient({
|
|
|
64
50
|
getToken: async () => sessionStorage.getItem('access_token'),
|
|
65
51
|
},
|
|
66
52
|
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**2. Call the API** (default: throws `ApiClientError` on HTTP errors):
|
|
67
56
|
|
|
57
|
+
```typescript
|
|
68
58
|
try {
|
|
69
59
|
const res = await client.get('/widgets');
|
|
70
60
|
if (res.kind === 'jsonapi-success') {
|
|
71
|
-
|
|
72
|
-
const next = getNextPageUrl(doc.links);
|
|
73
|
-
// ...
|
|
61
|
+
console.log(res.document.data);
|
|
74
62
|
}
|
|
75
63
|
} catch (e) {
|
|
76
64
|
if (e instanceof ApiClientError) {
|
|
@@ -80,269 +68,273 @@ try {
|
|
|
80
68
|
}
|
|
81
69
|
```
|
|
82
70
|
|
|
83
|
-
|
|
71
|
+
**3. Prefer no `try/catch` for expected errors?** Use `safe*` — see [Error handling](#error-handling).
|
|
84
72
|
|
|
85
|
-
|
|
73
|
+
---
|
|
86
74
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
| Option | Type | Default | Description |
|
|
90
|
-
|--------|------|---------|-------------|
|
|
91
|
-
| `baseURL` | `string` | **required** | API origin; see [Base URL modes](#base-url-modes). |
|
|
92
|
-
| `baseUrlMode` | `'modeB' \| 'modeA'` | `'modeB'` | How relative paths are joined with `baseURL`. |
|
|
93
|
-
| `auth` | `AuthConfig` | — | Bearer token or partner secret provider. |
|
|
94
|
-
| `getAcceptLanguage` | `() => string \| null \| …` | — | Sets `Accept-Language` when returned value is non-empty. |
|
|
95
|
-
| `defaultHeaders` | `Record<string, string>` | — | Merged into every request (after JSON:API defaults). |
|
|
96
|
-
| `timeout` | `number` | `30000` | Axios timeout in ms. |
|
|
97
|
-
| `retry` | `RetryOptions` | see [Retries](#retries) | Bounded retry with backoff. |
|
|
98
|
-
| `generateIdempotencyKey` | `() => string` | ULID | Custom idempotency key factory for mutations. |
|
|
99
|
-
| `onIdempotencyReplay` | `(ctx) => void` | — | Called when response has `Idempotent-Replayed: true`. |
|
|
100
|
-
| `onUnauthorized` | `(error) => void \| Promise<void>` | — | Called when a response normalizes to **401**. |
|
|
101
|
-
| `onDeprecated` | `(info) => void` | — | Called when deprecation/sunset headers are present. |
|
|
102
|
-
| `transformResponseKeys` | `'none' \| 'camelCase-attributes-meta'` | `'none'` | Optional camelCase on `attributes` / `meta` only. |
|
|
103
|
-
| `maxBodyLogLength` | `number` | — | Documented cap for logging; use `truncateForLog` in app code. |
|
|
75
|
+
## Guide for application developers
|
|
104
76
|
|
|
105
|
-
###
|
|
77
|
+
### Pick a base URL mode
|
|
106
78
|
|
|
107
|
-
|
|
79
|
+
| Mode | `baseURL` example | Path you pass | Result |
|
|
80
|
+
|------|-------------------|---------------|--------|
|
|
81
|
+
| **B** (default) | `https://api.example.com/api/v1` | `/widgets` | `…/api/v1/widgets` |
|
|
82
|
+
| **A** | `https://api.example.com` + `baseUrlMode: 'modeA'` | `/widgets` | `…/api/v1/widgets` |
|
|
108
83
|
|
|
109
|
-
|
|
110
|
-
// User session token
|
|
111
|
-
auth: { type: 'bearer', getToken: () => getAccessToken() }
|
|
84
|
+
Duplicate `/api/v1` in path + base is stripped automatically in Mode B.
|
|
112
85
|
|
|
113
|
-
|
|
114
|
-
auth: { type: 'partner-bearer', getSecret: () => process.env.PARTNER_SECRET }
|
|
115
|
-
```
|
|
86
|
+
**Absolute URLs:** `getByUrl(links.next)` or `request({ url: 'https://…' })` — still get auth, JSON:API headers, and retries.
|
|
116
87
|
|
|
117
|
-
|
|
88
|
+
### Pick error handling
|
|
118
89
|
|
|
119
|
-
|
|
90
|
+
| Style | When to use | Example |
|
|
91
|
+
|-------|-------------|---------|
|
|
92
|
+
| **Throwing** (`get`, `post`, …) | Happy path + rare errors; familiar Axios flow | `try { await client.get(…) } catch (e) { … }` |
|
|
93
|
+
| **Safe** (`safeGet`, `safePost`, …) | Branch on status without `try/catch` | `const r = await client.safeGet(…); if (!r.ok) …` |
|
|
120
94
|
|
|
121
|
-
|
|
95
|
+
Every throwing method has a `safe*` twin, including `safeGetByUrl` and `safePatchWithVersion`.
|
|
122
96
|
|
|
123
|
-
|
|
97
|
+
Non-`ApiClientError` failures (bugs, abort, network quirks) still **throw** from `safe*`.
|
|
124
98
|
|
|
125
|
-
|
|
99
|
+
### Per-request options
|
|
126
100
|
|
|
127
101
|
```typescript
|
|
128
|
-
|
|
129
|
-
await client.get('/admin/teams'); // → https://api.example.com/api/v1/admin/teams
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
If a path accidentally repeats `/api/v1` while `baseURL` already ends with `/api/v1`, the client strips the duplicate segment.
|
|
102
|
+
const controller = new AbortController();
|
|
133
103
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
createApiClient({
|
|
140
|
-
baseURL: 'https://api.example.com',
|
|
141
|
-
baseUrlMode: 'modeA',
|
|
104
|
+
await client.patch('/widgets/42', body, {
|
|
105
|
+
idempotencyKey: 'stable-key-for-this-action', // optional; max 64 chars
|
|
106
|
+
ifMatchVersion: 7, // sets If-Match: "v=7"
|
|
107
|
+
signal: controller.signal, // cancel with controller.abort()
|
|
142
108
|
});
|
|
143
|
-
await client.get('/teams'); // → https://api.example.com/api/v1/teams
|
|
144
109
|
```
|
|
145
110
|
|
|
146
|
-
### Absolute URLs
|
|
147
|
-
|
|
148
|
-
`getByUrl(fullUrl)` and `request({ url: 'https://…' })` use the URL as-is (still through interceptors: auth, JSON:API headers, retries).
|
|
149
|
-
|
|
150
111
|
---
|
|
151
112
|
|
|
152
|
-
##
|
|
113
|
+
## Recipes
|
|
153
114
|
|
|
154
|
-
|
|
115
|
+
Copy-paste patterns for everyday work.
|
|
155
116
|
|
|
156
|
-
|
|
157
|
-
|--------|------|--------|
|
|
158
|
-
| `get(path, opts?)` | GET | |
|
|
159
|
-
| `head(path, opts?)` | HEAD | Often `no-content` (204). |
|
|
160
|
-
| `post(path, data?, opts?)` | POST | Sends idempotency key. |
|
|
161
|
-
| `patch(path, data?, opts?)` | PATCH | Sends idempotency key. |
|
|
162
|
-
| `put(path, data?, opts?)` | PUT | Sends idempotency key. |
|
|
163
|
-
| `delete(path, opts?)` | DELETE | Sends idempotency key. |
|
|
164
|
-
| `patchWithVersion(path, data, version, opts?)` | PATCH | Sets `If-Match: "v=<version>"`. |
|
|
165
|
-
| `getByUrl(fullUrl, opts?)` | GET | Follow `links.next` or external URLs. |
|
|
166
|
-
| `request(axConfig, opts?)` | any | Escape hatch; merges `opts` with Axios config. |
|
|
167
|
-
|
|
168
|
-
Per-call options (`RequestCallOptions`):
|
|
117
|
+
### List resources and follow `links.next`
|
|
169
118
|
|
|
170
119
|
```typescript
|
|
171
|
-
|
|
172
|
-
idempotencyKey: 'my-stable-key', // max 64 chars
|
|
173
|
-
ifMatchVersion: 3,
|
|
174
|
-
});
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
### Safe variants (no throw on `ApiClientError`)
|
|
120
|
+
import { getNextPageUrl } from '@vahidkaargar/customized-api-client';
|
|
178
121
|
|
|
179
|
-
|
|
122
|
+
let res = await client.get('/widgets');
|
|
123
|
+
while (res.kind === 'jsonapi-success') {
|
|
124
|
+
// use res.document.data …
|
|
125
|
+
const next = getNextPageUrl(res.document.links);
|
|
126
|
+
if (!next) break;
|
|
127
|
+
res = await client.getByUrl(next);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
180
130
|
|
|
181
|
-
|
|
131
|
+
### Create (POST) with a stable idempotency key
|
|
182
132
|
|
|
183
133
|
```typescript
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
throw r.error;
|
|
188
|
-
}
|
|
189
|
-
const doc = r.value.kind === 'jsonapi-success' ? r.value.document : undefined;
|
|
134
|
+
await client.post('/widgets', payload, {
|
|
135
|
+
idempotencyKey: `create-widget-${userId}-${draftId}`,
|
|
136
|
+
});
|
|
190
137
|
```
|
|
191
138
|
|
|
192
|
-
|
|
139
|
+
### Update with optimistic locking
|
|
193
140
|
|
|
194
|
-
|
|
141
|
+
```typescript
|
|
142
|
+
import { readResourceVersion } from '@vahidkaargar/customized-api-client';
|
|
195
143
|
|
|
196
|
-
|
|
144
|
+
const current = await client.get('/widgets/42');
|
|
145
|
+
if (current.kind !== 'jsonapi-success' || Array.isArray(current.document.data)) {
|
|
146
|
+
throw new Error('expected single resource');
|
|
147
|
+
}
|
|
148
|
+
const version = readResourceVersion(current.document.data, current.headers.etag);
|
|
197
149
|
|
|
198
|
-
|
|
150
|
+
await client.patchWithVersion('/widgets/42', payload, version);
|
|
151
|
+
// or: client.patch('/widgets/42', payload, { ifMatchVersion: version })
|
|
152
|
+
```
|
|
199
153
|
|
|
200
|
-
###
|
|
154
|
+
### Handle validation errors (422)
|
|
201
155
|
|
|
202
156
|
```typescript
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
157
|
+
import {
|
|
158
|
+
groupValidationErrorsByPointer,
|
|
159
|
+
isValidationError,
|
|
160
|
+
} from '@vahidkaargar/customized-api-client';
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await client.post('/widgets', payload);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
if (e instanceof ApiClientError && isValidationError(e)) {
|
|
166
|
+
const byField = groupValidationErrorsByPointer(e.errors);
|
|
167
|
+
// { '/data/attributes/name': ['too short'], … }
|
|
168
|
+
}
|
|
209
169
|
}
|
|
210
170
|
```
|
|
211
171
|
|
|
212
|
-
###
|
|
172
|
+
### Safe GET when 404 is normal
|
|
213
173
|
|
|
214
174
|
```typescript
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
175
|
+
const r = await client.safeGet('/widgets/unknown-id');
|
|
176
|
+
if (!r.ok && r.error.status === 404) return null;
|
|
177
|
+
if (!r.ok) throw r.error;
|
|
178
|
+
return r.value.kind === 'jsonapi-success' ? r.value.document : undefined;
|
|
219
179
|
```
|
|
220
180
|
|
|
221
|
-
###
|
|
181
|
+
### Cancel an in-flight request
|
|
222
182
|
|
|
223
183
|
```typescript
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
184
|
+
const controller = new AbortController();
|
|
185
|
+
const promise = client.get('/slow-report', { signal: controller.signal });
|
|
186
|
+
setTimeout(() => controller.abort(), 5_000);
|
|
187
|
+
await promise; // throws when aborted
|
|
228
188
|
```
|
|
229
189
|
|
|
230
|
-
###
|
|
190
|
+
### Poll an async job (202)
|
|
231
191
|
|
|
232
192
|
```typescript
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
193
|
+
import { pollAsyncResult } from '@vahidkaargar/customized-api-client';
|
|
194
|
+
|
|
195
|
+
const accepted = await client.post('/jobs', jobPayload);
|
|
196
|
+
if (accepted.kind !== 'accepted') throw new Error('expected 202');
|
|
197
|
+
|
|
198
|
+
const done = await pollAsyncResult(client, accepted, {
|
|
199
|
+
maxAttempts: 10,
|
|
200
|
+
delayMs: 500,
|
|
201
|
+
});
|
|
239
202
|
```
|
|
240
203
|
|
|
241
|
-
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Configuration
|
|
207
|
+
|
|
208
|
+
Client-level options for `createApiClient({ … })`:
|
|
209
|
+
|
|
210
|
+
| Option | Default | Description |
|
|
211
|
+
|--------|---------|-------------|
|
|
212
|
+
| `baseURL` | **required** | API origin — [base URL modes](#pick-a-base-url-mode) |
|
|
213
|
+
| `baseUrlMode` | `'modeB'` | `'modeB'` \| `'modeA'` |
|
|
214
|
+
| `auth` | — | `{ type: 'bearer', getToken }` or `{ type: 'partner-bearer', getSecret }` |
|
|
215
|
+
| `timeout` | `30000` | Axios timeout (ms) |
|
|
216
|
+
| `defaultHeaders` | — | Merged into every request |
|
|
217
|
+
| `retry` | see [Retries](#retries) | `maxAttempts`, backoff, jitter |
|
|
218
|
+
| `generateIdempotencyKey` | ULID | Factory for mutation keys |
|
|
219
|
+
| `getAcceptLanguage` | — | Sets `Accept-Language` when non-empty |
|
|
220
|
+
| `onIdempotencyReplay` | — | Fired when `Idempotent-Replayed: true` |
|
|
221
|
+
| `onUnauthorized` | — | Fired on normalized **401** |
|
|
222
|
+
| `onDeprecated` | — | Deprecation / sunset headers |
|
|
223
|
+
| `transformResponseKeys` | `'none'` | `'camelCase-attributes-meta'` on response only |
|
|
224
|
+
| `maxBodyLogLength` | — | Hint for app logging; use `truncateForLog` |
|
|
225
|
+
|
|
226
|
+
### Authentication
|
|
227
|
+
|
|
228
|
+
User and partner credentials both use **`Authorization: Bearer …`**.
|
|
242
229
|
|
|
243
230
|
```typescript
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
res.headers.idempotentReplayed: boolean;
|
|
247
|
-
res.headers.retryAfterSeconds?: number;
|
|
231
|
+
auth: { type: 'bearer', getToken: () => getAccessToken() }
|
|
232
|
+
auth: { type: 'partner-bearer', getSecret: () => process.env.PARTNER_SECRET }
|
|
248
233
|
```
|
|
249
234
|
|
|
235
|
+
If `getToken` / `getSecret` returns `null` or `undefined`, no `Authorization` header is sent.
|
|
236
|
+
|
|
250
237
|
---
|
|
251
238
|
|
|
252
|
-
##
|
|
239
|
+
## Making requests
|
|
253
240
|
|
|
254
|
-
###
|
|
241
|
+
### Throwing methods
|
|
255
242
|
|
|
256
|
-
|
|
243
|
+
| Method | HTTP | Notes |
|
|
244
|
+
|--------|------|--------|
|
|
245
|
+
| `get` / `head` | GET / HEAD | No idempotency key |
|
|
246
|
+
| `post` / `patch` / `put` / `delete` | … | Sends `Idempotency-Key` |
|
|
247
|
+
| `patchWithVersion(path, data, version)` | PATCH | Sets `If-Match` |
|
|
248
|
+
| `getByUrl(fullUrl)` | GET | Absolute URL (e.g. `links.next`) |
|
|
249
|
+
| `request(axConfig, opts?)` | any | Low-level escape hatch |
|
|
257
250
|
|
|
258
|
-
|
|
259
|
-
import { ApiClientError, isApiClientError } from '@vahidkaargar/customized-api-client';
|
|
251
|
+
### Safe methods
|
|
260
252
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
e.requestMethod; // e.g. 'PATCH'
|
|
270
|
-
e.responseHeaders; // redacted in toJSON()
|
|
271
|
-
console.log(e.toJSON()); // safe for logs (secrets redacted)
|
|
272
|
-
}
|
|
253
|
+
`safeGet`, `safeHead`, `safePost`, `safePatch`, `safePut`, `safeDelete`, `safeRequest`, `safeGetByUrl`, `safePatchWithVersion`
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
const r = await client.safePatchWithVersion('/widgets/1', body, 3);
|
|
257
|
+
if (r.ok) {
|
|
258
|
+
console.log(r.value);
|
|
259
|
+
} else {
|
|
260
|
+
console.log(r.error.status, r.error.primaryCode);
|
|
273
261
|
}
|
|
274
262
|
```
|
|
275
263
|
|
|
276
|
-
|
|
264
|
+
---
|
|
277
265
|
|
|
278
|
-
|
|
266
|
+
## Success results (`ClientSuccess`)
|
|
279
267
|
|
|
280
|
-
|
|
281
|
-
import {
|
|
282
|
-
groupValidationErrorsByPointer,
|
|
283
|
-
isValidationError,
|
|
284
|
-
} from '@vahidkaargar/customized-api-client';
|
|
268
|
+
Narrow on `kind`:
|
|
285
269
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
270
|
+
| `kind` | Status | Main fields |
|
|
271
|
+
|--------|--------|-------------|
|
|
272
|
+
| `jsonapi-success` | 200, 201 | `document`, `headers` |
|
|
273
|
+
| `no-content` | 204 | `headers` |
|
|
274
|
+
| `accepted` | 202 | `location`, `rawBody?` |
|
|
275
|
+
| `multi-status` | 207 | `items[]` with `httpStatus`, `body` |
|
|
276
|
+
|
|
277
|
+
All success kinds expose `headers.etag`, `headers.contentLanguage`, `headers.idempotentReplayed`, and optional `headers.retryAfterSeconds`.
|
|
291
278
|
|
|
292
279
|
---
|
|
293
280
|
|
|
294
|
-
##
|
|
281
|
+
## Error handling
|
|
295
282
|
|
|
296
|
-
|
|
297
|
-
- Override per call: `opts.idempotencyKey` (non-empty, max **64** characters).
|
|
298
|
-
- Replace generator: `generateIdempotencyKey: () => myKey()`.
|
|
299
|
-
- On retry, the **same key and body** are reused.
|
|
300
|
-
- When the server replays a prior result, response header **`Idempotent-Replayed: true`** sets `headers.idempotentReplayed` and invokes **`onIdempotencyReplay`**:
|
|
283
|
+
### `ApiClientError` (default on throwing methods)
|
|
301
284
|
|
|
302
|
-
|
|
303
|
-
onIdempotencyReplay: ({ url, method }) => {
|
|
304
|
-
metrics.increment('idempotency_replay');
|
|
305
|
-
},
|
|
306
|
-
```
|
|
285
|
+
HTTP **≥ 400** and malformed JSON:API error bodies throw **`ApiClientError`**.
|
|
307
286
|
|
|
308
|
-
|
|
287
|
+
| Field | Meaning |
|
|
288
|
+
|-------|---------|
|
|
289
|
+
| `status` | HTTP status |
|
|
290
|
+
| `primaryCode` | First `errors[].code` |
|
|
291
|
+
| `errors` | `JsonApiErrorObject[]` |
|
|
292
|
+
| `retryAfterSeconds` | From `Retry-After` when parseable |
|
|
293
|
+
| `requestMethod` | e.g. `'PATCH'` |
|
|
294
|
+
| `toJSON()` | Log-safe (secrets redacted) |
|
|
295
|
+
|
|
296
|
+
Synthetic codes when the body is missing or invalid: `EMPTY_ERROR_BODY`, `INVALID_JSON`, `MISSING_ERRORS_ARRAY`.
|
|
297
|
+
|
|
298
|
+
### Guards
|
|
299
|
+
|
|
300
|
+
| Helper | True when |
|
|
301
|
+
|--------|-----------|
|
|
302
|
+
| `isAuthenticationError` | 401 |
|
|
303
|
+
| `isForbiddenError` | 403 |
|
|
304
|
+
| `isValidationError` | 422 |
|
|
305
|
+
| `isPreconditionRequiredError` | 428 |
|
|
306
|
+
| `isPreconditionFailedError` | 412 |
|
|
307
|
+
| `isConflictError` | 409 |
|
|
308
|
+
| `isPayloadTooLargeError` | 413 |
|
|
309
|
+
| `isRetryablePerPolicy` | Would retry per client policy (UI hints) |
|
|
309
310
|
|
|
310
311
|
---
|
|
311
312
|
|
|
312
|
-
##
|
|
313
|
+
## Idempotency
|
|
313
314
|
|
|
314
|
-
|
|
315
|
+
- **POST, PATCH, PUT, DELETE** send **`Idempotency-Key`** (ULID per request by default).
|
|
316
|
+
- Retries reuse the **same key and body**.
|
|
317
|
+
- Server replay → header `Idempotent-Replayed: true` → `headers.idempotentReplayed` + optional `onIdempotencyReplay`.
|
|
318
|
+
- **GET / HEAD** never send idempotency keys.
|
|
315
319
|
|
|
316
|
-
|
|
317
|
-
// Convenience helper
|
|
318
|
-
await client.patchWithVersion('/widgets/42', body, 7);
|
|
320
|
+
---
|
|
319
321
|
|
|
320
|
-
|
|
321
|
-
await client.patch('/widgets/42', body, { ifMatchVersion: 7 });
|
|
322
|
-
```
|
|
322
|
+
## Optimistic concurrency
|
|
323
323
|
|
|
324
|
-
|
|
324
|
+
Send **`If-Match: "v=<n>"`** when the resource is versioned:
|
|
325
325
|
|
|
326
326
|
```typescript
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const res = await client.get('/widgets/42');
|
|
330
|
-
if (res.kind !== 'jsonapi-success' || !res.document.data || Array.isArray(res.document.data)) {
|
|
331
|
-
throw new Error('expected single resource');
|
|
332
|
-
}
|
|
333
|
-
const resource = res.document.data;
|
|
334
|
-
const version = readResourceVersion(resource, res.headers.etag);
|
|
335
|
-
// Prefer meta.version (number or numeric string), else ETag v=n
|
|
327
|
+
await client.patchWithVersion('/widgets/42', body, version);
|
|
336
328
|
```
|
|
337
329
|
|
|
338
|
-
|
|
330
|
+
Read version with `readResourceVersion(resource, etag)` — prefers `meta.version`, else ETag `v=n`.
|
|
331
|
+
|
|
332
|
+
**412** / **428** are not auto-retried.
|
|
339
333
|
|
|
340
334
|
---
|
|
341
335
|
|
|
342
336
|
## Retries
|
|
343
337
|
|
|
344
|
-
Default `retry`:
|
|
345
|
-
|
|
346
338
|
| Field | Default |
|
|
347
339
|
|-------|---------|
|
|
348
340
|
| `maxAttempts` | `4` |
|
|
@@ -350,265 +342,139 @@ Default `retry`:
|
|
|
350
342
|
| `maxDelayMs` | `10000` |
|
|
351
343
|
| `jitterRatio` | `0.2` |
|
|
352
344
|
|
|
353
|
-
Honors **`Retry-After`** when present (seconds or HTTP-date).
|
|
354
|
-
|
|
355
|
-
### Policy summary
|
|
356
|
-
|
|
357
345
|
| Situation | Retried? |
|
|
358
346
|
|-----------|----------|
|
|
359
|
-
| Network error (no response) | Yes
|
|
347
|
+
| Network error (no response) | Yes |
|
|
360
348
|
| GET/HEAD **408, 429, 5xx** | Yes |
|
|
361
|
-
| GET/HEAD **401, 403, 412, 428
|
|
362
|
-
|
|
|
363
|
-
|
|
|
349
|
+
| GET/HEAD **401, 403, 412, 428**, validation 4xx | No |
|
|
350
|
+
| Mutations **5xx / 429** | No |
|
|
351
|
+
| Mutation **409** `IDEMPOTENCY_REQUEST_IN_PROGRESS` | Yes |
|
|
364
352
|
| **409** `IDEMPOTENCY_KEY_REUSED` | No |
|
|
365
|
-
| **401 / 403 / 412 / 428 / 422** etc. | No |
|
|
366
|
-
|
|
367
|
-
Disable retries: `retry: { maxAttempts: 1 }`.
|
|
368
353
|
|
|
369
|
-
|
|
354
|
+
Disable: `retry: { maxAttempts: 1 }`. Inspect logic: `retryAllowed({ … })`.
|
|
370
355
|
|
|
371
356
|
---
|
|
372
357
|
|
|
373
|
-
##
|
|
358
|
+
## More helpers
|
|
374
359
|
|
|
375
|
-
###
|
|
376
|
-
|
|
377
|
-
```typescript
|
|
378
|
-
import { getNextPageUrl, parsePaginationKind } from '@vahidkaargar/customized-api-client';
|
|
379
|
-
|
|
380
|
-
let res = await client.get('/widgets');
|
|
381
|
-
while (res.kind === 'jsonapi-success') {
|
|
382
|
-
const kind = parsePaginationKind(res.document.meta, res.document.links);
|
|
383
|
-
// kind: 'offset' | 'cursor' | 'unknown'
|
|
384
|
-
|
|
385
|
-
const next = getNextPageUrl(res.document.links);
|
|
386
|
-
if (!next) break;
|
|
387
|
-
res = await client.getByUrl(next);
|
|
388
|
-
}
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
`parsePaginationKind` understands:
|
|
392
|
-
|
|
393
|
-
- JSON:API `page[number]`, `page[cursor]`, `page[size]` in `links.next`
|
|
394
|
-
- Legacy `page` / `per_page` in URLs
|
|
395
|
-
- `meta` fields: `current_page`, `last_page`, `has_more`, `next_cursor`, etc.
|
|
396
|
-
|
|
397
|
-
### Building query strings
|
|
360
|
+
### Pagination and query building
|
|
398
361
|
|
|
399
362
|
```typescript
|
|
400
363
|
import {
|
|
401
364
|
buildJsonApiQuery,
|
|
402
365
|
buildOffsetPageParams,
|
|
403
366
|
buildCursorPageParams,
|
|
404
|
-
|
|
367
|
+
parsePaginationKind,
|
|
405
368
|
} from '@vahidkaargar/customized-api-client';
|
|
406
369
|
|
|
407
370
|
const params = {
|
|
408
371
|
...buildJsonApiQuery({
|
|
409
|
-
filter: { status: 'active'
|
|
410
|
-
sort: ['-created_at'
|
|
411
|
-
|
|
412
|
-
include: ['owner', 'tags'],
|
|
372
|
+
filter: { status: 'active' },
|
|
373
|
+
sort: ['-created_at'],
|
|
374
|
+
include: ['owner'],
|
|
413
375
|
}),
|
|
414
376
|
...buildOffsetPageParams({ number: 2, size: 50 }),
|
|
415
377
|
};
|
|
416
|
-
// page[size] is capped at DEFAULT_PAGE_SIZE_CAP (100)
|
|
417
378
|
|
|
418
|
-
await client.request({
|
|
419
|
-
method: 'GET',
|
|
420
|
-
url: '/widgets',
|
|
421
|
-
params,
|
|
422
|
-
});
|
|
379
|
+
await client.request({ method: 'GET', url: '/widgets', params });
|
|
423
380
|
```
|
|
424
381
|
|
|
425
|
-
|
|
426
|
-
const cursorParams = buildCursorPageParams({ cursor: 'abc123', size: 25 });
|
|
427
|
-
```
|
|
428
|
-
|
|
429
|
-
---
|
|
430
|
-
|
|
431
|
-
## Included resources
|
|
382
|
+
### Included resources
|
|
432
383
|
|
|
433
384
|
```typescript
|
|
434
385
|
import { indexIncluded, resolveIncluded } from '@vahidkaargar/customized-api-client';
|
|
435
386
|
|
|
436
|
-
const res = await client.get('/widgets/1?include=owner');
|
|
437
|
-
if (res.kind !== 'jsonapi-success') return;
|
|
438
|
-
|
|
439
387
|
const idx = indexIncluded(res.document.included);
|
|
440
|
-
const
|
|
441
|
-
const owner = resolveIncluded(ownerRef, idx);
|
|
442
|
-
```
|
|
443
|
-
|
|
444
|
-
---
|
|
445
|
-
|
|
446
|
-
## Async jobs (202) and polling
|
|
447
|
-
|
|
448
|
-
```typescript
|
|
449
|
-
import { pollAsyncResult } from '@vahidkaargar/customized-api-client';
|
|
450
|
-
|
|
451
|
-
const accepted = await client.post('/jobs', { data: { type: 'jobs', attributes: { … } } });
|
|
452
|
-
if (accepted.kind !== 'accepted') throw new Error('expected 202');
|
|
453
|
-
|
|
454
|
-
const done = await pollAsyncResult(client, accepted, {
|
|
455
|
-
maxAttempts: 10,
|
|
456
|
-
delayMs: 500,
|
|
457
|
-
});
|
|
458
|
-
// Polls GET on location until non-202 or max attempts (then throws)
|
|
388
|
+
const owner = resolveIncluded({ type: 'users', id: '9' }, idx);
|
|
459
389
|
```
|
|
460
390
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
---
|
|
464
|
-
|
|
465
|
-
## Bulk operations (207)
|
|
391
|
+
### Bulk 207
|
|
466
392
|
|
|
467
393
|
```typescript
|
|
468
394
|
const res = await client.post('/bulk/widgets', bulkPayload);
|
|
469
395
|
if (res.kind === 'multi-status') {
|
|
470
396
|
for (const item of res.items) {
|
|
471
|
-
if (item.httpStatus >= 400) {
|
|
472
|
-
// item.body may be a JSON:API error document
|
|
473
|
-
}
|
|
397
|
+
if (item.httpStatus >= 400) { /* item.body may be errors */ }
|
|
474
398
|
}
|
|
475
399
|
}
|
|
476
400
|
```
|
|
477
401
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
## Response key transformation
|
|
481
|
-
|
|
482
|
-
Wire format uses **snake_case** in JSON:API `attributes` / `meta`. Opt in to shallow **camelCase** on responses only:
|
|
402
|
+
### Response camelCase (opt-in)
|
|
483
403
|
|
|
484
404
|
```typescript
|
|
485
|
-
|
|
486
|
-
baseURL: '
|
|
405
|
+
createApiClient({
|
|
406
|
+
baseURL: '…',
|
|
487
407
|
transformResponseKeys: 'camelCase-attributes-meta',
|
|
488
408
|
});
|
|
489
|
-
|
|
490
|
-
const res = await client.get('/widgets/1');
|
|
491
|
-
if (res.kind === 'jsonapi-success' && !Array.isArray(res.document.data) && res.document.data) {
|
|
492
|
-
const attrs = res.document.data.attributes as { displayName?: string };
|
|
493
|
-
}
|
|
409
|
+
// Request bodies are NOT transformed — send snake_case on the wire.
|
|
494
410
|
```
|
|
495
411
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
---
|
|
499
|
-
|
|
500
|
-
## Guards and validation helpers
|
|
501
|
-
|
|
502
|
-
| Function | True when |
|
|
503
|
-
|----------|-----------|
|
|
504
|
-
| `isAuthenticationError` | `ApiClientError` status **401** |
|
|
505
|
-
| `isForbiddenError` | **403** |
|
|
506
|
-
| `isValidationError` | **422** |
|
|
507
|
-
| `isPreconditionRequiredError` | **428** |
|
|
508
|
-
| `isPreconditionFailedError` | **412** |
|
|
509
|
-
| `isConflictError` | **409** |
|
|
510
|
-
| `isPayloadTooLargeError` | **413** |
|
|
511
|
-
| `isRetryablePerPolicy` | Would retry per client policy (usually for UI hints, not for manual retry of failed calls) |
|
|
412
|
+
### Security and logging
|
|
512
413
|
|
|
513
|
-
|
|
414
|
+
- Tokens only from your `getToken` / `getSecret` callbacks.
|
|
415
|
+
- `redactHeaderRecord`, `truncateForLog`, `ApiClientError.toJSON()` redact secrets.
|
|
416
|
+
- Non-HTTPS `baseURL` (except localhost) logs a one-time warning.
|
|
514
417
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
- Tokens come only from your **`getToken` / `getSecret`** callbacks; the client does not store credentials.
|
|
518
|
-
- **`ApiClientError.toJSON()`** and **`redactHeaderRecord()`** redact `Authorization` and `Idempotency-Key`.
|
|
519
|
-
- **`truncateForLog(value, maxLen)`** safely stringifies values for logs (truncates, handles circular refs).
|
|
520
|
-
- Non-HTTPS `baseURL` outside **localhost** logs a **one-time** console warning.
|
|
521
|
-
|
|
522
|
-
```typescript
|
|
523
|
-
import { truncateForLog, redactHeaderRecord } from '@vahidkaargar/customized-api-client';
|
|
524
|
-
|
|
525
|
-
logger.info(truncateForLog(responseBody, 2_000));
|
|
526
|
-
```
|
|
527
|
-
|
|
528
|
-
---
|
|
529
|
-
|
|
530
|
-
## Health checks
|
|
418
|
+
### Health check
|
|
531
419
|
|
|
532
420
|
```typescript
|
|
533
421
|
import { createHealthCheck } from '@vahidkaargar/customized-api-client';
|
|
534
422
|
|
|
535
|
-
const ping = createHealthCheck(client);
|
|
536
|
-
const ok = await ping(); // GET /health/live
|
|
423
|
+
const ping = createHealthCheck(client);
|
|
424
|
+
const ok = await ping(); // GET /health/live
|
|
537
425
|
```
|
|
538
426
|
|
|
539
427
|
---
|
|
540
428
|
|
|
541
429
|
## Typing your API
|
|
542
430
|
|
|
543
|
-
This package
|
|
544
|
-
|
|
545
|
-
If you want OpenAPI-driven types:
|
|
431
|
+
This package ships **generic JSON:API types**, not endpoint-specific OpenAPI types.
|
|
546
432
|
|
|
547
|
-
1. Keep
|
|
548
|
-
2.
|
|
549
|
-
3.
|
|
433
|
+
1. Keep OpenAPI in your backend or `@myorg/api-types`.
|
|
434
|
+
2. Codegen there (e.g. `openapi-typescript`).
|
|
435
|
+
3. Use generics at call sites:
|
|
550
436
|
|
|
551
437
|
```typescript
|
|
552
|
-
import { createApiClient } from '@vahidkaargar/customized-api-client';
|
|
553
438
|
import type { operations } from '@myorg/api-types';
|
|
554
439
|
|
|
555
|
-
const client = createApiClient({ baseURL: 'https://api.example.com/api/v1', getToken: async () => token });
|
|
556
|
-
|
|
557
440
|
type MeResponse = operations['getMe']['responses'][200]['content']['application/vnd.api+json'];
|
|
558
441
|
const me = await client.get<MeResponse>('/me');
|
|
559
442
|
```
|
|
560
443
|
|
|
561
|
-
|
|
444
|
+
String paths and manual types work without OpenAPI.
|
|
562
445
|
|
|
563
446
|
---
|
|
564
447
|
|
|
565
|
-
## Advanced
|
|
448
|
+
## Advanced exports
|
|
566
449
|
|
|
567
|
-
For custom
|
|
450
|
+
For custom wrappers, tests, or pipelines:
|
|
568
451
|
|
|
569
|
-
|
|
570
|
-
|--------|---------|
|
|
571
|
-
| `normalizeAxiosResponse` | Map raw Axios response → `ClientSuccess` / throw |
|
|
572
|
-
| `parseJsonApiDocument` / `parseJsonApiErrorBody` | Parse success/error payloads |
|
|
573
|
-
| `dispatchWithRetry` | Retry wrapper around an `AxiosInstance` |
|
|
574
|
-
| `applyJsonApiHeaders` | Content-Type / Accept for JSON:API |
|
|
575
|
-
| `resolveResourcePath` | Mode A/B path resolution |
|
|
576
|
-
| `flattenAxiosHeaders` / `getHeader` | Header normalization |
|
|
577
|
-
| `parseMultiStatusBody` / `resolveAcceptedLocation` | 207 / 202 helpers |
|
|
578
|
-
| `parseRetryAfterSeconds` | Retry-After parsing |
|
|
579
|
-
| `assertValidIdempotencyKey` / `defaultIdempotencyKey` | Idempotency utilities |
|
|
452
|
+
`normalizeAxiosResponse`, `parseJsonApiDocument`, `parseJsonApiErrorBody`, `dispatchWithRetry`, `applyJsonApiHeaders`, `resolveResourcePath`, `parseMultiStatusBody`, `parseRetryAfterSeconds`, `assertValidIdempotencyKey`, …
|
|
580
453
|
|
|
581
|
-
|
|
454
|
+
Full list: [`src/index.ts`](./src/index.ts).
|
|
582
455
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
### Client
|
|
586
|
-
|
|
587
|
-
- `createApiClient(config)` → `ApiClient`
|
|
588
|
-
- Types: `ApiClient`, `ApiClientConfig`, `RequestCallOptions`, `AuthConfig`, `RetryOptions`, …
|
|
589
|
-
|
|
590
|
-
### Results & errors
|
|
591
|
-
|
|
592
|
-
- `ClientSuccess`, `JsonApiSuccessBody`, `NoContentBody`, `AcceptedBody`, `MultiStatusBody`
|
|
593
|
-
- `Result`, `OkResult`, `ErrResult`
|
|
594
|
-
- `ApiClientError`, `isApiClientError`
|
|
595
|
-
|
|
596
|
-
### JSON:API types
|
|
597
|
-
|
|
598
|
-
- `JsonApiDocument`, `JsonApiResourceObject`, `JsonApiErrorObject`, …
|
|
599
|
-
|
|
600
|
-
### Helpers
|
|
456
|
+
---
|
|
601
457
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
458
|
+
## Table of contents (full reference)
|
|
459
|
+
|
|
460
|
+
1. [What you get](#what-you-get)
|
|
461
|
+
2. [Install](#install)
|
|
462
|
+
3. [Quick start](#quick-start)
|
|
463
|
+
4. [Guide for application developers](#guide-for-application-developers)
|
|
464
|
+
5. [Recipes](#recipes)
|
|
465
|
+
6. [Configuration](#configuration)
|
|
466
|
+
7. [Making requests](#making-requests)
|
|
467
|
+
8. [Success results](#success-results-clientsuccess)
|
|
468
|
+
9. [Error handling](#error-handling)
|
|
469
|
+
10. [Idempotency](#idempotency)
|
|
470
|
+
11. [Optimistic concurrency](#optimistic-concurrency)
|
|
471
|
+
12. [Retries](#retries)
|
|
472
|
+
13. [More helpers](#more-helpers)
|
|
473
|
+
14. [Typing your API](#typing-your-api)
|
|
474
|
+
15. [Advanced exports](#advanced-exports)
|
|
475
|
+
16. [Development](#development-this-repository)
|
|
476
|
+
17. [Supply chain](#supply-chain)
|
|
477
|
+
18. [Publishing](#publishing-maintainers)
|
|
612
478
|
|
|
613
479
|
---
|
|
614
480
|
|
|
@@ -625,34 +491,29 @@ npm run build
|
|
|
625
491
|
npx vitest run --config vitest.postbuild.config.ts
|
|
626
492
|
```
|
|
627
493
|
|
|
628
|
-
CI uses **`node-version: '22.21'`** in
|
|
494
|
+
CI uses **`node-version: '22.21'`** in [`ci.yml`](.github/workflows/ci.yml) plus global **npm** `^11.5.1`.
|
|
629
495
|
|
|
630
496
|
---
|
|
631
497
|
|
|
632
498
|
## Supply chain
|
|
633
499
|
|
|
634
|
-
-
|
|
635
|
-
-
|
|
636
|
-
-
|
|
637
|
-
-
|
|
500
|
+
- [SECURITY.md](SECURITY.md) — reporting vulnerabilities
|
|
501
|
+
- [`.github/dependabot.yml`](.github/dependabot.yml) — weekly dependency PRs
|
|
502
|
+
- CI runs **`npm audit --omit=dev --audit-level=moderate`** on production deps
|
|
503
|
+
- Publish uses **`npm publish --provenance`** (OIDC)
|
|
638
504
|
|
|
639
505
|
---
|
|
640
506
|
|
|
641
507
|
## Publishing (maintainers)
|
|
642
508
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
2. Bump **`version`** in **`package.json`**, merge to **`main`**, then **Actions** → **Publish to npm** → **Run workflow** ( **`workflow_dispatch`** ).
|
|
647
|
-
|
|
648
|
-
Workflow permissions use **`contents: read`** and **`id-token: write`**; **[`publish-npm.yml`](.github/workflows/publish-npm.yml)** runs the same gates as **[`ci.yml`](.github/workflows/ci.yml)** (Node **`22.21`**, global **npm** **`^11.5.1`**) and then **`npm publish --provenance`**.
|
|
649
|
-
|
|
650
|
-
**Legacy (token publish):** If Trusted Publishing cannot be configured, restore a publish step env block with **`NODE_AUTH_TOKEN`** from a granular npm token (**Read and write** for this package; **Automation / bypass 2FA** if your npm account requires it for unattended publish). Prefer OIDC for supply-chain hygiene once enabled.
|
|
509
|
+
1. Bump **`version`** in **`package.json`** and update **[CHANGELOG.md](./CHANGELOG.md)**.
|
|
510
|
+
2. Merge to **`main`**, then **Actions → Publish to npm → Run workflow**.
|
|
511
|
+
3. Workflow fails fast if the version already exists on npm.
|
|
651
512
|
|
|
652
|
-
[`
|
|
513
|
+
[`publish-npm.yml`](.github/workflows/publish-npm.yml) runs the same gates as [`ci.yml`](.github/workflows/ci.yml) (audit, typecheck, lint, coverage, build, post-build tests), then publishes with provenance.
|
|
653
514
|
|
|
654
515
|
---
|
|
655
516
|
|
|
656
517
|
## License
|
|
657
518
|
|
|
658
|
-
MIT
|
|
519
|
+
MIT — see [LICENSE](./LICENSE).
|