ertk 0.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/LICENSE +21 -0
- package/README.md +717 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +93 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +122 -0
- package/dist/config.js.map +1 -0
- package/dist/endpoint.d.ts +9 -0
- package/dist/endpoint.d.ts.map +1 -0
- package/dist/endpoint.js +15 -0
- package/dist/endpoint.js.map +1 -0
- package/dist/generate.d.ts +20 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +653 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +2 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/route-handler.d.ts +82 -0
- package/dist/next/route-handler.d.ts.map +1 -0
- package/dist/next/route-handler.js +190 -0
- package/dist/next/route-handler.js.map +1 -0
- package/dist/types.d.ts +149 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +75 -0
package/README.md
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
# ERTK — Easy RTK
|
|
2
|
+
|
|
3
|
+
Define endpoints once, generate RTK Query hooks and Next.js route handlers automatically.
|
|
4
|
+
|
|
5
|
+
ERTK is a TypeScript code generation tool that eliminates the boilerplate of writing RTK Query APIs and Next.js App Router route handlers. You define your endpoints in simple, type-safe files — ERTK generates the rest.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Single source of truth** — Define each endpoint once with its name, method, validation, tags, and handler
|
|
10
|
+
- **RTK Query codegen** — Generates a fully typed `api.ts` with `createApi`, hooks, and cache tag configuration
|
|
11
|
+
- **Redux store scaffolding** — Generates a ready-to-use `store.ts` with the API middleware wired up
|
|
12
|
+
- **Next.js App Router routes** — Generates `route.ts` files that map HTTP methods to your handlers
|
|
13
|
+
- **Cache invalidation helpers** — Generates `invalidation.ts` with re-exported utilities
|
|
14
|
+
- **Optimistic updates** — Declarative single and multi-target optimistic update configuration
|
|
15
|
+
- **Validation** — Works with Zod (v3 & v4), Valibot, ArkType, or any schema with a `.parse()` method
|
|
16
|
+
- **Auth adapters** — Pluggable authentication via a simple `getUser(req)` interface
|
|
17
|
+
- **Incremental builds** — Manifest-based change detection skips generation when nothing changed
|
|
18
|
+
- **Watch mode** — Watches endpoint files and regenerates on save with 300ms debouncing
|
|
19
|
+
- **Path alias detection** — Auto-reads `tsconfig.json` paths to generate correct import paths
|
|
20
|
+
- **Custom error handlers** — Chainable error handlers for ORM-specific or domain errors
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install ertk
|
|
26
|
+
# or
|
|
27
|
+
pnpm add ertk
|
|
28
|
+
# or
|
|
29
|
+
yarn add ertk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Peer Dependencies
|
|
33
|
+
|
|
34
|
+
ERTK requires the following peer dependencies:
|
|
35
|
+
|
|
36
|
+
| Package | Version | Required |
|
|
37
|
+
|---------|---------|----------|
|
|
38
|
+
| `@reduxjs/toolkit` | `^2.0.0` | Yes |
|
|
39
|
+
| `react` | `>=18.0.0` | Yes |
|
|
40
|
+
| `react-redux` | `^9.0.0` | Yes |
|
|
41
|
+
| `typescript` | `^5.0.0` | Yes |
|
|
42
|
+
| `next` | `>=14.0.0` | Only for route generation |
|
|
43
|
+
| `zod` | `^3.0.0 \|\| ^4.0.0` | Only if using Zod validation |
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
### 1. Initialize your project
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx ertk init
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This creates:
|
|
54
|
+
- `ertk.config.ts` — Configuration file
|
|
55
|
+
- `src/endpoints/` — Directory for endpoint definitions
|
|
56
|
+
- `src/generated/` — Directory for generated output
|
|
57
|
+
|
|
58
|
+
### 2. Define an endpoint
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// src/endpoints/tasks/list.ts
|
|
62
|
+
import { endpoint } from "ertk";
|
|
63
|
+
import type { Task } from "@app/types/task";
|
|
64
|
+
|
|
65
|
+
export default endpoint.get<Task[]>({
|
|
66
|
+
name: "listTasks",
|
|
67
|
+
protected: true,
|
|
68
|
+
query: () => "/tasks",
|
|
69
|
+
tags: {
|
|
70
|
+
provides: ["Tasks"],
|
|
71
|
+
},
|
|
72
|
+
handler: async ({ user }) => {
|
|
73
|
+
return await db.task.findMany({ where: { userId: user.id } });
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 3. Generate
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx ertk generate
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 4. Use the generated hooks
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
import { useListTasksQuery } from "@app/generated/api";
|
|
88
|
+
|
|
89
|
+
function TaskList() {
|
|
90
|
+
const { data: tasks, isLoading } = useListTasksQuery();
|
|
91
|
+
|
|
92
|
+
if (isLoading) return <p>Loading...</p>;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<ul>
|
|
96
|
+
{tasks?.map((task) => (
|
|
97
|
+
<li key={task.id}>{task.title}</li>
|
|
98
|
+
))}
|
|
99
|
+
</ul>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## CLI
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
ertk — Easy RTK Query codegen
|
|
108
|
+
|
|
109
|
+
Usage:
|
|
110
|
+
ertk generate One-shot generation (skips if nothing changed)
|
|
111
|
+
ertk generate --watch Watch mode with incremental regeneration
|
|
112
|
+
ertk init Scaffold config file and directories
|
|
113
|
+
ertk --help Show this help message
|
|
114
|
+
|
|
115
|
+
Options:
|
|
116
|
+
--watch Watch for endpoint file changes and regenerate
|
|
117
|
+
--help Show help
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `ertk init`
|
|
121
|
+
|
|
122
|
+
Scaffolds the project structure. Creates `ertk.config.ts` and the `src/endpoints/` and `src/generated/` directories if they don't exist.
|
|
123
|
+
|
|
124
|
+
### `ertk generate`
|
|
125
|
+
|
|
126
|
+
Runs a one-shot generation. Compares an MD5 manifest of all endpoint files against the previous run and skips generation if nothing has changed.
|
|
127
|
+
|
|
128
|
+
### `ertk generate --watch`
|
|
129
|
+
|
|
130
|
+
Runs an initial full build, then watches the endpoints directory for file changes. Uses a 300ms debounce to batch rapid saves. When an endpoint file is modified, only that file is re-parsed and the full output is regenerated.
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
Create an `ertk.config.ts` (or `.mts`, `.js`, `.mjs`) in your project root:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { defineConfig } from "ertk";
|
|
138
|
+
|
|
139
|
+
export default defineConfig({
|
|
140
|
+
// Directory containing endpoint definition files
|
|
141
|
+
endpoints: "src/endpoints",
|
|
142
|
+
|
|
143
|
+
// Directory for generated output (api.ts, store.ts, invalidation.ts)
|
|
144
|
+
generated: "src/generated",
|
|
145
|
+
|
|
146
|
+
// Base URL for RTK Query fetchBaseQuery
|
|
147
|
+
baseUrl: "/api",
|
|
148
|
+
|
|
149
|
+
// Route generation config (omit entirely to skip route generation)
|
|
150
|
+
routes: {
|
|
151
|
+
dir: "src/app/api",
|
|
152
|
+
handlerModule: "ertk/next",
|
|
153
|
+
ignoredRoutes: ["auth"],
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Config Options
|
|
159
|
+
|
|
160
|
+
| Option | Type | Default | Description |
|
|
161
|
+
|--------|------|---------|-------------|
|
|
162
|
+
| `endpoints` | `string` | `"src/endpoints"` | Directory containing endpoint definition files |
|
|
163
|
+
| `generated` | `string` | `"src/generated"` | Directory for generated output files |
|
|
164
|
+
| `baseUrl` | `string` | `"/api"` | Base URL for `fetchBaseQuery` |
|
|
165
|
+
| `baseQuery` | `string` | — | Custom `baseQuery` source code (overrides `baseUrl`) |
|
|
166
|
+
| `pathAlias` | `string` | auto-detected | Path alias prefix (e.g., `"@app"`, `"@src"`) |
|
|
167
|
+
| `crudFilenames` | `string[]` | see below | Filenames that map to CRUD operations |
|
|
168
|
+
| `routes` | `object \| undefined` | — | Route generation config; omit to skip |
|
|
169
|
+
|
|
170
|
+
**Default CRUD filenames:** `["get", "list", "create", "update", "delete", "send", "remove", "cancel"]`
|
|
171
|
+
|
|
172
|
+
CRUD filenames determine which endpoint filenames become URL segments and which don't. For example, `src/endpoints/tasks/list.ts` generates the route `/api/tasks` (not `/api/tasks/list`) because `list` is a CRUD filename.
|
|
173
|
+
|
|
174
|
+
### Route Generation Options
|
|
175
|
+
|
|
176
|
+
| Option | Type | Default | Description |
|
|
177
|
+
|--------|------|---------|-------------|
|
|
178
|
+
| `routes.dir` | `string` | — | Directory where Next.js route files are generated |
|
|
179
|
+
| `routes.handlerModule` | `string` | `"ertk/next"` | Module that exports `createRouteHandler` |
|
|
180
|
+
| `routes.ignoredRoutes` | `string[]` | `[]` | Top-level route directories to skip |
|
|
181
|
+
|
|
182
|
+
### Custom `baseQuery`
|
|
183
|
+
|
|
184
|
+
For full control over fetch configuration (auth headers, base URLs, etc.):
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
export default defineConfig({
|
|
188
|
+
baseQuery: `fetchBaseQuery({
|
|
189
|
+
baseUrl: "https://api.example.com",
|
|
190
|
+
prepareHeaders: (headers) => {
|
|
191
|
+
headers.set("Authorization", \`Bearer \${getToken()}\`);
|
|
192
|
+
return headers;
|
|
193
|
+
},
|
|
194
|
+
})`,
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Path Alias Auto-Detection
|
|
199
|
+
|
|
200
|
+
ERTK automatically reads your `tsconfig.json` to detect path aliases. If you have:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"compilerOptions": {
|
|
205
|
+
"paths": {
|
|
206
|
+
"@app/*": ["./src/*"]
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
ERTK will use `@app` as the import prefix in generated files. If no alias is found, it defaults to `@app` with a `src` root.
|
|
213
|
+
|
|
214
|
+
## Endpoint Definitions
|
|
215
|
+
|
|
216
|
+
Endpoints are defined using the `endpoint` factory, which provides methods for each HTTP verb:
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
import { endpoint } from "ertk";
|
|
220
|
+
|
|
221
|
+
// Available methods
|
|
222
|
+
endpoint.get<ResponseType, ArgsType>({ ... })
|
|
223
|
+
endpoint.post<ResponseType, ArgsType>({ ... })
|
|
224
|
+
endpoint.put<ResponseType, ArgsType>({ ... })
|
|
225
|
+
endpoint.patch<ResponseType, ArgsType>({ ... })
|
|
226
|
+
endpoint.delete<ResponseType, ArgsType>({ ... })
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Each file should have a single `default export` of an endpoint definition.
|
|
230
|
+
|
|
231
|
+
### Endpoint Options
|
|
232
|
+
|
|
233
|
+
| Option | Type | Default | Description |
|
|
234
|
+
|--------|------|---------|-------------|
|
|
235
|
+
| `name` | `string` | — | **Required.** Name for the generated hook (e.g., `"getTasks"` becomes `useGetTasksQuery`) |
|
|
236
|
+
| `protected` | `boolean` | `true` | Whether the endpoint requires authentication |
|
|
237
|
+
| `query` | `(args) => string \| { url, method?, body? }` | — | Client-side query function for RTK Query |
|
|
238
|
+
| `request` | `ValidationSchema` | — | Request validation schema (Zod, Valibot, etc.) |
|
|
239
|
+
| `tags` | `{ provides?, invalidates? }` | — | RTK Query cache tag configuration |
|
|
240
|
+
| `optimistic` | `SingleOptimistic \| MultiOptimistic` | — | Optimistic update configuration |
|
|
241
|
+
| `handler` | `(ctx) => Promise<unknown>` | — | Server-side handler (omit for client-only endpoints) |
|
|
242
|
+
|
|
243
|
+
### GET Endpoint (Query)
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// src/endpoints/tasks/get.ts
|
|
247
|
+
import { endpoint } from "ertk";
|
|
248
|
+
import type { Task } from "@app/types/task";
|
|
249
|
+
|
|
250
|
+
export default endpoint.get<Task, { id: string }>({
|
|
251
|
+
name: "getTask",
|
|
252
|
+
protected: true,
|
|
253
|
+
query: ({ id }) => `/tasks/${id}`,
|
|
254
|
+
tags: {
|
|
255
|
+
provides: (result, _error, { id }) => [{ type: "Tasks", id }],
|
|
256
|
+
},
|
|
257
|
+
handler: async ({ query, user }) => {
|
|
258
|
+
return await db.task.findUnique({
|
|
259
|
+
where: { id: query.id, userId: user.id },
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### POST Endpoint (Mutation)
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
// src/endpoints/tasks/create.ts
|
|
269
|
+
import { endpoint } from "ertk";
|
|
270
|
+
import { z } from "zod";
|
|
271
|
+
import type { Task, CreateTaskInput } from "@app/types/task";
|
|
272
|
+
|
|
273
|
+
const createTaskSchema = z.object({
|
|
274
|
+
title: z.string().min(1),
|
|
275
|
+
description: z.string().optional(),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
export default endpoint.post<Task, CreateTaskInput>({
|
|
279
|
+
name: "createTask",
|
|
280
|
+
protected: true,
|
|
281
|
+
request: createTaskSchema,
|
|
282
|
+
query: (body) => ({ url: "/tasks", method: "POST", body }),
|
|
283
|
+
tags: {
|
|
284
|
+
invalidates: ["Tasks"],
|
|
285
|
+
},
|
|
286
|
+
handler: async ({ body, user }) => {
|
|
287
|
+
return await db.task.create({
|
|
288
|
+
data: { ...body, userId: user.id },
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Client-Only Endpoint (No Handler)
|
|
295
|
+
|
|
296
|
+
For endpoints that consume an external API (no server-side handler needed):
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
// src/endpoints/weather/get.ts
|
|
300
|
+
import { endpoint } from "ertk";
|
|
301
|
+
import type { WeatherData } from "@app/types/weather";
|
|
302
|
+
|
|
303
|
+
export default endpoint.get<WeatherData, { city: string }>({
|
|
304
|
+
name: "getWeather",
|
|
305
|
+
protected: false,
|
|
306
|
+
query: ({ city }) => `/weather?city=${city}`,
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Client-only endpoints (no `handler`) are excluded from route generation but are still included in the generated RTK Query API.
|
|
311
|
+
|
|
312
|
+
### File Structure and Route Mapping
|
|
313
|
+
|
|
314
|
+
Endpoint file paths map to API routes. CRUD filenames (configurable) are stripped from the URL:
|
|
315
|
+
|
|
316
|
+
| File Path | Route |
|
|
317
|
+
|-----------|-------|
|
|
318
|
+
| `src/endpoints/tasks/list.ts` | `/api/tasks` |
|
|
319
|
+
| `src/endpoints/tasks/create.ts` | `/api/tasks` |
|
|
320
|
+
| `src/endpoints/tasks/get.ts` | `/api/tasks` |
|
|
321
|
+
| `src/endpoints/users/profile/update.ts` | `/api/users/profile` |
|
|
322
|
+
| `src/endpoints/billing/invoices.ts` | `/api/billing/invoices` |
|
|
323
|
+
|
|
324
|
+
Multiple endpoints that resolve to the same route are grouped into a single `route.ts` file, each exported as the appropriate HTTP method (`GET`, `POST`, `PUT`, etc.).
|
|
325
|
+
|
|
326
|
+
## Generated Output
|
|
327
|
+
|
|
328
|
+
Running `ertk generate` produces the following files:
|
|
329
|
+
|
|
330
|
+
### `api.ts`
|
|
331
|
+
|
|
332
|
+
The RTK Query API definition with all endpoints and exported hooks:
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
// AUTO-GENERATED by ERTK codegen. Do not edit.
|
|
336
|
+
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
|
337
|
+
import type { Task } from "@app/types/task";
|
|
338
|
+
|
|
339
|
+
export const api = createApi({
|
|
340
|
+
reducerPath: "api",
|
|
341
|
+
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
|
|
342
|
+
tagTypes: ["Tasks"],
|
|
343
|
+
refetchOnFocus: false,
|
|
344
|
+
refetchOnReconnect: true,
|
|
345
|
+
endpoints: (builder) => ({
|
|
346
|
+
listTasks: builder.query<Task[], void>({
|
|
347
|
+
query: () => "/tasks",
|
|
348
|
+
providesTags: ["Tasks"],
|
|
349
|
+
}),
|
|
350
|
+
createTask: builder.mutation<Task, CreateTaskInput>({
|
|
351
|
+
query: (body) => ({ url: "/tasks", method: "POST", body }),
|
|
352
|
+
invalidatesTags: ["Tasks"],
|
|
353
|
+
}),
|
|
354
|
+
}),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
export const {
|
|
358
|
+
useListTasksQuery,
|
|
359
|
+
useCreateTaskMutation,
|
|
360
|
+
} = api;
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### `store.ts`
|
|
364
|
+
|
|
365
|
+
A pre-configured Redux store:
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
// AUTO-GENERATED by ERTK codegen. Do not edit.
|
|
369
|
+
import { configureStore } from "@reduxjs/toolkit";
|
|
370
|
+
import { api } from "./api";
|
|
371
|
+
|
|
372
|
+
export const store = configureStore({
|
|
373
|
+
reducer: {
|
|
374
|
+
[api.reducerPath]: api.reducer,
|
|
375
|
+
},
|
|
376
|
+
middleware: (getDefaultMiddleware) =>
|
|
377
|
+
getDefaultMiddleware().concat(api.middleware),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
export type RootState = ReturnType<typeof store.getState>;
|
|
381
|
+
export type AppDispatch = typeof store.dispatch;
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### `invalidation.ts`
|
|
385
|
+
|
|
386
|
+
Cache invalidation helper re-exports:
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
// AUTO-GENERATED by ERTK codegen. Do not edit.
|
|
390
|
+
import { api } from "./api";
|
|
391
|
+
|
|
392
|
+
export function invalidateTags(
|
|
393
|
+
...args: Parameters<typeof api.util.invalidateTags>
|
|
394
|
+
) {
|
|
395
|
+
return api.util.invalidateTags(...args);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export const updateQueryData = api.util.updateQueryData;
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Route Files (Next.js)
|
|
402
|
+
|
|
403
|
+
Generated in your configured routes directory (e.g., `src/app/api/tasks/route.ts`):
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
// AUTO-GENERATED by ERTK codegen. Do not edit.
|
|
407
|
+
import { createRouteHandler } from "ertk/next";
|
|
408
|
+
import listTasksEndpoint from "@app/endpoints/tasks/list";
|
|
409
|
+
import createTaskEndpoint from "@app/endpoints/tasks/create";
|
|
410
|
+
|
|
411
|
+
export const GET = createRouteHandler(listTasksEndpoint);
|
|
412
|
+
export const POST = createRouteHandler(createTaskEndpoint);
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
## Next.js Route Handlers
|
|
416
|
+
|
|
417
|
+
### Setting Up Auth
|
|
418
|
+
|
|
419
|
+
For protected endpoints, configure an auth adapter:
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
// src/lib/ertk-handler.ts
|
|
423
|
+
import { configureHandler } from "ertk/next";
|
|
424
|
+
import { getServerSession } from "next-auth";
|
|
425
|
+
import { authOptions } from "@app/lib/auth";
|
|
426
|
+
import { db } from "@app/lib/db";
|
|
427
|
+
|
|
428
|
+
export const createRouteHandler = configureHandler({
|
|
429
|
+
auth: {
|
|
430
|
+
getUser: async (req) => {
|
|
431
|
+
const session = await getServerSession(authOptions);
|
|
432
|
+
if (!session?.user?.email) return null;
|
|
433
|
+
return await db.user.findUnique({
|
|
434
|
+
where: { email: session.user.email },
|
|
435
|
+
});
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Then set `handlerModule` in your config to point to your custom module:
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
// ertk.config.ts
|
|
445
|
+
export default defineConfig({
|
|
446
|
+
routes: {
|
|
447
|
+
dir: "src/app/api",
|
|
448
|
+
handlerModule: "@app/lib/ertk-handler",
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Custom Error Handlers
|
|
454
|
+
|
|
455
|
+
Add ORM-specific or domain-specific error handling:
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
import { configureHandler } from "ertk/next";
|
|
459
|
+
import { Prisma } from "@prisma/client";
|
|
460
|
+
|
|
461
|
+
export const createRouteHandler = configureHandler({
|
|
462
|
+
auth: { /* ... */ },
|
|
463
|
+
errorHandlers: [
|
|
464
|
+
(error) => {
|
|
465
|
+
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
466
|
+
if (error.code === "P2025") {
|
|
467
|
+
return new Response(
|
|
468
|
+
JSON.stringify({ error: "Not found" }),
|
|
469
|
+
{ status: 404, headers: { "Content-Type": "application/json" } },
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return null; // Pass to next handler
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
});
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Error handlers are processed in order. The first handler to return a non-null `Response` wins. If no handler matches, ERTK falls back to built-in handling:
|
|
480
|
+
|
|
481
|
+
1. `ValidationError` → 400 with validation details
|
|
482
|
+
2. Errors with a numeric `status` property → uses that status code
|
|
483
|
+
3. All other errors → 500 with generic message (details logged server-side)
|
|
484
|
+
|
|
485
|
+
### Request Parsing
|
|
486
|
+
|
|
487
|
+
ERTK automatically handles request parsing based on the HTTP method:
|
|
488
|
+
|
|
489
|
+
- **GET, DELETE, HEAD, OPTIONS** — Parses `URLSearchParams` into an object (with automatic string-to-number coercion)
|
|
490
|
+
- **POST, PUT, PATCH** — Parses JSON request body
|
|
491
|
+
|
|
492
|
+
If a `request` schema is provided on the endpoint, the parsed data is validated through `schema.parse()` before reaching the handler.
|
|
493
|
+
|
|
494
|
+
### Handler Context
|
|
495
|
+
|
|
496
|
+
Every handler receives a context object:
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
interface HandlerContext<TBody, TQuery, TUser> {
|
|
500
|
+
user: TUser; // Resolved user (from auth adapter)
|
|
501
|
+
body: TBody; // Parsed & validated request body
|
|
502
|
+
query: TQuery; // Parsed & validated query parameters
|
|
503
|
+
params: Record<string, string>; // URL path parameters (Next.js dynamic segments)
|
|
504
|
+
req: Request; // Raw Request object
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
## Cache Tags
|
|
509
|
+
|
|
510
|
+
ERTK supports RTK Query's full tag system for automatic cache invalidation.
|
|
511
|
+
|
|
512
|
+
### Static Tags
|
|
513
|
+
|
|
514
|
+
```typescript
|
|
515
|
+
export default endpoint.get<Task[]>({
|
|
516
|
+
name: "listTasks",
|
|
517
|
+
tags: {
|
|
518
|
+
provides: ["Tasks"],
|
|
519
|
+
},
|
|
520
|
+
// ...
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
export default endpoint.post<Task, CreateTaskInput>({
|
|
524
|
+
name: "createTask",
|
|
525
|
+
tags: {
|
|
526
|
+
invalidates: ["Tasks"],
|
|
527
|
+
},
|
|
528
|
+
// ...
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### Dynamic Tags
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
export default endpoint.get<Task, { id: string }>({
|
|
536
|
+
name: "getTask",
|
|
537
|
+
tags: {
|
|
538
|
+
provides: (result, _error, { id }) => [{ type: "Tasks", id }],
|
|
539
|
+
},
|
|
540
|
+
// ...
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
export default endpoint.put<Task, { id: string; title: string }>({
|
|
544
|
+
name: "updateTask",
|
|
545
|
+
tags: {
|
|
546
|
+
invalidates: (_result, _error, { id }) => [
|
|
547
|
+
{ type: "Tasks", id },
|
|
548
|
+
"Tasks",
|
|
549
|
+
],
|
|
550
|
+
},
|
|
551
|
+
// ...
|
|
552
|
+
});
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Tag types are automatically extracted from your endpoint definitions and included in the generated `createApi({ tagTypes: [...] })` call.
|
|
556
|
+
|
|
557
|
+
## Optimistic Updates
|
|
558
|
+
|
|
559
|
+
ERTK supports declarative optimistic updates that generate the `onQueryStarted` boilerplate for you.
|
|
560
|
+
|
|
561
|
+
### Single Target
|
|
562
|
+
|
|
563
|
+
Update a single cached query when a mutation fires:
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
export default endpoint.put<Task, { id: string; completed: boolean }>({
|
|
567
|
+
name: "toggleTask",
|
|
568
|
+
optimistic: {
|
|
569
|
+
target: "listTasks",
|
|
570
|
+
args: (params) => undefined,
|
|
571
|
+
update: (draft, params) => {
|
|
572
|
+
const tasks = draft as Task[];
|
|
573
|
+
const task = tasks.find((t) => t.id === params.id);
|
|
574
|
+
if (task) task.completed = params.completed;
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
// ...
|
|
578
|
+
});
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### Multi Target
|
|
582
|
+
|
|
583
|
+
Update multiple cached queries with optional conditions:
|
|
584
|
+
|
|
585
|
+
```typescript
|
|
586
|
+
export default endpoint.delete<void, { id: string; listId: string }>({
|
|
587
|
+
name: "deleteTask",
|
|
588
|
+
optimistic: {
|
|
589
|
+
updates: [
|
|
590
|
+
{
|
|
591
|
+
target: "listTasks",
|
|
592
|
+
args: (params) => undefined,
|
|
593
|
+
update: (draft, params) => {
|
|
594
|
+
const tasks = draft as Task[];
|
|
595
|
+
const index = tasks.findIndex((t) => t.id === params.id);
|
|
596
|
+
if (index !== -1) tasks.splice(index, 1);
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
target: "getTaskList",
|
|
601
|
+
args: (params) => params.listId,
|
|
602
|
+
update: (draft, params) => {
|
|
603
|
+
const list = draft as TaskList;
|
|
604
|
+
list.count -= 1;
|
|
605
|
+
},
|
|
606
|
+
condition: (params) => !!params.listId,
|
|
607
|
+
},
|
|
608
|
+
],
|
|
609
|
+
},
|
|
610
|
+
// ...
|
|
611
|
+
});
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
The generated code automatically handles `queryFulfilled` awaiting and rolls back all patches on failure.
|
|
615
|
+
|
|
616
|
+
## Validation
|
|
617
|
+
|
|
618
|
+
ERTK works with any validation library that exposes a `.parse(data) => T` method.
|
|
619
|
+
|
|
620
|
+
### With Zod
|
|
621
|
+
|
|
622
|
+
```typescript
|
|
623
|
+
import { z } from "zod";
|
|
624
|
+
|
|
625
|
+
const createTaskSchema = z.object({
|
|
626
|
+
title: z.string().min(1),
|
|
627
|
+
description: z.string().optional(),
|
|
628
|
+
priority: z.enum(["low", "medium", "high"]).default("medium"),
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
export default endpoint.post<Task, z.infer<typeof createTaskSchema>>({
|
|
632
|
+
name: "createTask",
|
|
633
|
+
request: createTaskSchema,
|
|
634
|
+
// ...
|
|
635
|
+
});
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
### With Any `.parse()` Compatible Library
|
|
639
|
+
|
|
640
|
+
```typescript
|
|
641
|
+
const schema = {
|
|
642
|
+
parse: (data: unknown) => {
|
|
643
|
+
// Custom validation logic
|
|
644
|
+
if (!data || typeof data !== "object") throw new Error("Invalid input");
|
|
645
|
+
return data as MyType;
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
export default endpoint.post<MyType, MyInput>({
|
|
650
|
+
name: "createItem",
|
|
651
|
+
request: schema,
|
|
652
|
+
// ...
|
|
653
|
+
});
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Validation errors are caught by the route handler and returned as 400 responses with structured error details when using Zod.
|
|
657
|
+
|
|
658
|
+
## API Reference
|
|
659
|
+
|
|
660
|
+
### `ertk` (Main Entry Point)
|
|
661
|
+
|
|
662
|
+
| Export | Type | Description |
|
|
663
|
+
|--------|------|-------------|
|
|
664
|
+
| `endpoint` | `object` | Factory with `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()` methods |
|
|
665
|
+
| `defineConfig` | `(config: ErtkConfig) => ErtkConfig` | Type-safe config wrapper |
|
|
666
|
+
|
|
667
|
+
### `ertk/next` (Next.js Entry Point)
|
|
668
|
+
|
|
669
|
+
| Export | Type | Description |
|
|
670
|
+
|--------|------|-------------|
|
|
671
|
+
| `configureHandler` | `(options?) => createRouteHandler` | Creates a configured route handler factory |
|
|
672
|
+
| `createRouteHandler` | `(def) => RequestHandler` | Default handler (no auth, no custom errors) |
|
|
673
|
+
| `ErtkAuthAdapter` | `interface` | Auth adapter shape: `{ getUser(req) => Promise<User \| null> }` |
|
|
674
|
+
| `ErtkErrorHandler` | `type` | Error handler: `(error) => Response \| null` |
|
|
675
|
+
| `ConfigureHandlerOptions` | `interface` | Options for `configureHandler` |
|
|
676
|
+
|
|
677
|
+
### Types
|
|
678
|
+
|
|
679
|
+
| Type | Description |
|
|
680
|
+
|------|-------------|
|
|
681
|
+
| `EndpointDefinition<TResponse, TArgs>` | Main endpoint configuration interface |
|
|
682
|
+
| `HandlerContext<TBody, TQuery, TUser>` | Server-side handler context |
|
|
683
|
+
| `DefaultUser` | Minimal user shape (`{ id: string }`) |
|
|
684
|
+
| `ValidationSchema<T>` | Generic validation interface (`.parse()` compatible) |
|
|
685
|
+
| `TagType` | String tag identifier |
|
|
686
|
+
| `TagDescription` | Tag string or `{ type, id }` object |
|
|
687
|
+
| `SingleOptimistic<TArgs>` | Single-target optimistic update config |
|
|
688
|
+
| `MultiOptimistic<TArgs>` | Multi-target optimistic update config |
|
|
689
|
+
| `ErtkConfig` | User-facing config type |
|
|
690
|
+
| `ErtkRoutesConfig` | Route generation config type |
|
|
691
|
+
|
|
692
|
+
## Known Issues and Caveats
|
|
693
|
+
|
|
694
|
+
### Endpoint Parsing
|
|
695
|
+
|
|
696
|
+
- **Malformed endpoints are silently skipped.** If an endpoint file lacks a default export, an `endpoint.{method}()` call, or a `name` property, it is skipped with a `console.warn`. Check your terminal output if endpoints are missing from generated code.
|
|
697
|
+
- **AST extraction assumes standard patterns.** The parser expects `endpoint.get<...>({ ... })` call syntax directly. Wrapping in helper functions, using spread operators, or storing the config in a separate variable may not be detected.
|
|
698
|
+
- **Type imports are not transitively resolved.** Only types directly imported in the endpoint file are carried over to the generated `api.ts`. If your response type re-exports from another module, you may need to import the underlying type directly.
|
|
699
|
+
|
|
700
|
+
### Optimistic Updates
|
|
701
|
+
|
|
702
|
+
- **Parsed via regex, not AST.** The optimistic update extraction uses regex matching, which can break with unusual formatting, computed property names, or complex expressions inside `target`, `args`, or `update` fields. Keep optimistic configurations simple and well-formatted.
|
|
703
|
+
|
|
704
|
+
### Route Generation
|
|
705
|
+
|
|
706
|
+
- **Deleted endpoints don't clean up routes.** In watch mode, if you delete an endpoint file, the corresponding route handler file is not automatically removed. You'll need to delete stale route files manually or re-run a fresh `ertk generate` after cleaning the output directory.
|
|
707
|
+
- **Route path validation is minimal.** Generated route paths are derived from file paths without checking for special characters that could produce invalid Next.js route segments.
|
|
708
|
+
|
|
709
|
+
### General
|
|
710
|
+
|
|
711
|
+
- **`refetchOnFocus` and `refetchOnReconnect` are hardcoded.** The generated API sets `refetchOnFocus: false` and `refetchOnReconnect: true`. These are not yet configurable via `ertk.config.ts`.
|
|
712
|
+
- **No formatting of generated code.** Generated files use tabs and don't pass through Prettier or ESLint. Add generated paths to your formatter's include list if you want consistent style.
|
|
713
|
+
- **No test suite.** The package does not currently include automated tests.
|
|
714
|
+
|
|
715
|
+
## License
|
|
716
|
+
|
|
717
|
+
MIT
|