hanzio 2.2.0 → 3.1.0
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/README.md +133 -7
- package/dist/api-wrapper/connection-auth.d.ts +61 -0
- package/dist/cli/fields.d.ts +30 -0
- package/dist/cli/index.d.ts +122 -0
- package/dist/format/index.d.ts +91 -0
- package/dist/format/phone.d.ts +19 -0
- package/dist/http/index.d.ts +14 -0
- package/dist/i18n/catalog.d.ts +47 -0
- package/dist/i18n/check.d.ts +45 -0
- package/dist/i18n/cli.d.ts +2 -0
- package/dist/i18n/content-rules.d.ts +55 -0
- package/dist/i18n/i18next.d.ts +39 -0
- package/dist/i18n/index.d.ts +16 -1
- package/dist/i18n/locales.d.ts +62 -0
- package/dist/i18n/vite.d.ts +16 -0
- package/dist/i18n/vue.d.ts +93 -0
- package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
- package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
- package/dist/index-5n2e326q.js +409 -0
- package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
- package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
- package/dist/index-6ragggbs.js +473 -0
- package/dist/index-mq2qgc20.js +116 -0
- package/dist/index-najbyzpr.js +99 -0
- package/dist/index-ww09bm49.js +250 -0
- package/dist/iso/countries.d.ts +262 -0
- package/dist/iso/currencies.d.ts +165 -0
- package/dist/iso/index.d.ts +4 -0
- package/dist/iso/languages.d.ts +195 -0
- package/dist/iso/phone-language.d.ts +5 -0
- package/dist/promise/index.d.ts +31 -0
- package/dist/redis/client.d.ts +49 -0
- package/dist/redis/concurrency-limit.d.ts +63 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/lua-script.d.ts +13 -0
- package/dist/secrets/infisical-identity-file.d.ts +12 -0
- package/dist/src/api-wrapper/connection-auth.js +82 -0
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/cli/index.js +10 -0
- package/dist/src/format/index.js +79 -2
- package/dist/src/format/phone.js +21 -0
- package/dist/src/http/index.js +69 -0
- package/dist/src/i18n/check.js +15 -0
- package/dist/src/i18n/cli.js +71 -0
- package/dist/src/i18n/i18next.js +48 -0
- package/dist/src/i18n/index.js +16 -34
- package/dist/src/i18n/vite.js +25 -0
- package/dist/src/i18n/vue.js +51 -0
- package/dist/src/index.js +22 -6
- package/dist/src/iso/index.js +655 -0
- package/dist/src/redis/index.js +577 -0
- package/dist/src/secrets/index.js +78 -35
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/storage/index.js +8 -117
- package/dist/src/testing/index.js +348 -0
- package/dist/src/zod/index.js +20 -253
- package/dist/string/index.d.ts +21 -0
- package/dist/testing/connection-auth-contract.d.ts +43 -0
- package/dist/testing/helpers.d.ts +26 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/module-mocker.d.ts +30 -0
- package/dist/testing/prisma.d.ts +30 -0
- package/dist/testing/window.d.ts +18 -0
- package/dist/typedswitch/index.d.ts +55 -34
- package/dist/zod/index.d.ts +10 -0
- package/dist/zod/introspect.d.ts +2 -1
- package/package.json +97 -2
- package/src/api-wrapper/README.md +57 -2
- package/src/api-wrapper/SKILL.md +256 -0
- package/src/api-wrapper/connection-auth.ts +142 -0
- package/src/cli/README.md +220 -0
- package/src/cli/fields.ts +213 -0
- package/src/cli/index.ts +532 -0
- package/src/format/README.md +82 -2
- package/src/format/index.ts +148 -0
- package/src/format/phone.ts +43 -0
- package/src/http/README.md +25 -0
- package/src/http/index.ts +94 -0
- package/src/i18n/README.md +206 -3
- package/src/i18n/catalog.ts +139 -0
- package/src/i18n/check.ts +376 -0
- package/src/i18n/cli.ts +90 -0
- package/src/i18n/content-rules.ts +179 -0
- package/src/i18n/i18next.ts +89 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/locales.ts +135 -0
- package/src/i18n/vite.ts +40 -0
- package/src/i18n/vue.ts +82 -0
- package/src/index.ts +1 -1
- package/src/iso/README.md +44 -0
- package/src/iso/countries.ts +272 -0
- package/src/iso/currencies.ts +172 -0
- package/src/iso/index.ts +8 -0
- package/src/iso/languages.ts +208 -0
- package/src/iso/phone-language.ts +46 -0
- package/src/promise/index.ts +100 -0
- package/src/redis/README.md +112 -0
- package/src/redis/client.ts +239 -0
- package/src/redis/concurrency-limit.ts +591 -0
- package/src/redis/index.ts +15 -0
- package/src/redis/lua-script.ts +43 -0
- package/src/secrets/README.md +5 -1
- package/src/secrets/infisical-identity-file.ts +69 -0
- package/src/secrets/infisical.ts +18 -3
- package/src/string/index.ts +59 -0
- package/src/testing/README.md +119 -0
- package/src/testing/connection-auth-contract.ts +303 -0
- package/src/testing/helpers.ts +41 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/module-mocker.ts +50 -0
- package/src/testing/prisma.ts +78 -0
- package/src/testing/window.ts +119 -0
- package/src/typedswitch/README.md +55 -39
- package/src/typedswitch/index.ts +97 -83
- package/src/zod/index.ts +20 -0
- package/src/zod/introspect.ts +1 -1
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hanzio",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "A lightweight set of utility functions for TypeScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/src/index.js",
|
|
7
7
|
"main": "./dist/src/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"hanzio-i18n": "./dist/src/i18n/cli.js"
|
|
11
|
+
},
|
|
9
12
|
"license": "MIT",
|
|
10
13
|
"repository": {
|
|
11
14
|
"type": "git",
|
|
@@ -26,6 +29,7 @@
|
|
|
26
29
|
"files": [
|
|
27
30
|
"src/**/*.ts",
|
|
28
31
|
"src/**/README.md",
|
|
32
|
+
"src/api-wrapper/SKILL.md",
|
|
29
33
|
"dist",
|
|
30
34
|
"!src/**/*.test.ts"
|
|
31
35
|
],
|
|
@@ -55,6 +59,12 @@
|
|
|
55
59
|
"import": "./dist/src/api-wrapper/testing.js",
|
|
56
60
|
"default": "./dist/src/api-wrapper/testing.js"
|
|
57
61
|
},
|
|
62
|
+
"./api-wrapper/connection-auth": {
|
|
63
|
+
"types": "./dist/api-wrapper/connection-auth.d.ts",
|
|
64
|
+
"bun": "./src/api-wrapper/connection-auth.ts",
|
|
65
|
+
"import": "./dist/src/api-wrapper/connection-auth.js",
|
|
66
|
+
"default": "./dist/src/api-wrapper/connection-auth.js"
|
|
67
|
+
},
|
|
58
68
|
"./jwt": {
|
|
59
69
|
"types": "./dist/jwt/index.d.ts",
|
|
60
70
|
"bun": "./src/jwt/index.ts",
|
|
@@ -85,6 +95,12 @@
|
|
|
85
95
|
"import": "./dist/src/format/index.js",
|
|
86
96
|
"default": "./dist/src/format/index.js"
|
|
87
97
|
},
|
|
98
|
+
"./format/phone": {
|
|
99
|
+
"types": "./dist/format/phone.d.ts",
|
|
100
|
+
"bun": "./src/format/phone.ts",
|
|
101
|
+
"import": "./dist/src/format/phone.js",
|
|
102
|
+
"default": "./dist/src/format/phone.js"
|
|
103
|
+
},
|
|
88
104
|
"./crypto": {
|
|
89
105
|
"types": "./dist/crypto/index.d.ts",
|
|
90
106
|
"bun": "./src/crypto/index.ts",
|
|
@@ -103,6 +119,36 @@
|
|
|
103
119
|
"import": "./dist/src/i18n/index.js",
|
|
104
120
|
"default": "./dist/src/i18n/index.js"
|
|
105
121
|
},
|
|
122
|
+
"./i18n/vue": {
|
|
123
|
+
"types": "./dist/i18n/vue.d.ts",
|
|
124
|
+
"bun": "./src/i18n/vue.ts",
|
|
125
|
+
"import": "./dist/src/i18n/vue.js",
|
|
126
|
+
"default": "./dist/src/i18n/vue.js"
|
|
127
|
+
},
|
|
128
|
+
"./i18n/i18next": {
|
|
129
|
+
"types": "./dist/i18n/i18next.d.ts",
|
|
130
|
+
"bun": "./src/i18n/i18next.ts",
|
|
131
|
+
"import": "./dist/src/i18n/i18next.js",
|
|
132
|
+
"default": "./dist/src/i18n/i18next.js"
|
|
133
|
+
},
|
|
134
|
+
"./i18n/vite": {
|
|
135
|
+
"types": "./dist/i18n/vite.d.ts",
|
|
136
|
+
"bun": "./src/i18n/vite.ts",
|
|
137
|
+
"import": "./dist/src/i18n/vite.js",
|
|
138
|
+
"default": "./dist/src/i18n/vite.js"
|
|
139
|
+
},
|
|
140
|
+
"./i18n/check": {
|
|
141
|
+
"types": "./dist/i18n/check.d.ts",
|
|
142
|
+
"bun": "./src/i18n/check.ts",
|
|
143
|
+
"import": "./dist/src/i18n/check.js",
|
|
144
|
+
"default": "./dist/src/i18n/check.js"
|
|
145
|
+
},
|
|
146
|
+
"./iso": {
|
|
147
|
+
"types": "./dist/iso/index.d.ts",
|
|
148
|
+
"bun": "./src/iso/index.ts",
|
|
149
|
+
"import": "./dist/src/iso/index.js",
|
|
150
|
+
"default": "./dist/src/iso/index.js"
|
|
151
|
+
},
|
|
106
152
|
"./secrets": {
|
|
107
153
|
"types": "./dist/secrets/index.d.ts",
|
|
108
154
|
"bun": "./src/secrets/index.ts",
|
|
@@ -120,11 +166,35 @@
|
|
|
120
166
|
"bun": "./src/secrets/worker.ts",
|
|
121
167
|
"import": "./dist/src/secrets/worker.js",
|
|
122
168
|
"default": "./dist/src/secrets/worker.js"
|
|
169
|
+
},
|
|
170
|
+
"./redis": {
|
|
171
|
+
"types": "./dist/redis/index.d.ts",
|
|
172
|
+
"bun": "./src/redis/index.ts",
|
|
173
|
+
"import": "./dist/src/redis/index.js",
|
|
174
|
+
"default": "./dist/src/redis/index.js"
|
|
175
|
+
},
|
|
176
|
+
"./http": {
|
|
177
|
+
"types": "./dist/http/index.d.ts",
|
|
178
|
+
"bun": "./src/http/index.ts",
|
|
179
|
+
"import": "./dist/src/http/index.js",
|
|
180
|
+
"default": "./dist/src/http/index.js"
|
|
181
|
+
},
|
|
182
|
+
"./cli": {
|
|
183
|
+
"types": "./dist/cli/index.d.ts",
|
|
184
|
+
"bun": "./src/cli/index.ts",
|
|
185
|
+
"import": "./dist/src/cli/index.js",
|
|
186
|
+
"default": "./dist/src/cli/index.js"
|
|
187
|
+
},
|
|
188
|
+
"./testing": {
|
|
189
|
+
"types": "./dist/testing/index.d.ts",
|
|
190
|
+
"bun": "./src/testing/index.ts",
|
|
191
|
+
"import": "./dist/src/testing/index.js",
|
|
192
|
+
"default": "./dist/src/testing/index.js"
|
|
123
193
|
}
|
|
124
194
|
},
|
|
125
195
|
"scripts": {
|
|
126
196
|
"build": "rm -rf dist && bun run build:js && bun run build:types",
|
|
127
|
-
"build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/api-wrapper/testing.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/cool-console-log/index.ts ./src/format/index.ts ./src/crypto/index.ts ./src/storage/index.ts ./src/i18n/index.ts ./src/secrets/index.ts ./src/secrets/vite.ts ./src/secrets/worker.ts --outdir ./dist --target node --packages external --splitting",
|
|
197
|
+
"build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/api-wrapper/testing.ts ./src/api-wrapper/connection-auth.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/cool-console-log/index.ts ./src/format/index.ts ./src/format/phone.ts ./src/crypto/index.ts ./src/storage/index.ts ./src/i18n/index.ts ./src/i18n/vue.ts ./src/i18n/i18next.ts ./src/i18n/vite.ts ./src/i18n/check.ts ./src/i18n/cli.ts ./src/iso/index.ts ./src/secrets/index.ts ./src/secrets/vite.ts ./src/secrets/worker.ts ./src/redis/index.ts ./src/http/index.ts ./src/cli/index.ts ./src/testing/index.ts --outdir ./dist --target node --packages external --splitting",
|
|
128
198
|
"build:types": "tsgo -p tsconfig.build.json",
|
|
129
199
|
"prepublishOnly": "bun run build",
|
|
130
200
|
"test": "bun test",
|
|
@@ -138,19 +208,44 @@
|
|
|
138
208
|
},
|
|
139
209
|
"devDependencies": {
|
|
140
210
|
"@biomejs/biome": "^2.4.6",
|
|
211
|
+
"@intlify/message-compiler": "^11.4.16",
|
|
141
212
|
"@opentelemetry/api": "^1.9.1",
|
|
142
213
|
"@types/bun": "latest",
|
|
143
214
|
"@typescript/native-preview": "^7.0.0-dev.20260306.1",
|
|
144
215
|
"husky": "^9.1.7",
|
|
216
|
+
"i18next": "^25.10.10",
|
|
217
|
+
"libphonenumber-js": "^1.13.15",
|
|
145
218
|
"miniflare": "^4.20260730.0",
|
|
146
219
|
"typescript": "^5.7.0",
|
|
147
220
|
"vite": "^8.2.2",
|
|
221
|
+
"vue": "^3.5.44",
|
|
222
|
+
"vue-i18n": "^11.4.16",
|
|
148
223
|
"zod": "^4.3.6"
|
|
149
224
|
},
|
|
150
225
|
"peerDependencies": {
|
|
226
|
+
"@intlify/message-compiler": ">=10.0.0",
|
|
227
|
+
"i18next": ">=23.0.0",
|
|
228
|
+
"libphonenumber-js": "^1.10.0",
|
|
229
|
+
"vite": ">=6.0.0",
|
|
230
|
+
"vue-i18n": ">=10.0.0",
|
|
151
231
|
"zod": "^4.3.6"
|
|
152
232
|
},
|
|
153
233
|
"peerDependenciesMeta": {
|
|
234
|
+
"@intlify/message-compiler": {
|
|
235
|
+
"optional": true
|
|
236
|
+
},
|
|
237
|
+
"i18next": {
|
|
238
|
+
"optional": true
|
|
239
|
+
},
|
|
240
|
+
"libphonenumber-js": {
|
|
241
|
+
"optional": true
|
|
242
|
+
},
|
|
243
|
+
"vite": {
|
|
244
|
+
"optional": true
|
|
245
|
+
},
|
|
246
|
+
"vue-i18n": {
|
|
247
|
+
"optional": true
|
|
248
|
+
},
|
|
154
249
|
"zod": {
|
|
155
250
|
"optional": true
|
|
156
251
|
}
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
Typed HTTP clients from Zod schemas: `createApiClient` validates requests and
|
|
4
4
|
responses, retries with exponential backoff, and throws structured errors.
|
|
5
5
|
|
|
6
|
-
Building a new wrapper with an AI agent?
|
|
7
|
-
step-by-step workflow and folder conventions.
|
|
6
|
+
Building a new wrapper with an AI agent? [SKILL.md](./SKILL.md) has the
|
|
7
|
+
step-by-step workflow and folder conventions. It ships with the package: copy
|
|
8
|
+
`node_modules/hanzio/src/api-wrapper/SKILL.md` to
|
|
9
|
+
`.claude/skills/hanzio-api-wrapper/SKILL.md` (or `.agents/skills/…`), or point
|
|
10
|
+
your agent at it.
|
|
8
11
|
|
|
9
12
|
## Quick Start
|
|
10
13
|
|
|
@@ -645,6 +648,58 @@ Cache API:
|
|
|
645
648
|
The cache is also exposed on the client itself as `api.cache` for use outside
|
|
646
649
|
of action handlers (e.g. clearing auth on logout).
|
|
647
650
|
|
|
651
|
+
### Sharing a login across clients
|
|
652
|
+
|
|
653
|
+
`ctx.cache` lives for one client. When every request builds its own client
|
|
654
|
+
(one per tenant or per connection, from stored credentials), use
|
|
655
|
+
`ConnectionAuthCache` from `hanzio/api-wrapper/connection-auth` instead: one
|
|
656
|
+
module-level cache shares a single token exchange per connection across all
|
|
657
|
+
of them. It needs `node:crypto` (Bun, Node, Deno, or Workers with
|
|
658
|
+
`nodejs_compat`), so it is not part of `hanzio/api-wrapper`.
|
|
659
|
+
|
|
660
|
+
```ts
|
|
661
|
+
import { ConnectionAuthCache } from 'hanzio/api-wrapper/connection-auth'
|
|
662
|
+
|
|
663
|
+
const authCache = new ConnectionAuthCache() // keeps at most 1,000 connections
|
|
664
|
+
|
|
665
|
+
export function createShopClient(connection: { id: string; apiKey: string; secret: string }) {
|
|
666
|
+
const auth = authCache.bind({
|
|
667
|
+
connectionId: connection.id,
|
|
668
|
+
authInputs: [connection.apiKey, connection.secret],
|
|
669
|
+
refreshMarginMs: 60_000,
|
|
670
|
+
exchange: () => login(connection) // resolves { token, expiresAt }
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
async get(path: string, signal?: AbortSignal) {
|
|
675
|
+
for (let attempt = 0; ; attempt++) {
|
|
676
|
+
const current = await auth.get({ signal })
|
|
677
|
+
const response = await fetch(`https://shop.example.com${path}`, {
|
|
678
|
+
headers: { authorization: `Bearer ${current.token}` },
|
|
679
|
+
signal
|
|
680
|
+
})
|
|
681
|
+
// One retry with a new login when the token was refused.
|
|
682
|
+
if (response.status !== 401 || attempt > 0) return response
|
|
683
|
+
auth.reject(current)
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
- Concurrent callers share one exchange, and aborting one caller's wait leaves
|
|
691
|
+
it running for the others.
|
|
692
|
+
- Changing any `authInputs` value (a rotated secret) retires the old token: a
|
|
693
|
+
client still using the old credentials can finish its request but cannot
|
|
694
|
+
store its token. The inputs are kept only as an HMAC under a random key.
|
|
695
|
+
- `reject(token)` drops a token the API refused, unless a newer login has
|
|
696
|
+
already replaced it. `get({ fresh: true })` always exchanges, for an explicit
|
|
697
|
+
credential check, and never falls back to the old token when it fails.
|
|
698
|
+
- `clear()` forgets everything, including exchanges still in flight.
|
|
699
|
+
|
|
700
|
+
`connectionAuthContract` in [`hanzio/testing`](../testing/README.md#connection-auth-contract)
|
|
701
|
+
runs these races against your client.
|
|
702
|
+
|
|
648
703
|
## Endpoint Configuration Reference
|
|
649
704
|
|
|
650
705
|
| Option | Required | Description |
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hanzio-api-wrapper
|
|
3
|
+
description: Building typed HTTP API wrappers with createApiClient and Zod in hanzio/api-wrapper. Covers folder layout, index factory, endpoint files with defineEndpoint, shared types, pagination with paginate, and common REST API integration patterns.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# AI Agent Guide: Building API Wrappers
|
|
7
|
+
|
|
8
|
+
This guide is for AI assistants to follow when building API wrappers using the
|
|
9
|
+
`createApiClient` utility from `hanzio/api-wrapper`. The API reference
|
|
10
|
+
(configuration, errors, actions, retries, pagination) lives in
|
|
11
|
+
[README.md](./README.md).
|
|
12
|
+
|
|
13
|
+
## Folder Structure
|
|
14
|
+
|
|
15
|
+
When creating a new API wrapper, follow this structure:
|
|
16
|
+
|
|
17
|
+
```txt
|
|
18
|
+
src/api-wrapper/{api-name}/
|
|
19
|
+
├── index.ts
|
|
20
|
+
├── types.ts
|
|
21
|
+
├── {endpointName}.ts
|
|
22
|
+
├── {anotherEndpoint}.ts
|
|
23
|
+
└── utils/
|
|
24
|
+
├── {utilName}.ts
|
|
25
|
+
└── ...
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Naming Conventions
|
|
29
|
+
|
|
30
|
+
- Folder name: lowercase, kebab-case, for example `github-api`, `stripe`, `template-persona`.
|
|
31
|
+
- Endpoint files: camelCase and action-oriented, for example `topicsGet.ts`, `usersCreate.ts`, `ordersUpdate.ts`.
|
|
32
|
+
- Utility files: camelCase and descriptive, for example `paginateResults.ts`, `templateGetAll.ts`.
|
|
33
|
+
|
|
34
|
+
## Step-By-Step Process
|
|
35
|
+
|
|
36
|
+
### Step 1: Gather Information
|
|
37
|
+
|
|
38
|
+
The user will typically provide one or more of:
|
|
39
|
+
|
|
40
|
+
1. cURL command: extract method, URL, headers, and body.
|
|
41
|
+
2. Request/response examples: use them to build Zod schemas.
|
|
42
|
+
3. Chrome DevTools Network logs: extract request and response details.
|
|
43
|
+
4. API documentation: reference schemas, auth, pagination, and endpoints.
|
|
44
|
+
|
|
45
|
+
### Step 2: Create The Folder
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
mkdir -p src/api-wrapper/{api-name}/utils
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Step 3: Create The Index File
|
|
52
|
+
|
|
53
|
+
The index file creates and exports the API client. When the token comes from
|
|
54
|
+
a secret store, create the client at module level and resolve the header
|
|
55
|
+
lazily with a `defaultHeaders` function; take the token as a parameter only
|
|
56
|
+
when callers supply it per instance.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { createApiClient } from 'hanzio/api-wrapper'
|
|
60
|
+
import { endpointOne } from './endpointOne'
|
|
61
|
+
import { endpointTwo } from './endpointTwo'
|
|
62
|
+
|
|
63
|
+
export const exampleApi = createApiClient({
|
|
64
|
+
name: 'exampleApi',
|
|
65
|
+
baseApiUrls: {
|
|
66
|
+
default: 'https://api.example.com'
|
|
67
|
+
},
|
|
68
|
+
defaultHeaders: () => ({
|
|
69
|
+
Authorization: `Bearer ${getSecret('EXAMPLE_API_TOKEN')}`
|
|
70
|
+
}),
|
|
71
|
+
endpoints: {
|
|
72
|
+
endpointOne,
|
|
73
|
+
endpointTwo
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
If the project uses OpenTelemetry, add
|
|
79
|
+
`use: [otelMiddleware(trace.getTracer('app'))]`; for other shared middleware
|
|
80
|
+
(metrics), pass it with `use: [...]` instead of wrapping `fetch`. Pass a
|
|
81
|
+
`logger` so failures are logged with their category (server, input or output
|
|
82
|
+
validation). If the API returns HTTP 200 with an
|
|
83
|
+
error envelope (e.g. `{ success: false, errors }`), add a `checkResponse` that
|
|
84
|
+
throws on failure rather than checking after every call. Endpoints without the
|
|
85
|
+
envelope set `checkResponse: false` (or their own check). When the result is in
|
|
86
|
+
response headers (e.g. a `Location`), read them in `resFormatter(body, headers)`.
|
|
87
|
+
|
|
88
|
+
### Step 4: Create Shared Types
|
|
89
|
+
|
|
90
|
+
If multiple endpoints share schemas, create a `types.ts` file.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { z } from 'zod'
|
|
94
|
+
|
|
95
|
+
export const ListQueryInput = z.object({
|
|
96
|
+
page: z.number().optional(),
|
|
97
|
+
page_size: z.number().optional()
|
|
98
|
+
})
|
|
99
|
+
export type ListQueryInput = z.infer<typeof ListQueryInput>
|
|
100
|
+
|
|
101
|
+
export const ListQueryOutput = <T extends z.ZodType>(itemSchema: T) =>
|
|
102
|
+
z.object({
|
|
103
|
+
data: z.array(itemSchema),
|
|
104
|
+
current_page: z.number(),
|
|
105
|
+
last_page: z.number(),
|
|
106
|
+
per_page: z.number(),
|
|
107
|
+
total: z.number()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
export type ListQueryOutput<T> = {
|
|
111
|
+
data: T[]
|
|
112
|
+
current_page: number
|
|
113
|
+
last_page: number
|
|
114
|
+
per_page: number
|
|
115
|
+
total: number
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Step 5: Create Endpoint Files
|
|
120
|
+
|
|
121
|
+
Each endpoint gets its own file with request/response schemas and endpoint
|
|
122
|
+
configuration.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { defineEndpoint } from 'hanzio/api-wrapper'
|
|
126
|
+
import { z } from 'zod'
|
|
127
|
+
|
|
128
|
+
export const UserResponse = z.object({
|
|
129
|
+
id: z.number(),
|
|
130
|
+
name: z.string(),
|
|
131
|
+
email: z.string()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
export const usersGet = defineEndpoint({
|
|
135
|
+
method: 'GET',
|
|
136
|
+
path: '/users/:userId',
|
|
137
|
+
reqQuerySchema: z.object({ includeInactive: z.boolean().optional() }),
|
|
138
|
+
resSchema: UserResponse
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`defineEndpoint` types `reqParams` from the `:params` in `path`, so add a
|
|
143
|
+
`reqParamsSchema` only when params need validation or coercion.
|
|
144
|
+
|
|
145
|
+
### Step 6: Create Utility Functions
|
|
146
|
+
|
|
147
|
+
Place reusable helpers in `utils/`, e.g. response normalization or rate-limit
|
|
148
|
+
handling. For pagination, use the built-in `paginate` instead of a hand-written
|
|
149
|
+
loop:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { paginate } from 'hanzio/api-wrapper'
|
|
153
|
+
import type { GetExampleApi } from '../index'
|
|
154
|
+
|
|
155
|
+
export const usersGetAll = (api: GetExampleApi) =>
|
|
156
|
+
Array.fromAsync(
|
|
157
|
+
paginate(
|
|
158
|
+
(page: number = 1) => api.usersList({ reqQuery: { page } }),
|
|
159
|
+
(res) =>
|
|
160
|
+
res.data.current_page < res.data.last_page
|
|
161
|
+
? res.data.current_page + 1
|
|
162
|
+
: null
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Extracting Information From User Input
|
|
168
|
+
|
|
169
|
+
### From cURL Commands
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
curl -X POST 'https://api.example.com/users' \
|
|
173
|
+
-H 'Authorization: Bearer token123' \
|
|
174
|
+
-H 'Content-Type: application/json' \
|
|
175
|
+
-d '{"name": "John", "email": "john@example.com"}'
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Extract:
|
|
179
|
+
|
|
180
|
+
- Method: `POST`
|
|
181
|
+
- Base URL: `https://api.example.com`
|
|
182
|
+
- Path: `/users`
|
|
183
|
+
- Headers: `Authorization`, `Content-Type`
|
|
184
|
+
- Body schema: `{ name: string, email: string }`
|
|
185
|
+
|
|
186
|
+
### From Chrome DevTools
|
|
187
|
+
|
|
188
|
+
Look for:
|
|
189
|
+
|
|
190
|
+
1. Request URL: base URL plus path.
|
|
191
|
+
2. Request method: HTTP method.
|
|
192
|
+
3. Request headers: auth and custom headers.
|
|
193
|
+
4. Request payload: body schema.
|
|
194
|
+
5. Response body: response schema.
|
|
195
|
+
6. Query string parameters: query schema.
|
|
196
|
+
|
|
197
|
+
### Building Zod Schemas From JSON
|
|
198
|
+
|
|
199
|
+
Given this response:
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"id": 123,
|
|
204
|
+
"name": "John Doe",
|
|
205
|
+
"email": "john@example.com",
|
|
206
|
+
"created_at": "2024-01-15T10:30:00Z",
|
|
207
|
+
"roles": ["admin", "user"],
|
|
208
|
+
"profile": {
|
|
209
|
+
"avatar_url": "https://example.com/avatar.png",
|
|
210
|
+
"bio": "Developer"
|
|
211
|
+
},
|
|
212
|
+
"is_active": true
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Create this schema:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const UserResponse = z.object({
|
|
220
|
+
id: z.number(),
|
|
221
|
+
name: z.string(),
|
|
222
|
+
email: z.string(),
|
|
223
|
+
created_at: z.string(),
|
|
224
|
+
roles: z.array(z.string()),
|
|
225
|
+
profile: z.object({
|
|
226
|
+
avatar_url: z.string(),
|
|
227
|
+
bio: z.string()
|
|
228
|
+
}),
|
|
229
|
+
is_active: z.boolean()
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Checklist
|
|
234
|
+
|
|
235
|
+
- [ ] Create folder with API name in kebab-case.
|
|
236
|
+
- [ ] Create `index.ts` with a `createApiClient` call.
|
|
237
|
+
- [ ] Set `baseApiUrls` and `defaultHeaders` (a function when the token comes from a secret store).
|
|
238
|
+
- [ ] Add `checkResponse` if the API returns errors inside 200 responses.
|
|
239
|
+
- [ ] Create `types.ts` for shared schemas when needed.
|
|
240
|
+
- [ ] Create one file per endpoint.
|
|
241
|
+
- [ ] Define Zod schemas for all request and response data.
|
|
242
|
+
- [ ] Use `defineEndpoint` for endpoint definitions.
|
|
243
|
+
- [ ] Create helpers in `utils/` when needed.
|
|
244
|
+
- [ ] Export the API type, for example `export type ExampleApi = typeof exampleApi`.
|
|
245
|
+
- [ ] Test with `createMockFetch` from `hanzio/api-wrapper/testing` passed as `fetch`; don't replace `globalThis.fetch`.
|
|
246
|
+
|
|
247
|
+
## Tips
|
|
248
|
+
|
|
249
|
+
1. Always use `defineEndpoint` for endpoint definitions (it checks `resFormatter` and types path params).
|
|
250
|
+
2. Import Zod from `zod`.
|
|
251
|
+
3. Be strict with schemas and mark unstable fields as `.optional()` or `.nullish()`.
|
|
252
|
+
4. Use descriptive endpoint names like `usersGet`, `usersCreate`, and `ordersListByStatus`.
|
|
253
|
+
5. Keep files small by using one endpoint per file.
|
|
254
|
+
6. Extract schemas to `types.ts` when used by two or more endpoints.
|
|
255
|
+
7. Use `paginate` when APIs use pagination.
|
|
256
|
+
8. Ask for sample responses if the user only provides cURL commands.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from 'node:crypto'
|
|
2
|
+
import { raceAbort } from '../promise'
|
|
3
|
+
|
|
4
|
+
export type ConnectionAuthToken = { token: string; expiresAt: number }
|
|
5
|
+
|
|
6
|
+
type Entry = {
|
|
7
|
+
identity: string
|
|
8
|
+
token?: ConnectionAuthToken
|
|
9
|
+
pending?: Promise<ConnectionAuthToken>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shares one login per connection across every client instance in the process,
|
|
14
|
+
* for APIs that exchange credentials for a short-lived token.
|
|
15
|
+
*
|
|
16
|
+
* - Concurrent callers share one exchange; aborting one caller's wait leaves the
|
|
17
|
+
* exchange running for the others.
|
|
18
|
+
* - Changing any of `authInputs` (rotated password, new client id) retires the
|
|
19
|
+
* old token: a retired client may finish its request but cannot store its
|
|
20
|
+
* token. Inputs are kept only as an HMAC under a random per-cache key, so
|
|
21
|
+
* the cache never holds, and cannot be used to guess, the credentials.
|
|
22
|
+
* - On a 401, `reject(token)` drops the refused token, unless a newer one has
|
|
23
|
+
* already replaced it, and the next `get()` exchanges again.
|
|
24
|
+
* - `get({ fresh: true })` always exchanges, for an explicit credential check;
|
|
25
|
+
* its failure never falls back to the previous token.
|
|
26
|
+
* - At most `maxEntries` connections are kept, least recently used first;
|
|
27
|
+
* connections with an exchange in flight are never evicted.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const auth = new ConnectionAuthCache()
|
|
31
|
+
*
|
|
32
|
+
* class ShopApi {
|
|
33
|
+
* private readonly token
|
|
34
|
+
* constructor(credentials: { connectionId: string; apiKey: string; secret: string }) {
|
|
35
|
+
* this.token = auth.bind({
|
|
36
|
+
* connectionId: credentials.connectionId,
|
|
37
|
+
* authInputs: [credentials.apiKey, credentials.secret],
|
|
38
|
+
* refreshMarginMs: 60_000,
|
|
39
|
+
* exchange: () => login(credentials) // resolves { token, expiresAt }
|
|
40
|
+
* })
|
|
41
|
+
* }
|
|
42
|
+
* async read(signal?: AbortSignal) {
|
|
43
|
+
* const current = await this.token.get({ signal })
|
|
44
|
+
* // On a 401: this.token.reject(current), then retry once with this.token.get().
|
|
45
|
+
* }
|
|
46
|
+
* }
|
|
47
|
+
*/
|
|
48
|
+
export class ConnectionAuthCache {
|
|
49
|
+
private readonly key = randomBytes(32)
|
|
50
|
+
private readonly entries = new Map<string, Entry>()
|
|
51
|
+
|
|
52
|
+
constructor(private readonly maxEntries = 1_000) {}
|
|
53
|
+
|
|
54
|
+
get size() {
|
|
55
|
+
return this.entries.size
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
clear() {
|
|
59
|
+
for (const entry of this.entries.values()) entry.token = undefined
|
|
60
|
+
this.entries.clear()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private prune(protectedEntry?: Entry) {
|
|
64
|
+
for (const [connectionId, entry] of this.entries) {
|
|
65
|
+
if (this.entries.size <= this.maxEntries) break
|
|
66
|
+
// In-flight work must remain discoverable by identical callers. Its
|
|
67
|
+
// ownership ends on settlement, when pruning runs again.
|
|
68
|
+
if (entry.pending || entry === protectedEntry) continue
|
|
69
|
+
entry.token = undefined
|
|
70
|
+
this.entries.delete(connectionId)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
bind(input: {
|
|
75
|
+
connectionId: string
|
|
76
|
+
authInputs: readonly (string | number)[]
|
|
77
|
+
refreshMarginMs: number
|
|
78
|
+
exchange: () => Promise<ConnectionAuthToken>
|
|
79
|
+
}) {
|
|
80
|
+
const identity = createHmac('sha256', this.key)
|
|
81
|
+
.update(JSON.stringify(input.authInputs))
|
|
82
|
+
.digest('hex')
|
|
83
|
+
let entry = this.entries.get(input.connectionId)
|
|
84
|
+
|
|
85
|
+
if (!entry || entry.identity !== identity) {
|
|
86
|
+
if (entry) entry.token = undefined
|
|
87
|
+
entry = { identity }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Map order is LRU. Eviction never cancels an exchange already in use.
|
|
91
|
+
this.entries.delete(input.connectionId)
|
|
92
|
+
this.entries.set(input.connectionId, entry)
|
|
93
|
+
this.prune(entry)
|
|
94
|
+
|
|
95
|
+
const state = entry
|
|
96
|
+
const get = (options?: { fresh?: boolean; signal?: AbortSignal }) => {
|
|
97
|
+
if (options?.signal?.aborted) {
|
|
98
|
+
return Promise.reject<ConnectionAuthToken>(options.signal.reason)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (this.entries.get(input.connectionId) === state) {
|
|
102
|
+
this.entries.delete(input.connectionId)
|
|
103
|
+
this.entries.set(input.connectionId, state)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Aborting cancels only this wait; the exchange belongs to every waiter.
|
|
107
|
+
if (state.pending) return raceAbort(state.pending, options?.signal)
|
|
108
|
+
if (
|
|
109
|
+
!options?.fresh &&
|
|
110
|
+
state.token &&
|
|
111
|
+
state.token.expiresAt - Date.now() > input.refreshMarginMs
|
|
112
|
+
) {
|
|
113
|
+
return Promise.resolve(state.token)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A fresh check cannot leave the previous token usable after failure.
|
|
117
|
+
state.token = undefined
|
|
118
|
+
const pending = input
|
|
119
|
+
.exchange()
|
|
120
|
+
.then((token) => {
|
|
121
|
+
if (this.entries.get(input.connectionId) === state) {
|
|
122
|
+
state.token = token
|
|
123
|
+
}
|
|
124
|
+
return token
|
|
125
|
+
})
|
|
126
|
+
.finally(() => {
|
|
127
|
+
if (state.pending === pending) state.pending = undefined
|
|
128
|
+
this.prune()
|
|
129
|
+
})
|
|
130
|
+
state.pending = pending
|
|
131
|
+
|
|
132
|
+
return raceAbort(pending, options?.signal)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
get,
|
|
137
|
+
reject(token: ConnectionAuthToken) {
|
|
138
|
+
if (state.token === token) state.token = undefined
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|