enlace 0.0.1-beta.1 → 0.0.1-beta.11
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 +836 -0
- package/dist/hook/index.d.mts +178 -0
- package/dist/hook/index.d.ts +178 -0
- package/dist/{next/hook → hook}/index.js +278 -167
- package/dist/{next/hook → hook}/index.mjs +279 -185
- package/dist/index.d.mts +59 -68
- package/dist/index.d.ts +59 -68
- package/dist/index.js +53 -327
- package/dist/index.mjs +55 -327
- package/package.json +6 -11
- package/dist/next/hook/index.d.mts +0 -124
- package/dist/next/hook/index.d.ts +0 -124
- package/dist/next/index.d.mts +0 -74
- package/dist/next/index.d.ts +0 -74
- package/dist/next/index.js +0 -111
- package/dist/next/index.mjs +0 -95
package/README.md
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
# enlace
|
|
2
|
+
|
|
3
|
+
Type-safe API client with React hooks and Next.js integration.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install enlace
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { createEnlaceHookReact } from "enlace/hook";
|
|
15
|
+
import { Endpoint } from "enlace";
|
|
16
|
+
|
|
17
|
+
// Define your API error type
|
|
18
|
+
type ApiError = { message: string; code: number };
|
|
19
|
+
|
|
20
|
+
type ApiSchema = {
|
|
21
|
+
posts: {
|
|
22
|
+
$get: Post[]; // Simple: just data type
|
|
23
|
+
$post: Endpoint<Post, CreatePost>; // Data + Body
|
|
24
|
+
$put: Endpoint<Post, UpdatePost, CustomError>; // Data + Body + Custom Error
|
|
25
|
+
_: {
|
|
26
|
+
$get: Post; // Simple: just data type
|
|
27
|
+
$delete: void; // Simple: void response
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Pass global error type as second generic
|
|
33
|
+
const useAPI = createEnlaceHookReact<ApiSchema, ApiError>(
|
|
34
|
+
"https://api.example.com"
|
|
35
|
+
);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Schema Conventions
|
|
39
|
+
|
|
40
|
+
Defining a schema is **recommended** for full type safety, but **optional**. You can go without types:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
// Without schema (untyped, but still works!)
|
|
44
|
+
const useAPI = createEnlaceHookReact("https://api.example.com");
|
|
45
|
+
const { data } = useAPI((api) => api.any.path.you.want.get());
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// With schema (recommended for type safety)
|
|
50
|
+
const useAPI = createEnlaceHookReact<ApiSchema>("https://api.example.com");
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Schema Structure
|
|
54
|
+
|
|
55
|
+
- `$get`, `$post`, `$put`, `$patch`, `$delete` — HTTP method endpoints
|
|
56
|
+
- `_` — Dynamic path segment (e.g., `/users/:id`)
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { Endpoint } from "enlace";
|
|
60
|
+
|
|
61
|
+
type ApiError = { message: string };
|
|
62
|
+
|
|
63
|
+
type ApiSchema = {
|
|
64
|
+
users: {
|
|
65
|
+
$get: User[]; // GET /users (simple)
|
|
66
|
+
$post: Endpoint<User, CreateUser>; // POST /users with body
|
|
67
|
+
_: {
|
|
68
|
+
// /users/:id
|
|
69
|
+
$get: User; // GET /users/:id (simple)
|
|
70
|
+
$put: Endpoint<User, UpdateUser>; // PUT /users/:id with body
|
|
71
|
+
$delete: void; // DELETE /users/:id (void response)
|
|
72
|
+
profile: {
|
|
73
|
+
$get: Profile; // GET /users/:id/profile (simple)
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Pass global error type - applies to all endpoints
|
|
80
|
+
const api = createEnlace<ApiSchema, ApiError>("https://api.example.com");
|
|
81
|
+
|
|
82
|
+
// Usage
|
|
83
|
+
api.users.get(); // GET /users
|
|
84
|
+
api.users[123].get(); // GET /users/123
|
|
85
|
+
api.users[123].profile.get(); // GET /users/123/profile
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Endpoint Types
|
|
89
|
+
|
|
90
|
+
The `Endpoint` type helpers let you define response data, request body, query params, formData, and error types.
|
|
91
|
+
|
|
92
|
+
#### `Endpoint<TData, TBody?, TError?>`
|
|
93
|
+
|
|
94
|
+
For endpoints with JSON body:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
import { Endpoint } from "enlace";
|
|
98
|
+
|
|
99
|
+
type ApiSchema = {
|
|
100
|
+
posts: {
|
|
101
|
+
$get: Post[]; // Direct type (simplest)
|
|
102
|
+
$post: Endpoint<Post, CreatePost>; // Data + Body
|
|
103
|
+
$put: Endpoint<Post, UpdatePost, ValidationError>; // Data + Body + Error
|
|
104
|
+
$delete: void; // void response
|
|
105
|
+
$patch: Endpoint<Post, never, NotFoundError>; // Custom error without body
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### `EndpointWithQuery<TData, TQuery, TError?>`
|
|
111
|
+
|
|
112
|
+
For endpoints with typed query parameters:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
import { EndpointWithQuery } from "enlace";
|
|
116
|
+
|
|
117
|
+
type ApiSchema = {
|
|
118
|
+
users: {
|
|
119
|
+
$get: EndpointWithQuery<User[], { page: number; limit: number; search?: string }>;
|
|
120
|
+
};
|
|
121
|
+
posts: {
|
|
122
|
+
$get: EndpointWithQuery<Post[], { status: "draft" | "published" }, ApiError>;
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Usage - query params are fully typed
|
|
127
|
+
const { data } = useAPI((api) => api.users.get({ query: { page: 1, limit: 10 } }));
|
|
128
|
+
// api.users.get({ query: { foo: "bar" } }); // ✗ Error: 'foo' does not exist
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
#### `EndpointWithFormData<TData, TFormData, TError?>`
|
|
132
|
+
|
|
133
|
+
For file uploads (multipart/form-data):
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
import { EndpointWithFormData } from "enlace";
|
|
137
|
+
|
|
138
|
+
type ApiSchema = {
|
|
139
|
+
uploads: {
|
|
140
|
+
$post: EndpointWithFormData<Upload, { file: Blob | File; name: string }>;
|
|
141
|
+
};
|
|
142
|
+
avatars: {
|
|
143
|
+
$post: EndpointWithFormData<Avatar, { image: File }, UploadError>;
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Usage - formData is automatically converted to FormData
|
|
148
|
+
const { trigger } = useAPI((api) => api.uploads.post);
|
|
149
|
+
trigger({
|
|
150
|
+
formData: {
|
|
151
|
+
file: selectedFile, // File object
|
|
152
|
+
name: "document.pdf", // String - converted automatically
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
// → Sends as multipart/form-data
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**FormData conversion rules:**
|
|
159
|
+
|
|
160
|
+
| Type | Conversion |
|
|
161
|
+
|------|------------|
|
|
162
|
+
| `File` / `Blob` | Appended directly |
|
|
163
|
+
| `string` / `number` / `boolean` | Converted to string |
|
|
164
|
+
| `object` (nested) | JSON stringified |
|
|
165
|
+
| `array` of primitives | Each item appended separately |
|
|
166
|
+
| `array` of files | Each file appended with same key |
|
|
167
|
+
|
|
168
|
+
#### `EndpointFull<T>`
|
|
169
|
+
|
|
170
|
+
Object-style for complex endpoints:
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import { EndpointFull } from "enlace";
|
|
174
|
+
|
|
175
|
+
type ApiSchema = {
|
|
176
|
+
products: {
|
|
177
|
+
$post: EndpointFull<{
|
|
178
|
+
data: Product;
|
|
179
|
+
body: CreateProduct;
|
|
180
|
+
query: { categoryId: string };
|
|
181
|
+
error: ValidationError;
|
|
182
|
+
}>;
|
|
183
|
+
};
|
|
184
|
+
files: {
|
|
185
|
+
$post: EndpointFull<{
|
|
186
|
+
data: FileUpload;
|
|
187
|
+
formData: { file: File; description: string };
|
|
188
|
+
query: { folder: string };
|
|
189
|
+
}>;
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Global error type:**
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
type ApiError = { message: string; code: number };
|
|
198
|
+
|
|
199
|
+
// Second generic sets default error type for all endpoints
|
|
200
|
+
const api = createEnlace<ApiSchema, ApiError>("https://api.example.com");
|
|
201
|
+
// const useAPI = createEnlaceHookReact<ApiSchema, ApiError>("...");
|
|
202
|
+
// const useAPI = createEnlaceHookNext<ApiSchema, ApiError>("...");
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## React Hooks
|
|
206
|
+
|
|
207
|
+
### Query Mode (Auto-Fetch)
|
|
208
|
+
|
|
209
|
+
For GET requests that fetch data automatically:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
function Posts({ page, limit }: { page: number; limit: number }) {
|
|
213
|
+
const { data, loading, error } = useAPI((api) =>
|
|
214
|
+
api.posts.get({ query: { page, limit, published: true } })
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (loading) return <div>Loading...</div>;
|
|
218
|
+
if (error) return <div>Error: {error.message}</div>;
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<ul>
|
|
222
|
+
{data.map((post) => (
|
|
223
|
+
<li key={post.id}>{post.title}</li>
|
|
224
|
+
))}
|
|
225
|
+
</ul>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Features:**
|
|
231
|
+
|
|
232
|
+
- Auto-fetches on mount
|
|
233
|
+
- Re-fetches when dependencies change (no deps array needed!)
|
|
234
|
+
- Returns cached data while revalidating
|
|
235
|
+
- **Request deduplication** — identical requests from multiple components trigger only one fetch
|
|
236
|
+
|
|
237
|
+
### Conditional Fetching
|
|
238
|
+
|
|
239
|
+
Skip fetching with the `enabled` option:
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
function ProductForm({ id }: { id: string | "new" }) {
|
|
243
|
+
// Skip fetching when creating a new product
|
|
244
|
+
const { data, loading } = useAPI(
|
|
245
|
+
(api) => api.products[id].get(),
|
|
246
|
+
{ enabled: id !== "new" }
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (id === "new") return <CreateProductForm />;
|
|
250
|
+
if (loading) return <div>Loading...</div>;
|
|
251
|
+
return <EditProductForm product={data} />;
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
// Also useful when waiting for a dependency
|
|
257
|
+
function UserPosts({ userId }: { userId: string | undefined }) {
|
|
258
|
+
const { data } = useAPI((api) => api.users[userId!].posts.get(), {
|
|
259
|
+
enabled: userId !== undefined,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
function Post({ id }: { id: number }) {
|
|
266
|
+
// Automatically re-fetches when `id` or query values change
|
|
267
|
+
const { data } = useAPI((api) => api.posts[id].get({ query: { include: "author" } }));
|
|
268
|
+
return <div>{data?.title}</div>;
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Request Deduplication
|
|
273
|
+
|
|
274
|
+
Multiple components requesting the same data will share a single network request:
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
// Both components render at the same time
|
|
278
|
+
function PostTitle({ id }: { id: number }) {
|
|
279
|
+
const { data } = useAPI((api) => api.posts[id].get());
|
|
280
|
+
return <h1>{data?.title}</h1>;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function PostBody({ id }: { id: number }) {
|
|
284
|
+
const { data } = useAPI((api) => api.posts[id].get());
|
|
285
|
+
return <p>{data?.body}</p>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Only ONE fetch request is made to GET /posts/123
|
|
289
|
+
// Both components share the same cached result
|
|
290
|
+
function PostPage() {
|
|
291
|
+
return (
|
|
292
|
+
<>
|
|
293
|
+
<PostTitle id={123} />
|
|
294
|
+
<PostBody id={123} />
|
|
295
|
+
</>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Selector Mode (Manual Trigger)
|
|
301
|
+
|
|
302
|
+
For mutations or lazy-loaded requests:
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
function DeleteButton({ id }: { id: number }) {
|
|
306
|
+
const { trigger, loading } = useAPI((api) => api.posts[id].delete);
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<button onClick={() => trigger()} disabled={loading}>
|
|
310
|
+
{loading ? "Deleting..." : "Delete"}
|
|
311
|
+
</button>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**With request body:**
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
function CreatePost() {
|
|
320
|
+
const { trigger, loading, data } = useAPI((api) => api.posts.post);
|
|
321
|
+
|
|
322
|
+
const handleSubmit = async (title: string) => {
|
|
323
|
+
const result = await trigger({ body: { title } });
|
|
324
|
+
if (!result.error) {
|
|
325
|
+
console.log("Created:", result.data);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
return <button onClick={() => handleSubmit("New Post")}>Create</button>;
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Dynamic Path Parameters
|
|
334
|
+
|
|
335
|
+
Use `:paramName` syntax for dynamic IDs passed at trigger time:
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
function PostList({ posts }: { posts: Post[] }) {
|
|
339
|
+
// Define once with :id placeholder
|
|
340
|
+
const { trigger, loading } = useAPI((api) => api.posts[":id"].delete);
|
|
341
|
+
|
|
342
|
+
const handleDelete = (postId: number) => {
|
|
343
|
+
// Pass the actual ID when triggering
|
|
344
|
+
trigger({ pathParams: { id: postId } });
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<ul>
|
|
349
|
+
{posts.map((post) => (
|
|
350
|
+
<li key={post.id}>
|
|
351
|
+
{post.title}
|
|
352
|
+
<button onClick={() => handleDelete(post.id)} disabled={loading}>
|
|
353
|
+
Delete
|
|
354
|
+
</button>
|
|
355
|
+
</li>
|
|
356
|
+
))}
|
|
357
|
+
</ul>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
**Multiple path parameters:**
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
const { trigger } = useAPI(
|
|
366
|
+
(api) => api.users[":userId"].posts[":postId"].delete
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
trigger({ pathParams: { userId: "1", postId: "42" } });
|
|
370
|
+
// → DELETE /users/1/posts/42
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
**With request body:**
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
const { trigger } = useAPI((api) => api.products[":id"].patch);
|
|
377
|
+
|
|
378
|
+
trigger({
|
|
379
|
+
pathParams: { id: "123" },
|
|
380
|
+
body: { name: "Updated Product" },
|
|
381
|
+
});
|
|
382
|
+
// → PATCH /products/123 with body
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## Caching & Auto-Revalidation
|
|
386
|
+
|
|
387
|
+
### Automatic Cache Tags (Zero Config)
|
|
388
|
+
|
|
389
|
+
**Tags are automatically generated from URL paths** — no manual configuration needed:
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
// GET /posts → tags: ['posts']
|
|
393
|
+
// GET /posts/123 → tags: ['posts', 'posts/123']
|
|
394
|
+
// GET /users/5/posts → tags: ['users', 'users/5', 'users/5/posts']
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**Mutations automatically revalidate matching tags:**
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
const { trigger } = useAPI((api) => api.posts.post);
|
|
401
|
+
|
|
402
|
+
// POST /posts automatically revalidates 'posts' tag
|
|
403
|
+
// All queries with 'posts' tag will refetch!
|
|
404
|
+
trigger({ body: { title: "New Post" } });
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
This means in most cases, **you don't need to specify any tags manually**. The cache just works.
|
|
408
|
+
|
|
409
|
+
### How It Works
|
|
410
|
+
|
|
411
|
+
1. **Queries** automatically cache with tags derived from the URL
|
|
412
|
+
2. **Mutations** automatically revalidate tags derived from the URL
|
|
413
|
+
3. All queries matching those tags refetch automatically
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
// Component A: fetches posts (cached with tag 'posts')
|
|
417
|
+
const { data } = useAPI((api) => api.posts.get());
|
|
418
|
+
|
|
419
|
+
// Component B: creates a post
|
|
420
|
+
const { trigger } = useAPI((api) => api.posts.post);
|
|
421
|
+
trigger({ body: { title: "New" } });
|
|
422
|
+
// → Automatically revalidates 'posts' tag
|
|
423
|
+
// → Component A refetches automatically!
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Stale Time
|
|
427
|
+
|
|
428
|
+
Control how long cached data is considered fresh:
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
const useAPI = createEnlaceHookReact<ApiSchema>(
|
|
432
|
+
"https://api.example.com",
|
|
433
|
+
{},
|
|
434
|
+
{
|
|
435
|
+
staleTime: 5000, // 5 seconds
|
|
436
|
+
}
|
|
437
|
+
);
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
- `staleTime: 0` (default) — Always revalidate on mount
|
|
441
|
+
- `staleTime: 5000` — Data is fresh for 5 seconds
|
|
442
|
+
- `staleTime: Infinity` — Never revalidate automatically
|
|
443
|
+
|
|
444
|
+
### Manual Tag Override (Optional)
|
|
445
|
+
|
|
446
|
+
Override auto-generated tags when needed:
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
// Custom cache tags
|
|
450
|
+
const { data } = useAPI((api) => api.posts.get({ tags: ["my-custom-tag"] }));
|
|
451
|
+
|
|
452
|
+
// Custom revalidation tags
|
|
453
|
+
trigger({
|
|
454
|
+
body: { title: "New" },
|
|
455
|
+
revalidateTags: ["posts", "dashboard"], // Override auto-generated
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Disable Auto-Revalidation
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
const useAPI = createEnlaceHookReact<ApiSchema>(
|
|
463
|
+
"https://api.example.com",
|
|
464
|
+
{},
|
|
465
|
+
{
|
|
466
|
+
autoGenerateTags: false, // Disable auto tag generation
|
|
467
|
+
autoRevalidateTags: false, // Disable auto revalidation
|
|
468
|
+
}
|
|
469
|
+
);
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
## Hook Options
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
const useAPI = createEnlaceHookReact<ApiSchema>(
|
|
476
|
+
"https://api.example.com",
|
|
477
|
+
{
|
|
478
|
+
// Default fetch options
|
|
479
|
+
headers: { Authorization: "Bearer token" },
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
// Hook options
|
|
483
|
+
autoGenerateTags: true, // Auto-generate cache tags from URL
|
|
484
|
+
autoRevalidateTags: true, // Auto-revalidate after mutations
|
|
485
|
+
staleTime: 0, // Cache freshness duration (ms)
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### Async Headers
|
|
491
|
+
|
|
492
|
+
Headers can be provided as a static value, sync function, or async function. This is useful when you need to fetch headers dynamically (e.g., auth tokens from async storage):
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
// Static headers
|
|
496
|
+
const useAPI = createEnlaceHookReact<ApiSchema>("https://api.example.com", {
|
|
497
|
+
headers: { Authorization: "Bearer token" },
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// Sync function
|
|
501
|
+
const useAPI = createEnlaceHookReact<ApiSchema>("https://api.example.com", {
|
|
502
|
+
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// Async function
|
|
506
|
+
const useAPI = createEnlaceHookReact<ApiSchema>("https://api.example.com", {
|
|
507
|
+
headers: async () => {
|
|
508
|
+
const token = await getTokenFromStorage();
|
|
509
|
+
return { Authorization: `Bearer ${token}` };
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
This also works for per-request headers:
|
|
515
|
+
|
|
516
|
+
```typescript
|
|
517
|
+
const { data } = useAPI((api) =>
|
|
518
|
+
api.posts.get({
|
|
519
|
+
headers: async () => {
|
|
520
|
+
const token = await refreshToken();
|
|
521
|
+
return { Authorization: `Bearer ${token}` };
|
|
522
|
+
},
|
|
523
|
+
})
|
|
524
|
+
);
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### Global Callbacks
|
|
528
|
+
|
|
529
|
+
You can set up global `onSuccess` and `onError` callbacks that are called for every request:
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
const useAPI = createEnlaceHookReact<ApiSchema>(
|
|
533
|
+
"https://api.example.com",
|
|
534
|
+
{
|
|
535
|
+
headers: { Authorization: "Bearer token" },
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
onSuccess: (payload) => {
|
|
539
|
+
console.log("Request succeeded:", payload.status, payload.data);
|
|
540
|
+
},
|
|
541
|
+
onError: (payload) => {
|
|
542
|
+
if (payload.status === 0) {
|
|
543
|
+
// Network error
|
|
544
|
+
console.error("Network error:", payload.error.message);
|
|
545
|
+
} else {
|
|
546
|
+
// HTTP error (4xx, 5xx)
|
|
547
|
+
console.error("HTTP error:", payload.status, payload.error);
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
}
|
|
551
|
+
);
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
**Callback Payloads:**
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
// onSuccess payload
|
|
558
|
+
type EnlaceCallbackPayload<T> = {
|
|
559
|
+
status: number;
|
|
560
|
+
data: T;
|
|
561
|
+
headers: Headers;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
// onError payload (HTTP error or network error)
|
|
565
|
+
type EnlaceErrorCallbackPayload<T> =
|
|
566
|
+
| { status: number; error: T; headers: Headers } // HTTP error
|
|
567
|
+
| { status: 0; error: Error; headers: null }; // Network error
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
**Use cases:**
|
|
571
|
+
|
|
572
|
+
- Global error logging/reporting
|
|
573
|
+
- Toast notifications for all API errors
|
|
574
|
+
- Authentication refresh on 401 errors
|
|
575
|
+
- Analytics tracking
|
|
576
|
+
|
|
577
|
+
## Return Types
|
|
578
|
+
|
|
579
|
+
### Query Mode
|
|
580
|
+
|
|
581
|
+
```typescript
|
|
582
|
+
// Basic usage
|
|
583
|
+
const result = useAPI((api) => api.posts.get());
|
|
584
|
+
|
|
585
|
+
// With options
|
|
586
|
+
const result = useAPI(
|
|
587
|
+
(api) => api.posts.get(),
|
|
588
|
+
{ enabled: true } // Skip fetching when false
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
type UseEnlaceQueryResult<TData, TError> = {
|
|
592
|
+
loading: boolean; // No cached data and fetching
|
|
593
|
+
fetching: boolean; // Request in progress
|
|
594
|
+
data: TData | undefined;
|
|
595
|
+
error: TError | undefined;
|
|
596
|
+
};
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### Selector Mode
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
type UseEnlaceSelectorResult<TMethod> = {
|
|
603
|
+
trigger: TMethod; // Function to trigger the request
|
|
604
|
+
loading: boolean;
|
|
605
|
+
fetching: boolean;
|
|
606
|
+
data: TData | undefined;
|
|
607
|
+
error: TError | undefined;
|
|
608
|
+
};
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### Request Options
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
type RequestOptions = {
|
|
615
|
+
query?: TQuery; // Query parameters (typed when using EndpointWithQuery/EndpointFull)
|
|
616
|
+
body?: TBody; // Request body (JSON)
|
|
617
|
+
formData?: TFormData; // FormData fields (auto-converted, for file uploads)
|
|
618
|
+
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>); // Request headers
|
|
619
|
+
tags?: string[]; // Cache tags (GET only)
|
|
620
|
+
revalidateTags?: string[]; // Tags to invalidate after mutation
|
|
621
|
+
pathParams?: Record<string, string | number>; // Dynamic path parameters
|
|
622
|
+
};
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
## Next.js Integration
|
|
628
|
+
|
|
629
|
+
### Server Components
|
|
630
|
+
|
|
631
|
+
Use `createEnlaceNext` from `enlace` for server components:
|
|
632
|
+
|
|
633
|
+
```typescript
|
|
634
|
+
import { createEnlaceNext } from "enlace";
|
|
635
|
+
|
|
636
|
+
type ApiError = { message: string };
|
|
637
|
+
|
|
638
|
+
const api = createEnlaceNext<ApiSchema, ApiError>("https://api.example.com", {}, {
|
|
639
|
+
autoGenerateTags: true,
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
export default async function Page() {
|
|
643
|
+
const { data } = await api.posts.get({
|
|
644
|
+
revalidate: 60, // ISR: revalidate every 60 seconds
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
return <PostList posts={data} />;
|
|
648
|
+
}
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
### Client Components
|
|
652
|
+
|
|
653
|
+
Use `createEnlaceHookNext` from `enlace/hook` for client components:
|
|
654
|
+
|
|
655
|
+
```typescript
|
|
656
|
+
"use client";
|
|
657
|
+
|
|
658
|
+
import { createEnlaceHookNext } from "enlace/hook";
|
|
659
|
+
|
|
660
|
+
type ApiError = { message: string };
|
|
661
|
+
|
|
662
|
+
const useAPI = createEnlaceHookNext<ApiSchema, ApiError>(
|
|
663
|
+
"https://api.example.com"
|
|
664
|
+
);
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### Server-Side Revalidation
|
|
668
|
+
|
|
669
|
+
Trigger Next.js cache revalidation after mutations:
|
|
670
|
+
|
|
671
|
+
```typescript
|
|
672
|
+
// actions.ts
|
|
673
|
+
"use server";
|
|
674
|
+
|
|
675
|
+
import { revalidateTag, revalidatePath } from "next/cache";
|
|
676
|
+
|
|
677
|
+
export async function revalidateAction(tags: string[], paths: string[]) {
|
|
678
|
+
for (const tag of tags) {
|
|
679
|
+
revalidateTag(tag);
|
|
680
|
+
}
|
|
681
|
+
for (const path of paths) {
|
|
682
|
+
revalidatePath(path);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
```typescript
|
|
688
|
+
// useAPI.ts
|
|
689
|
+
import { createEnlaceHookNext } from "enlace/hook";
|
|
690
|
+
import { revalidateAction } from "./actions";
|
|
691
|
+
|
|
692
|
+
type ApiError = { message: string };
|
|
693
|
+
|
|
694
|
+
const useAPI = createEnlaceHookNext<ApiSchema, ApiError>(
|
|
695
|
+
"/api",
|
|
696
|
+
{},
|
|
697
|
+
{
|
|
698
|
+
serverRevalidator: revalidateAction,
|
|
699
|
+
}
|
|
700
|
+
);
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
**In components:**
|
|
704
|
+
|
|
705
|
+
```typescript
|
|
706
|
+
function CreatePost() {
|
|
707
|
+
const { trigger } = useAPI((api) => api.posts.post);
|
|
708
|
+
|
|
709
|
+
const handleCreate = () => {
|
|
710
|
+
trigger({
|
|
711
|
+
body: { title: "New Post" },
|
|
712
|
+
revalidateTags: ["posts"], // Passed to serverRevalidator
|
|
713
|
+
revalidatePaths: ["/posts"], // Passed to serverRevalidator
|
|
714
|
+
});
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### CSR-Heavy Projects
|
|
720
|
+
|
|
721
|
+
For projects that primarily use client-side rendering with minimal SSR, you can disable server-side revalidation by default:
|
|
722
|
+
|
|
723
|
+
```typescript
|
|
724
|
+
const useAPI = createEnlaceHookNext<ApiSchema, ApiError>(
|
|
725
|
+
"/api",
|
|
726
|
+
{},
|
|
727
|
+
{
|
|
728
|
+
serverRevalidator: revalidateAction,
|
|
729
|
+
skipServerRevalidation: true, // Disable server revalidation by default
|
|
730
|
+
}
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
// Mutations won't trigger server revalidation by default
|
|
734
|
+
await trigger({ body: { title: "New Post" } });
|
|
735
|
+
|
|
736
|
+
// Opt-in to server revalidation when needed
|
|
737
|
+
await trigger({ body: { title: "New Post" }, serverRevalidate: true });
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
### Per-Request Server Revalidation Control
|
|
741
|
+
|
|
742
|
+
Override the global setting for individual requests:
|
|
743
|
+
|
|
744
|
+
```typescript
|
|
745
|
+
// Skip server revalidation for this request
|
|
746
|
+
await trigger({ body: data, serverRevalidate: false });
|
|
747
|
+
|
|
748
|
+
// Force server revalidation for this request
|
|
749
|
+
await trigger({ body: data, serverRevalidate: true });
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
### Next.js Request Options
|
|
753
|
+
|
|
754
|
+
```typescript
|
|
755
|
+
api.posts.get({
|
|
756
|
+
tags: ["posts"], // Next.js cache tags
|
|
757
|
+
revalidate: 60, // ISR revalidation (seconds)
|
|
758
|
+
revalidateTags: ["posts"], // Tags to invalidate after mutation
|
|
759
|
+
revalidatePaths: ["/"], // Paths to revalidate after mutation
|
|
760
|
+
serverRevalidate: true, // Control server-side revalidation per-request
|
|
761
|
+
});
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
### Relative URLs
|
|
765
|
+
|
|
766
|
+
Works with Next.js API routes:
|
|
767
|
+
|
|
768
|
+
```typescript
|
|
769
|
+
// Client component calling /api/posts
|
|
770
|
+
const useAPI = createEnlaceHookNext<ApiSchema, ApiError>("/api");
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
---
|
|
774
|
+
|
|
775
|
+
## API Reference
|
|
776
|
+
|
|
777
|
+
### `createEnlaceHookReact<TSchema, TDefaultError>(baseUrl, options?, hookOptions?)`
|
|
778
|
+
|
|
779
|
+
Creates a React hook for making API calls.
|
|
780
|
+
|
|
781
|
+
### `createEnlaceHookNext<TSchema, TDefaultError>(baseUrl, options?, hookOptions?)`
|
|
782
|
+
|
|
783
|
+
Creates a Next.js hook with server revalidation support.
|
|
784
|
+
|
|
785
|
+
### `createEnlace<TSchema, TDefaultError>(baseUrl, options?, callbacks?)`
|
|
786
|
+
|
|
787
|
+
Creates a typed API client (non-hook, for direct calls or server components).
|
|
788
|
+
|
|
789
|
+
### `createEnlaceNext<TSchema, TDefaultError>(baseUrl, options?, nextOptions?)`
|
|
790
|
+
|
|
791
|
+
Creates a Next.js typed API client with caching support.
|
|
792
|
+
|
|
793
|
+
**Generic Parameters:**
|
|
794
|
+
|
|
795
|
+
- `TSchema` — API schema type defining endpoints
|
|
796
|
+
- `TDefaultError` — Default error type for all endpoints (default: `unknown`)
|
|
797
|
+
|
|
798
|
+
**Function Parameters:**
|
|
799
|
+
|
|
800
|
+
- `baseUrl` — Base URL for requests
|
|
801
|
+
- `options` — Default fetch options (headers, cache, etc.)
|
|
802
|
+
- `hookOptions` / `callbacks` / `nextOptions` — Additional configuration
|
|
803
|
+
|
|
804
|
+
**Hook Options:**
|
|
805
|
+
|
|
806
|
+
```typescript
|
|
807
|
+
type EnlaceHookOptions = {
|
|
808
|
+
autoGenerateTags?: boolean; // default: true
|
|
809
|
+
autoRevalidateTags?: boolean; // default: true
|
|
810
|
+
staleTime?: number; // default: 0
|
|
811
|
+
onSuccess?: (payload: EnlaceCallbackPayload<unknown>) => void;
|
|
812
|
+
onError?: (payload: EnlaceErrorCallbackPayload<unknown>) => void;
|
|
813
|
+
};
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
### Re-exports from enlace-core
|
|
817
|
+
|
|
818
|
+
- `Endpoint` — Type helper for endpoints with JSON body
|
|
819
|
+
- `EndpointWithQuery` — Type helper for endpoints with typed query params
|
|
820
|
+
- `EndpointWithFormData` — Type helper for file upload endpoints
|
|
821
|
+
- `EndpointFull` — Object-style type helper for complex endpoints
|
|
822
|
+
- `EnlaceResponse` — Response type
|
|
823
|
+
- `EnlaceOptions` — Fetch options type
|
|
824
|
+
|
|
825
|
+
## OpenAPI Generation
|
|
826
|
+
|
|
827
|
+
Generate OpenAPI 3.0 specs from your TypeScript schema using [`enlace-openapi`](../openapi/README.md):
|
|
828
|
+
|
|
829
|
+
```bash
|
|
830
|
+
npm install enlace-openapi
|
|
831
|
+
enlace-openapi --schema ./types/APISchema.ts --output ./openapi.json
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
## License
|
|
835
|
+
|
|
836
|
+
MIT
|