routesync 1.0.25 → 1.0.27
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 +245 -206
- package/dist/cli.js +39 -60
- package/dist/react.d.mts +5 -2
- package/dist/react.d.ts +5 -2
- package/dist/sdk.d.mts +3 -1
- package/dist/sdk.d.ts +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Stop writing API clients by hand.
|
|
4
4
|
|
|
5
|
-
RouteSync syncs your Laravel (or PHP) routes to a fully-typed frontend SDK — complete with TypeScript types, a camelCase mapper,
|
|
5
|
+
RouteSync syncs your Laravel (or PHP) routes to a fully-typed frontend SDK — complete with TypeScript types, a camelCase mapper, React/Vue Query hooks, and Next.js Server Actions. One command. Zero boilerplate.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -13,21 +13,26 @@ You've been there. The backend ships a new endpoint. You update the route, write
|
|
|
13
13
|
RouteSync does all of that. You point it at `routes/api.php` and it generates the whole thing.
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
# Step 1 — in your Laravel folder
|
|
17
|
+
npx routesync scan --input routes/api.php --models
|
|
18
18
|
|
|
19
|
+
# Step 2 — in your frontend folder
|
|
20
|
+
npx routesync generate --manifest routesync.manifest.json --output src/api --next-actions --zod
|
|
19
21
|
```
|
|
20
|
-
routesync sync
|
|
21
|
-
|
|
22
|
-
✔ Scanning Laravel routes (24 routes)
|
|
23
|
-
✔ Generating types
|
|
24
|
-
✔ Generating SDK
|
|
25
|
-
✔ Generating hooks
|
|
26
22
|
|
|
27
|
-
|
|
23
|
+
```
|
|
24
|
+
✔ Found 35 routes, 19 models → routesync.manifest.json
|
|
25
|
+
|
|
26
|
+
✔ SDK generated → src/api
|
|
27
|
+
api.ts Typed API client
|
|
28
|
+
types.ts TypeScript interfaces (from real DB columns)
|
|
29
|
+
hooks.ts React Query hooks
|
|
30
|
+
actions.ts Next.js Server Actions
|
|
31
|
+
schemas.ts Zod validation schemas
|
|
32
|
+
index.ts Barrel export
|
|
28
33
|
```
|
|
29
34
|
|
|
30
|
-
That's it. Your frontend has a typed client,
|
|
35
|
+
That's it. Your frontend has a typed client, real DB-derived types, Zod schemas, and ready-to-use hooks — and you didn't write any of it.
|
|
31
36
|
|
|
32
37
|
---
|
|
33
38
|
|
|
@@ -35,10 +40,10 @@ That's it. Your frontend has a typed client, response types, and ready-to-use ho
|
|
|
35
40
|
|
|
36
41
|
| Package | What it does |
|
|
37
42
|
|---|---|
|
|
38
|
-
| `@routesync/sdk` | The core developer API. `defineApi`, `
|
|
43
|
+
| `@routesync/sdk` | The core developer API. `defineApi`, `endpoint`, `resource`, `createService`. |
|
|
39
44
|
| `@routesync/core` | HTTP client engine, auth, path resolution, error handling. |
|
|
40
|
-
| `@routesync/cli` | Scans routes, generates types + SDK + hooks. |
|
|
41
|
-
| `@routesync/react` |
|
|
45
|
+
| `@routesync/cli` | Scans routes + models, generates types + SDK + hooks + actions. |
|
|
46
|
+
| `@routesync/react` | `useApiQuery` / `useApiMutation` hooks built on TanStack Query. |
|
|
42
47
|
| `@routesync/vue` | Vue Query composables, same idea. |
|
|
43
48
|
|
|
44
49
|
---
|
|
@@ -46,304 +51,338 @@ That's it. Your frontend has a typed client, response types, and ready-to-use ho
|
|
|
46
51
|
## Install
|
|
47
52
|
|
|
48
53
|
```bash
|
|
49
|
-
# SDK
|
|
50
|
-
npm install @
|
|
54
|
+
# SDK + React hooks
|
|
55
|
+
npm install routesync @tanstack/react-query
|
|
51
56
|
|
|
52
|
-
#
|
|
53
|
-
npm install
|
|
57
|
+
# Vue composables
|
|
58
|
+
npm install routesync @tanstack/vue-query
|
|
54
59
|
|
|
55
|
-
# With
|
|
56
|
-
npm install
|
|
57
|
-
|
|
58
|
-
# CLI (route scanner + code generator)
|
|
59
|
-
npm install -g @routesync/cli
|
|
60
|
+
# With Zod validation
|
|
61
|
+
npm install routesync zod
|
|
60
62
|
```
|
|
61
63
|
|
|
62
64
|
---
|
|
63
65
|
|
|
64
|
-
##
|
|
65
|
-
|
|
66
|
-
### Option A — Define routes manually
|
|
66
|
+
## Full Workflow (Laravel + Next.js)
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
### 1. Scan routes & models
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
import { defineApi } from '@routesync/sdk'
|
|
72
|
-
|
|
73
|
-
export const api = defineApi(
|
|
74
|
-
{
|
|
75
|
-
auth: {
|
|
76
|
-
login: { method: 'POST', path: '/login' },
|
|
77
|
-
logout: { method: 'POST', path: '/logout', auth: true },
|
|
78
|
-
},
|
|
79
|
-
products: {
|
|
80
|
-
list: { method: 'GET', path: '/products' },
|
|
81
|
-
detail: { method: 'GET', path: '/products/:id' },
|
|
82
|
-
create: { method: 'POST', path: '/products', auth: true },
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
{ baseURL: 'https://api.myapp.com/api' }
|
|
86
|
-
)
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
Then call it:
|
|
90
|
-
|
|
91
|
-
```ts
|
|
92
|
-
// GET /products?page=1&search=kaos
|
|
93
|
-
await api.products.list({ query: { page: 1, search: 'kaos' } })
|
|
70
|
+
Run this from your **Laravel project root**:
|
|
94
71
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// POST /products
|
|
99
|
-
await api.products.create({ body: { name: 'Kaos Polos', price: 89000 } })
|
|
72
|
+
```bash
|
|
73
|
+
npx routesync scan --input routes/api.php --models
|
|
100
74
|
```
|
|
101
75
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
76
|
+
| Option | Default | Description |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `--input` | `routes/api.php` | Path to your Laravel routes file |
|
|
79
|
+
| `--output` | `routesync.manifest.json` | Where to save the manifest |
|
|
80
|
+
| `--baseURL` | `http://localhost/api` | API base URL |
|
|
81
|
+
| `--models` | off | Also scan `app/Models/` for real DB column types |
|
|
82
|
+
|
|
83
|
+
> **`--models` requirement:** PHP must be available in your terminal and your database must be accessible (`.env` configured). The scanner runs a temporary PHP script via Laravel's bootstrap to read `Schema::getColumns()` from each Eloquent model.
|
|
84
|
+
|
|
85
|
+
> **Important — manifest location:** The manifest is saved in whichever folder you run `scan` from. If you run it from your Laravel root, copy the manifest to your frontend folder before running `generate`:
|
|
86
|
+
>
|
|
87
|
+
> ```bash
|
|
88
|
+
> # Windows PowerShell
|
|
89
|
+
> copy ..\routesync.manifest.json .
|
|
90
|
+
>
|
|
91
|
+
> # macOS / Linux
|
|
92
|
+
> cp ../backend/routesync.manifest.json .
|
|
93
|
+
> ```
|
|
105
94
|
|
|
106
|
-
###
|
|
95
|
+
### 2. Generate the SDK
|
|
107
96
|
|
|
108
|
-
|
|
97
|
+
Run this from your **frontend project root**:
|
|
109
98
|
|
|
110
99
|
```bash
|
|
111
|
-
npx routesync
|
|
112
|
-
--
|
|
100
|
+
npx routesync generate \
|
|
101
|
+
--manifest routesync.manifest.json \
|
|
113
102
|
--output src/api \
|
|
114
|
-
--
|
|
103
|
+
--next-actions \
|
|
104
|
+
--zod
|
|
115
105
|
```
|
|
116
106
|
|
|
117
|
-
|
|
107
|
+
| Option | Default | Description |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `--manifest` | `routesync.manifest.json` | Path to manifest from step 1 |
|
|
110
|
+
| `--output` | `src/api` | Output folder |
|
|
111
|
+
| `--next-actions` | off | Generate `actions.ts` (Next.js Server Actions) |
|
|
112
|
+
| `--zod` | off | Generate `schemas.ts` (Zod validation) |
|
|
113
|
+
| `--no-hooks` | off | Skip generating `hooks.ts` |
|
|
114
|
+
| `--msw` | off | Generate MSW mock handlers |
|
|
115
|
+
|
|
116
|
+
> **Windows PowerShell note:** Do not use backslash `\` for line continuation — PowerShell treats it differently. Run the command on a single line:
|
|
117
|
+
>
|
|
118
|
+
> ```powershell
|
|
119
|
+
> npx routesync generate --manifest routesync.manifest.json --output src/api --next-actions --zod
|
|
120
|
+
> ```
|
|
121
|
+
|
|
122
|
+
### Generated files
|
|
118
123
|
|
|
119
124
|
```
|
|
120
125
|
src/api/
|
|
121
|
-
├── api.ts
|
|
122
|
-
├── types.ts
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
npx routesync watch --input routes/api.php --output src/api
|
|
126
|
+
├── api.ts ← defineApi() with all endpoints + Contract types
|
|
127
|
+
├── types.ts ← TypeScript interfaces (real DB columns when --models used)
|
|
128
|
+
├── hooks.ts ← useApiQuery / useApiMutation per endpoint
|
|
129
|
+
├── actions.ts ← Next.js Server Actions (--next-actions)
|
|
130
|
+
├── schemas.ts ← Zod schemas from FormRequest rules (--zod)
|
|
131
|
+
├── index.ts ← Barrel re-export
|
|
132
|
+
└── core/
|
|
133
|
+
└── models.ts ← Raw Eloquent model interfaces (when --models used)
|
|
130
134
|
```
|
|
131
135
|
|
|
132
|
-
|
|
136
|
+
### 3. Initialize the client
|
|
133
137
|
|
|
134
|
-
|
|
138
|
+
Call `createClient` once at app startup (e.g. in your layout or provider):
|
|
135
139
|
|
|
136
140
|
```ts
|
|
137
|
-
|
|
141
|
+
// src/lib/api-client.ts
|
|
142
|
+
import { createClient } from 'routesync'
|
|
138
143
|
|
|
139
|
-
|
|
140
|
-
baseURL:
|
|
144
|
+
createClient({
|
|
145
|
+
baseURL: process.env.NEXT_PUBLIC_API_URL!, // e.g. http://localhost:8000/api
|
|
146
|
+
withCredentials: true,
|
|
141
147
|
})
|
|
142
|
-
|
|
143
|
-
// After login:
|
|
144
|
-
setToken(response.data.token)
|
|
145
|
-
|
|
146
|
-
// On logout:
|
|
147
|
-
clearToken()
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
### 4. Use in components
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
```ts
|
|
157
|
-
import { createService } from '@routesync/sdk'
|
|
158
|
-
import { createHooks } from '@routesync/react'
|
|
159
|
-
import { z } from 'zod'
|
|
160
|
-
|
|
161
|
-
const productSchema = z.object({
|
|
162
|
-
id: z.number(),
|
|
163
|
-
product_name: z.string(),
|
|
164
|
-
price: z.number(),
|
|
165
|
-
})
|
|
152
|
+
```tsx
|
|
153
|
+
import { useApiQuery, useApiMutation } from 'routesync/react'
|
|
154
|
+
import { api } from '@/api/api'
|
|
166
155
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
156
|
+
// GET — fetch data
|
|
157
|
+
function ProdukList() {
|
|
158
|
+
const { data, isLoading } = useApiQuery(api.produk.get, {
|
|
159
|
+
query: { page: 1, search: 'kaos' }
|
|
160
|
+
})
|
|
171
161
|
|
|
172
|
-
|
|
173
|
-
|
|
162
|
+
if (isLoading) return <p>Loading...</p>
|
|
163
|
+
return <ul>{data?.map(p => <li key={p.id}>{p.nama}</li>)}</ul>
|
|
164
|
+
}
|
|
174
165
|
|
|
175
|
-
|
|
166
|
+
// GET with path params
|
|
167
|
+
function ProdukDetail({ id }: { id: string }) {
|
|
168
|
+
const { data } = useApiQuery(api.produk.getId, { params: { id } })
|
|
169
|
+
return <div>{data?.nama}</div>
|
|
170
|
+
}
|
|
176
171
|
|
|
177
|
-
|
|
178
|
-
function
|
|
179
|
-
const
|
|
180
|
-
const mutation = useCreate()
|
|
172
|
+
// POST / mutation
|
|
173
|
+
function AddToCart({ produkItemId }: { produkItemId: string }) {
|
|
174
|
+
const mutation = useApiMutation(api.cart.postItems)
|
|
181
175
|
|
|
182
176
|
return (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
Add Product
|
|
187
|
-
</button>
|
|
188
|
-
</>
|
|
177
|
+
<button onClick={() => mutation.mutate({ body: { produk_item_id: produkItemId, qty: 1 } })}>
|
|
178
|
+
Tambah ke Keranjang
|
|
179
|
+
</button>
|
|
189
180
|
)
|
|
190
181
|
}
|
|
191
182
|
```
|
|
192
183
|
|
|
193
|
-
|
|
184
|
+
### 5. Use Server Actions (Next.js)
|
|
194
185
|
|
|
195
|
-
|
|
186
|
+
```ts
|
|
187
|
+
// In a Server Component or form action
|
|
188
|
+
import { produkGetAction, cartPostItemsAction } from '@/api/actions'
|
|
196
189
|
|
|
197
|
-
|
|
190
|
+
// GET — no params needed
|
|
191
|
+
const result = await produkGetAction({ query: { page: 1 } })
|
|
192
|
+
if (result.success) console.log(result.data)
|
|
198
193
|
|
|
199
|
-
|
|
200
|
-
|
|
194
|
+
// POST — with body
|
|
195
|
+
const result = await cartPostItemsAction({ body: { produk_item_id: '5', qty: 1 } })
|
|
201
196
|
|
|
202
|
-
|
|
197
|
+
// GET with path params — params are required
|
|
198
|
+
const result = await produkGetIdAction({ params: { id: '42' } })
|
|
203
199
|
```
|
|
204
200
|
|
|
205
|
-
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Data Transformation
|
|
204
|
+
|
|
205
|
+
RouteSync handles all data mapping automatically:
|
|
206
|
+
|
|
207
|
+
| Direction | What happens | Where |
|
|
208
|
+
|---|---|---|
|
|
209
|
+
| Response (backend → frontend) | `snake_case` → `camelCase` keys | `HttpClient` interceptor |
|
|
210
|
+
| Request (frontend → backend) | `camelCase` → `snake_case` keys | `HttpClient` interceptor |
|
|
211
|
+
| Response unwrap | `{ data: T, message, meta }` → `T` | `HttpClient` `.get()` / `.post()` etc. |
|
|
212
|
+
| Zod validation | Parse + validate response shape | Per-endpoint `responseSchema` |
|
|
213
|
+
|
|
214
|
+
No extra config needed. `product_name` from Laravel becomes `productName` in your component automatically.
|
|
206
215
|
|
|
207
216
|
---
|
|
208
217
|
|
|
209
|
-
|
|
218
|
+
## Manual Route Definitions
|
|
210
219
|
|
|
211
|
-
|
|
220
|
+
If you don't have a Laravel backend, define routes manually:
|
|
212
221
|
|
|
213
222
|
```ts
|
|
214
|
-
import { defineApi, resource } from '
|
|
223
|
+
import { defineApi, endpoint, resource } from 'routesync'
|
|
224
|
+
|
|
225
|
+
createClient({ baseURL: 'https://api.myapp.com/api' })
|
|
215
226
|
|
|
216
|
-
const api = defineApi({
|
|
227
|
+
export const api = defineApi({
|
|
228
|
+
// Basic endpoint
|
|
229
|
+
auth: {
|
|
230
|
+
login: endpoint<{ token: string }>({ method: 'POST', path: '/login' }),
|
|
231
|
+
logout: endpoint({ method: 'POST', path: '/logout', auth: true }),
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
// With path params
|
|
235
|
+
produk: {
|
|
236
|
+
list: endpoint<ProdukItem[]>({ method: 'GET', path: '/produk' }),
|
|
237
|
+
detail: endpoint<ProdukItem, { id: string }>({ method: 'GET', path: '/produk/:id' }),
|
|
238
|
+
create: endpoint<ProdukItem, unknown, CreateProdukBody>({
|
|
239
|
+
method: 'POST', path: '/produk', auth: true
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
// resource() — shared auth/headers for a group
|
|
217
244
|
cart: resource({
|
|
218
|
-
auth: true,
|
|
245
|
+
auth: true,
|
|
219
246
|
endpoints: {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
remove: { method: 'DELETE', path: '/cart/items/:id' },
|
|
225
|
-
checkout: { method: 'POST', path: '/cart/checkout' },
|
|
247
|
+
get: { method: 'GET', path: '/cart' },
|
|
248
|
+
add: { method: 'POST', path: '/cart/items' },
|
|
249
|
+
update: { method: 'PATCH', path: '/cart/items/:id' },
|
|
250
|
+
remove: { method: 'DELETE', path: '/cart/items/:id' },
|
|
226
251
|
}
|
|
227
252
|
})
|
|
228
|
-
}
|
|
253
|
+
})
|
|
229
254
|
```
|
|
230
255
|
|
|
231
|
-
|
|
256
|
+
`endpoint<TResponse, TParams, TBody>` — generic order:
|
|
257
|
+
1. **TResponse** — shape returned by the backend
|
|
258
|
+
2. **TParams** — path params like `{ id: string }`
|
|
259
|
+
3. **TBody** — POST/PUT/PATCH body shape
|
|
232
260
|
|
|
233
|
-
|
|
261
|
+
---
|
|
234
262
|
|
|
235
|
-
|
|
263
|
+
## Authentication
|
|
236
264
|
|
|
237
265
|
```ts
|
|
238
|
-
import {
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}, config)
|
|
266
|
+
import { createClient } from 'routesync'
|
|
267
|
+
|
|
268
|
+
const client = createClient({ baseURL: 'https://api.myapp.com/api' })
|
|
269
|
+
|
|
270
|
+
// After login — set token
|
|
271
|
+
client.setToken(response.token)
|
|
272
|
+
|
|
273
|
+
// On logout — clear token
|
|
274
|
+
client.removeToken()
|
|
250
275
|
```
|
|
251
276
|
|
|
252
|
-
|
|
253
|
-
>
|
|
254
|
-
> **Important:** To ensure your schemas are detected automatically, you **must use Laravel `FormRequest` classes**. Inline `$request->validate([...])` calls inside Controller methods cannot be reliably extracted.
|
|
255
|
-
>
|
|
256
|
-
> ```php
|
|
257
|
-
> // ✅ DO THIS: RouteSync will generate Zod schemas automatically
|
|
258
|
-
> public function store(StoreProductRequest $request)
|
|
259
|
-
>
|
|
260
|
-
> // ❌ AVOID THIS: Validation rules will be ignored
|
|
261
|
-
> public function store(Request $request) {
|
|
262
|
-
> $request->validate([...]);
|
|
263
|
-
> }
|
|
264
|
-
> ```
|
|
277
|
+
Any endpoint with `auth: true` automatically gets `Authorization: Bearer TOKEN` injected. For Next.js Server Actions, the generated `actions.ts` reads the token from cookies automatically via `getAuthHeaders()`.
|
|
265
278
|
|
|
266
279
|
---
|
|
267
280
|
|
|
268
|
-
|
|
281
|
+
## React Query Hooks
|
|
269
282
|
|
|
270
|
-
|
|
283
|
+
### Auto-generated hooks (from CLI)
|
|
271
284
|
|
|
272
285
|
```ts
|
|
273
|
-
import {
|
|
274
|
-
import {
|
|
286
|
+
import { useApiQuery, useApiMutation } from 'routesync/react'
|
|
287
|
+
import { api } from '@/api/api'
|
|
275
288
|
|
|
276
|
-
|
|
277
|
-
const {
|
|
289
|
+
// GET
|
|
290
|
+
const { data, isLoading } = useApiQuery(api.orders.get)
|
|
291
|
+
|
|
292
|
+
// GET with params + query
|
|
293
|
+
const { data } = useApiQuery(api.orders.getId, { params: { id: '1' } })
|
|
294
|
+
|
|
295
|
+
// Mutation
|
|
296
|
+
const mutation = useApiMutation(api.cart.postItems)
|
|
297
|
+
mutation.mutate({ body: { produk_item_id: '5', qty: 1 } })
|
|
278
298
|
```
|
|
279
299
|
|
|
280
|
-
|
|
300
|
+
### Generate hooks for entire api at once
|
|
281
301
|
|
|
282
|
-
|
|
302
|
+
```ts
|
|
303
|
+
import { generateHooks } from 'routesync'
|
|
283
304
|
|
|
284
|
-
|
|
305
|
+
const hooks = generateHooks(api)
|
|
306
|
+
const { useOrdersGet, useCartPostItems } = hooks
|
|
307
|
+
// GET/DELETE → useQuery, everything else → useMutation
|
|
308
|
+
```
|
|
285
309
|
|
|
286
|
-
|
|
310
|
+
### createHooks — per group
|
|
287
311
|
|
|
288
312
|
```ts
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
313
|
+
import { createHooks } from 'routesync/react'
|
|
314
|
+
|
|
315
|
+
const cartHooks = createHooks(api.cart)
|
|
316
|
+
const { usePostItems, usePatchItemsProdukItemId } = cartHooks
|
|
292
317
|
```
|
|
293
318
|
|
|
294
|
-
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Zod Schema Validation
|
|
322
|
+
|
|
323
|
+
When using `--zod` with `routesync generate`, `schemas.ts` is generated from your Laravel `FormRequest` rules.
|
|
324
|
+
|
|
325
|
+
> **Requirement:** You must use Laravel `FormRequest` classes for rules to be detected:
|
|
326
|
+
>
|
|
327
|
+
> ```php
|
|
328
|
+
> // ✅ RouteSync will auto-generate Zod schema
|
|
329
|
+
> public function store(StoreProductRequest $request) { ... }
|
|
330
|
+
>
|
|
331
|
+
> // ❌ Rules will not be detected
|
|
332
|
+
> public function store(Request $request) {
|
|
333
|
+
> $request->validate([...]);
|
|
334
|
+
> }
|
|
335
|
+
> ```
|
|
295
336
|
|
|
296
337
|
---
|
|
297
338
|
|
|
298
|
-
## CLI
|
|
339
|
+
## CLI Reference
|
|
299
340
|
|
|
300
341
|
```bash
|
|
301
|
-
# Scan routes
|
|
342
|
+
# Scan Laravel routes only
|
|
302
343
|
npx routesync scan --input routes/api.php
|
|
303
344
|
|
|
345
|
+
# Scan routes + Eloquent models (recommended)
|
|
346
|
+
npx routesync scan --input routes/api.php --models
|
|
347
|
+
|
|
304
348
|
# Generate SDK from manifest
|
|
305
|
-
npx routesync generate --manifest routesync.json --output src/api
|
|
349
|
+
npx routesync generate --manifest routesync.manifest.json --output src/api
|
|
306
350
|
|
|
307
|
-
#
|
|
308
|
-
npx routesync
|
|
351
|
+
# Generate everything
|
|
352
|
+
npx routesync generate --manifest routesync.manifest.json --output src/api --next-actions --zod
|
|
309
353
|
|
|
310
|
-
# Watch mode — auto-
|
|
354
|
+
# Watch mode — auto re-generates on route file change
|
|
311
355
|
npx routesync watch --input routes/api.php --output src/api
|
|
312
356
|
```
|
|
313
357
|
|
|
314
358
|
---
|
|
315
359
|
|
|
316
|
-
## How
|
|
360
|
+
## How It Works
|
|
317
361
|
|
|
318
362
|
```
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
routesync
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
363
|
+
routes/api.php + app/Models/
|
|
364
|
+
↓ routesync scan --models
|
|
365
|
+
routesync.manifest.json
|
|
366
|
+
↓ routesync generate
|
|
367
|
+
src/api/
|
|
368
|
+
├── api.ts ← defineApi + endpoints
|
|
369
|
+
├── types.ts ← interfaces from DB columns
|
|
370
|
+
├── hooks.ts ← TanStack Query hooks
|
|
371
|
+
├── actions.ts ← Next.js Server Actions
|
|
372
|
+
└── schemas.ts ← Zod schemas
|
|
373
|
+
↓
|
|
374
|
+
React / Vue / Next.js
|
|
328
375
|
```
|
|
329
376
|
|
|
330
|
-
The CLI parses your route file, builds a language-agnostic manifest, then feeds it to
|
|
331
|
-
|
|
332
|
-
---
|
|
333
|
-
|
|
334
|
-
## Roadmap
|
|
335
|
-
|
|
336
|
-
- [ ] OpenAPI export from the manifest
|
|
337
|
-
- [ ] SWR adapter alongside React Query
|
|
338
|
-
- [ ] Solid.js composables
|
|
339
|
-
- [ ] First-class Next.js Server Actions integration
|
|
340
|
-
- [ ] VSCode extension — IntelliSense on `api.` without importing
|
|
377
|
+
The CLI parses your route file via PHP reflection (using Laravel's own bootstrap), builds a language-agnostic manifest, then feeds it to independent generators. Each generator can be used standalone.
|
|
341
378
|
|
|
342
379
|
---
|
|
343
380
|
|
|
344
381
|
## Requirements
|
|
345
382
|
|
|
346
|
-
- Node.js >=
|
|
383
|
+
- Node.js >= 20
|
|
384
|
+
- PHP available in PATH (for `scan --models`)
|
|
385
|
+
- Laravel project with database accessible (for `scan --models`)
|
|
347
386
|
|
|
348
387
|
## License
|
|
349
388
|
|
package/dist/cli.js
CHANGED
|
@@ -8773,9 +8773,9 @@ var SDKGenerator = class {
|
|
|
8773
8773
|
const responseType = route.response ? `Types.${route.response.type}${route.response.collection ? "[]" : ""}` : `unknown`;
|
|
8774
8774
|
lines.push(`export type ${ContractName} = {`);
|
|
8775
8775
|
lines.push(` request: {`);
|
|
8776
|
-
lines.push(` params: ${paramsType}`);
|
|
8776
|
+
lines.push(` params${paramsType === "unknown" ? "?" : ""}: ${paramsType}`);
|
|
8777
8777
|
lines.push(` query?: Record<string, unknown>`);
|
|
8778
|
-
lines.push(` body: ${bodyType}`);
|
|
8778
|
+
lines.push(` body${bodyType === "unknown" ? "?" : ""}: ${bodyType}`);
|
|
8779
8779
|
lines.push(` }`);
|
|
8780
8780
|
lines.push(` response: ${responseType}`);
|
|
8781
8781
|
lines.push(`}`);
|
|
@@ -8887,9 +8887,9 @@ var TypeGenerator = class {
|
|
|
8887
8887
|
for (const col of model.columns) {
|
|
8888
8888
|
if (model.hidden && model.hidden.includes(col.name)) continue;
|
|
8889
8889
|
const tsType = this.mapSqlTypeToTs(col.type);
|
|
8890
|
-
const
|
|
8890
|
+
const finalTsType = col.nullable ? `${tsType} | null` : tsType;
|
|
8891
8891
|
const safeName = camelCase(col.name).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/) ? camelCase(col.name) : `"${camelCase(col.name)}"`;
|
|
8892
|
-
lines.push(` ${safeName}
|
|
8892
|
+
lines.push(` ${safeName}: ${finalTsType}`);
|
|
8893
8893
|
}
|
|
8894
8894
|
if (model.appends && model.appends.length > 0) {
|
|
8895
8895
|
for (const append of model.appends) {
|
|
@@ -9007,7 +9007,7 @@ var SchemaGenerator = class {
|
|
|
9007
9007
|
return "z.boolean()";
|
|
9008
9008
|
}
|
|
9009
9009
|
if (type.includes("json")) {
|
|
9010
|
-
return "z.record(z.unknown())";
|
|
9010
|
+
return "z.record(z.string(), z.unknown())";
|
|
9011
9011
|
}
|
|
9012
9012
|
const enumMatch = type.match(/^enum\((.*)\)$/);
|
|
9013
9013
|
if (enumMatch && enumMatch[1]) {
|
|
@@ -9025,7 +9025,7 @@ var SchemaGenerator = class {
|
|
|
9025
9025
|
return "z.boolean()";
|
|
9026
9026
|
}
|
|
9027
9027
|
if (type.includes("array") || type.includes("json") || type.includes("collection") || type.includes("object")) {
|
|
9028
|
-
return "z.record(z.unknown())";
|
|
9028
|
+
return "z.record(z.string(), z.unknown())";
|
|
9029
9029
|
}
|
|
9030
9030
|
if (type.includes("date") || type.includes("datetime") || type.includes("string")) {
|
|
9031
9031
|
return "z.string()";
|
|
@@ -9052,13 +9052,18 @@ var HookGenerator = class _HookGenerator {
|
|
|
9052
9052
|
const method = route.method.toUpperCase();
|
|
9053
9053
|
const hookName = _HookGenerator.toHookName(group, route.actionName);
|
|
9054
9054
|
const queryKey = `['${group}', '${route.actionName}']`;
|
|
9055
|
+
const contractName = `${toTypeName(group)}${toTypeName(route.actionName)}Contract`;
|
|
9056
|
+
const hasParams = route.path.includes(":") || route.path.includes("{");
|
|
9057
|
+
const hasQuery = route.schema?.query || route.method.toUpperCase() === "GET";
|
|
9058
|
+
const hasBody = route.schema?.body;
|
|
9059
|
+
const requiresOptions = hasParams || hasBody || hasQuery && route.method.toUpperCase() !== "GET";
|
|
9055
9060
|
if (method === "GET") {
|
|
9056
9061
|
lines.push(`/**`);
|
|
9057
9062
|
lines.push(` * @deprecated Generated hooks will become optional in v2.`);
|
|
9058
9063
|
lines.push(` * Prefer \`useApiQuery(api.${group}.${route.actionName}, ...args)\` for future compatibility.`);
|
|
9059
9064
|
lines.push(` */`);
|
|
9060
|
-
lines.push(`export function ${hookName}(...args:
|
|
9061
|
-
lines.push(` return useApiQuery(api.${group}.${route.actionName}, ...args
|
|
9065
|
+
lines.push(`export function ${hookName}(...args: [options${requiresOptions ? "" : "?"}: import('./api').${contractName}['request'], queryOptions?: import('routesync/react').ApiQueryOptions<import('./api').${contractName}['response'], import('./types').ApiError>]) {`);
|
|
9066
|
+
lines.push(` return useApiQuery<import('./api').${contractName}['response'], import('./api').${contractName}['request']['params'], import('./api').${contractName}['request']['body'], import('./types').ApiError>(api.${group}.${route.actionName}, ...args)`);
|
|
9062
9067
|
lines.push(`}`);
|
|
9063
9068
|
lines.push(``);
|
|
9064
9069
|
} else {
|
|
@@ -9089,7 +9094,7 @@ var NextActionGenerator = class {
|
|
|
9089
9094
|
lines.push(`// Auto-generated Next.js Server Actions. Do not edit manually.`);
|
|
9090
9095
|
lines.push(`"use server";`);
|
|
9091
9096
|
lines.push(``);
|
|
9092
|
-
lines.push(`
|
|
9097
|
+
lines.push(`IMPORT_PLACEHOLDER`);
|
|
9093
9098
|
lines.push(`import { cookies } from 'next/headers'`);
|
|
9094
9099
|
lines.push(``);
|
|
9095
9100
|
lines.push(`// Helper to auto-inject token from cookies if available`);
|
|
@@ -9100,58 +9105,33 @@ var NextActionGenerator = class {
|
|
|
9100
9105
|
lines.push(`}`);
|
|
9101
9106
|
lines.push(``);
|
|
9102
9107
|
const grouped = buildGeneratedRoutes(manifest.routes);
|
|
9108
|
+
const usedContracts = /* @__PURE__ */ new Set();
|
|
9103
9109
|
for (const [groupName, routes] of Object.entries(grouped)) {
|
|
9104
9110
|
for (const route of routes) {
|
|
9105
|
-
const actionName = `${groupName}${toTypeName(route.actionName)}`;
|
|
9106
9111
|
const TitleCaseGroup = groupName.charAt(0).toUpperCase() + groupName.slice(1);
|
|
9107
9112
|
const TitleCaseAction = route.actionName.charAt(0).toUpperCase() + route.actionName.slice(1);
|
|
9108
9113
|
const ContractName = `${TitleCaseGroup}${TitleCaseAction}Contract`;
|
|
9109
|
-
const
|
|
9110
|
-
const
|
|
9111
|
-
const hasParams =
|
|
9112
|
-
const
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
|
|
9127
|
-
args.push(`body: payload.body`);
|
|
9128
|
-
}
|
|
9129
|
-
payloadParam = `payload: { ${props.join(", ")} }`;
|
|
9130
|
-
} else if (count === 1) {
|
|
9131
|
-
if (hasParams) {
|
|
9132
|
-
payloadParam = `payload: ${ContractName}['request']['params']`;
|
|
9133
|
-
args.push(`params: payload`);
|
|
9134
|
-
}
|
|
9135
|
-
if (hasQuery) {
|
|
9136
|
-
payloadParam = `payload?: ${ContractName}['request']['query']`;
|
|
9137
|
-
args.push(`query: payload`);
|
|
9138
|
-
}
|
|
9139
|
-
if (hasBody) {
|
|
9140
|
-
payloadParam = `payload: ${ContractName}['request']['body']`;
|
|
9141
|
-
args.push(`body: payload`);
|
|
9142
|
-
}
|
|
9143
|
-
} else {
|
|
9144
|
-
payloadParam = `payload?: unknown`;
|
|
9145
|
-
}
|
|
9146
|
-
lines.push(`export async function ${actionName}Action(${payloadParam}) {`);
|
|
9147
|
-
if (route.auth) {
|
|
9148
|
-
args.push(`headers: await getAuthHeaders()`);
|
|
9149
|
-
}
|
|
9150
|
-
const argsString = args.length > 0 ? `{ ${args.join(", ")} }` : "";
|
|
9151
|
-
const apiCall = `await api.${groupName}.${route.actionName}(${argsString})`;
|
|
9114
|
+
const actionFnName = `${groupName}${TitleCaseAction}Action`;
|
|
9115
|
+
const pathParams = Array.from(route.runtimePath.matchAll(/:([a-zA-Z0-9_]+)/g)).map((m) => m[1]);
|
|
9116
|
+
const hasParams = pathParams.length > 0;
|
|
9117
|
+
const hasBody = ["POST", "PUT", "PATCH", "DELETE"].includes(route.method) && route.schema?.rules && Object.keys(route.schema.rules).length > 0;
|
|
9118
|
+
const hasQuery = route.method === "GET" || route.method === "DELETE";
|
|
9119
|
+
usedContracts.add(ContractName);
|
|
9120
|
+
const sigParts = [];
|
|
9121
|
+
if (hasParams) sigParts.push(`params: ${ContractName}['request']['params']`);
|
|
9122
|
+
if (hasQuery) sigParts.push(`query?: ${ContractName}['request']['query']`);
|
|
9123
|
+
if (hasBody) sigParts.push(`body?: ${ContractName}['request']['body']`);
|
|
9124
|
+
const fnParam = sigParts.length > 0 ? `payload${hasParams ? "" : "?"}: { ${sigParts.join(", ")} }` : "";
|
|
9125
|
+
const callArgs = [];
|
|
9126
|
+
if (hasParams) callArgs.push(`params: payload.params`);
|
|
9127
|
+
if (hasQuery) callArgs.push(`query: payload?.query`);
|
|
9128
|
+
if (hasBody) callArgs.push(`body: payload?.body`);
|
|
9129
|
+
if (route.auth) callArgs.push(`headers: await getAuthHeaders()`);
|
|
9130
|
+
const argsStr = callArgs.length > 0 ? `{ ${callArgs.join(", ")} }` : "";
|
|
9131
|
+
lines.push(`export async function ${actionFnName}(${fnParam}) {`);
|
|
9152
9132
|
lines.push(` try {`);
|
|
9153
|
-
lines.push(` const
|
|
9154
|
-
lines.push(` return { success: true, data
|
|
9133
|
+
lines.push(` const data = await api.${groupName}.${route.actionName}(${argsStr})`);
|
|
9134
|
+
lines.push(` return { success: true, data }`);
|
|
9155
9135
|
lines.push(` } catch (error: unknown) {`);
|
|
9156
9136
|
lines.push(` return { success: false, error: error instanceof Error ? error.message : String(error) }`);
|
|
9157
9137
|
lines.push(` }`);
|
|
@@ -9159,11 +9139,10 @@ var NextActionGenerator = class {
|
|
|
9159
9139
|
lines.push(``);
|
|
9160
9140
|
}
|
|
9161
9141
|
}
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
await import_fs_extra7.default.writeFile(import_path6.default.join(outputDir, "actions.ts"), lines.join("\n"));
|
|
9142
|
+
const contractsToImport = Array.from(usedContracts);
|
|
9143
|
+
const importStr = contractsToImport.length > 0 ? `import { api, type ${contractsToImport.join(", type ")} } from './api'` : `import { api } from './api'`;
|
|
9144
|
+
const output = lines.join("\n").replace("IMPORT_PLACEHOLDER", importStr);
|
|
9145
|
+
await import_fs_extra7.default.writeFile(import_path6.default.join(outputDir, "actions.ts"), output);
|
|
9167
9146
|
}
|
|
9168
9147
|
};
|
|
9169
9148
|
|
package/dist/react.d.mts
CHANGED
|
@@ -47,7 +47,9 @@ interface RouteDefinition<TResponse = unknown, TParams = unknown, TBody = unknow
|
|
|
47
47
|
|
|
48
48
|
type EndpointCallableOptions<TParams, TBody> = (unknown extends TParams ? {} : {
|
|
49
49
|
params: TParams;
|
|
50
|
-
}) & (unknown extends TBody ? {
|
|
50
|
+
}) & (unknown extends TBody ? {
|
|
51
|
+
body?: unknown;
|
|
52
|
+
} : {
|
|
51
53
|
body: TBody;
|
|
52
54
|
}) & {
|
|
53
55
|
query?: Record<string, any>;
|
|
@@ -76,6 +78,7 @@ interface EndpointCallable<TResponse = unknown, TParams = unknown, TBody = unkno
|
|
|
76
78
|
* const { data, isLoading } = useApiQuery(api.produk.list)
|
|
77
79
|
* const { data } = useApiQuery(api.produk.detail, { params: { id: 10 } })
|
|
78
80
|
*/
|
|
81
|
+
type ApiQueryOptions<TResponse, TError = ApiError, TData = TResponse> = Omit<UseQueryOptions<TResponse, TError, TData>, 'queryKey' | 'queryFn'>;
|
|
79
82
|
declare function useApiQuery<TResponse = unknown, TParams = unknown, TBody = unknown, TError = ApiError, TData = TResponse>(endpoint: EndpointCallable<TResponse, TParams, TBody>, ...args: [
|
|
80
83
|
...OptionalIfEmpty<EndpointCallableOptions<TParams, TBody>>,
|
|
81
84
|
queryOptions?: Omit<UseQueryOptions<TResponse, TError, TData>, 'queryKey' | 'queryFn'>
|
|
@@ -171,4 +174,4 @@ interface UseFormSetError<TFieldValues extends Record<string, any>> {
|
|
|
171
174
|
*/
|
|
172
175
|
declare function setFormErrors<TFieldValues extends Record<string, any>>(error: unknown, setError: UseFormSetError<TFieldValues>): void;
|
|
173
176
|
|
|
174
|
-
export { type ApiMutationOptions, createHooks, setFormErrors, useApiInfiniteQuery, useApiMutation, useApiQuery, useApiQueryClient, useApiSuspenseQuery };
|
|
177
|
+
export { type ApiMutationOptions, type ApiQueryOptions, createHooks, setFormErrors, useApiInfiniteQuery, useApiMutation, useApiQuery, useApiQueryClient, useApiSuspenseQuery };
|
package/dist/react.d.ts
CHANGED
|
@@ -47,7 +47,9 @@ interface RouteDefinition<TResponse = unknown, TParams = unknown, TBody = unknow
|
|
|
47
47
|
|
|
48
48
|
type EndpointCallableOptions<TParams, TBody> = (unknown extends TParams ? {} : {
|
|
49
49
|
params: TParams;
|
|
50
|
-
}) & (unknown extends TBody ? {
|
|
50
|
+
}) & (unknown extends TBody ? {
|
|
51
|
+
body?: unknown;
|
|
52
|
+
} : {
|
|
51
53
|
body: TBody;
|
|
52
54
|
}) & {
|
|
53
55
|
query?: Record<string, any>;
|
|
@@ -76,6 +78,7 @@ interface EndpointCallable<TResponse = unknown, TParams = unknown, TBody = unkno
|
|
|
76
78
|
* const { data, isLoading } = useApiQuery(api.produk.list)
|
|
77
79
|
* const { data } = useApiQuery(api.produk.detail, { params: { id: 10 } })
|
|
78
80
|
*/
|
|
81
|
+
type ApiQueryOptions<TResponse, TError = ApiError, TData = TResponse> = Omit<UseQueryOptions<TResponse, TError, TData>, 'queryKey' | 'queryFn'>;
|
|
79
82
|
declare function useApiQuery<TResponse = unknown, TParams = unknown, TBody = unknown, TError = ApiError, TData = TResponse>(endpoint: EndpointCallable<TResponse, TParams, TBody>, ...args: [
|
|
80
83
|
...OptionalIfEmpty<EndpointCallableOptions<TParams, TBody>>,
|
|
81
84
|
queryOptions?: Omit<UseQueryOptions<TResponse, TError, TData>, 'queryKey' | 'queryFn'>
|
|
@@ -171,4 +174,4 @@ interface UseFormSetError<TFieldValues extends Record<string, any>> {
|
|
|
171
174
|
*/
|
|
172
175
|
declare function setFormErrors<TFieldValues extends Record<string, any>>(error: unknown, setError: UseFormSetError<TFieldValues>): void;
|
|
173
176
|
|
|
174
|
-
export { type ApiMutationOptions, createHooks, setFormErrors, useApiInfiniteQuery, useApiMutation, useApiQuery, useApiQueryClient, useApiSuspenseQuery };
|
|
177
|
+
export { type ApiMutationOptions, type ApiQueryOptions, createHooks, setFormErrors, useApiInfiniteQuery, useApiMutation, useApiQuery, useApiQueryClient, useApiSuspenseQuery };
|
package/dist/sdk.d.mts
CHANGED
|
@@ -123,7 +123,9 @@ type CallOptions<TParams = unknown, TBody = unknown> = {
|
|
|
123
123
|
};
|
|
124
124
|
type EndpointCallableOptions<TParams, TBody> = (unknown extends TParams ? {} : {
|
|
125
125
|
params: TParams;
|
|
126
|
-
}) & (unknown extends TBody ? {
|
|
126
|
+
}) & (unknown extends TBody ? {
|
|
127
|
+
body?: unknown;
|
|
128
|
+
} : {
|
|
127
129
|
body: TBody;
|
|
128
130
|
}) & {
|
|
129
131
|
query?: Record<string, any>;
|
package/dist/sdk.d.ts
CHANGED
|
@@ -123,7 +123,9 @@ type CallOptions<TParams = unknown, TBody = unknown> = {
|
|
|
123
123
|
};
|
|
124
124
|
type EndpointCallableOptions<TParams, TBody> = (unknown extends TParams ? {} : {
|
|
125
125
|
params: TParams;
|
|
126
|
-
}) & (unknown extends TBody ? {
|
|
126
|
+
}) & (unknown extends TBody ? {
|
|
127
|
+
body?: unknown;
|
|
128
|
+
} : {
|
|
127
129
|
body: TBody;
|
|
128
130
|
}) & {
|
|
129
131
|
query?: Record<string, any>;
|