routesync 1.0.35 → 1.0.36
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 +131 -0
- package/dist/cli.js +251 -175
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -200,6 +200,137 @@ const result = await produkGetIdAction({ params: { id: '42' } })
|
|
|
200
200
|
|
|
201
201
|
---
|
|
202
202
|
|
|
203
|
+
## Response Type Inference
|
|
204
|
+
|
|
205
|
+
RouteSync automatically infers the TypeScript response type for each endpoint. The scanner works through **7 stages in order**, stopping at the first successful match.
|
|
206
|
+
|
|
207
|
+
### How inference works
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
Controller method
|
|
211
|
+
│
|
|
212
|
+
▼
|
|
213
|
+
Stage 1: PHP 8 #[RouteSyncResponse] attribute on method ← most explicit
|
|
214
|
+
│
|
|
215
|
+
▼
|
|
216
|
+
Stage 2: return new UserResource($user) in method body
|
|
217
|
+
│ ├─ Stage 2a: #[RouteSyncResponse] on Resource class
|
|
218
|
+
│ ├─ Stage 2b: @mixin \App\Models\User docblock
|
|
219
|
+
│ ├─ Stage 2c: __construct(User $user) type hint ← NEW
|
|
220
|
+
│ ├─ Stage 2d: @var User $resource docblock ← NEW
|
|
221
|
+
│ ├─ Stage 2e: Strip "Resource" suffix → App\Models\* ← NEW
|
|
222
|
+
│ └─ Stage 2f: toArray() keys vs DB column matching ← NEW
|
|
223
|
+
│
|
|
224
|
+
▼
|
|
225
|
+
Stage 3: response()->json([...]) inline array
|
|
226
|
+
keys matched against DB columns (min score 2) ← NEW
|
|
227
|
+
│
|
|
228
|
+
▼
|
|
229
|
+
response: unknown ← annotate manually if all stages fail
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Zero-config inference (no annotation needed)
|
|
233
|
+
|
|
234
|
+
For the common convention `UserResource` → `User`, RouteSync infers automatically:
|
|
235
|
+
|
|
236
|
+
```php
|
|
237
|
+
// ✅ Auto-detected — no annotation needed
|
|
238
|
+
public function show(User $user): JsonResponse
|
|
239
|
+
{
|
|
240
|
+
return new UserResource($user);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ✅ Auto-detected — UserResource::collection → User[]
|
|
244
|
+
public function index(): JsonResponse
|
|
245
|
+
{
|
|
246
|
+
return UserResource::collection(User::all());
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Explicit annotation with PHP 8 Attribute
|
|
251
|
+
|
|
252
|
+
Use `#[RouteSyncResponse]` when auto-inference fails — for example, when the Resource name doesn't match the model, or the response is a DTO/custom shape.
|
|
253
|
+
|
|
254
|
+
**Step 1 — Create the attribute class** (`app/Attributes/RouteSyncResponse.php`):
|
|
255
|
+
|
|
256
|
+
```php
|
|
257
|
+
<?php
|
|
258
|
+
|
|
259
|
+
namespace App\Attributes;
|
|
260
|
+
|
|
261
|
+
use Attribute;
|
|
262
|
+
|
|
263
|
+
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
|
|
264
|
+
class RouteSyncResponse
|
|
265
|
+
{
|
|
266
|
+
public function __construct(
|
|
267
|
+
public readonly string $model,
|
|
268
|
+
public readonly bool $collection = false,
|
|
269
|
+
) {}
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Step 2 — Annotate your controller method:**
|
|
274
|
+
|
|
275
|
+
```php
|
|
276
|
+
use App\Attributes\RouteSyncResponse;
|
|
277
|
+
use App\Models\User;
|
|
278
|
+
|
|
279
|
+
class AuthController extends Controller
|
|
280
|
+
{
|
|
281
|
+
// Single object response
|
|
282
|
+
#[RouteSyncResponse(model: User::class)]
|
|
283
|
+
public function register(RegisterRequest $request): JsonResponse
|
|
284
|
+
{
|
|
285
|
+
$user = User::create($request->validated());
|
|
286
|
+
$token = $user->createToken('auth')->plainTextToken;
|
|
287
|
+
|
|
288
|
+
return response()->json(['token' => $token, 'user' => new UserResource($user)]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Collection response
|
|
292
|
+
#[RouteSyncResponse(model: User::class, collection: true)]
|
|
293
|
+
public function index(): JsonResponse
|
|
294
|
+
{
|
|
295
|
+
return response()->json(User::all());
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Or annotate the Resource class directly** (applies to all endpoints that return this Resource):
|
|
301
|
+
|
|
302
|
+
```php
|
|
303
|
+
use App\Attributes\RouteSyncResponse;
|
|
304
|
+
use App\Models\User;
|
|
305
|
+
|
|
306
|
+
#[RouteSyncResponse(model: User::class)]
|
|
307
|
+
class UserResource extends JsonResource
|
|
308
|
+
{
|
|
309
|
+
public function toArray($request): array
|
|
310
|
+
{
|
|
311
|
+
return [
|
|
312
|
+
'id' => $this->id,
|
|
313
|
+
'name' => $this->name,
|
|
314
|
+
'email' => $this->email,
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
> **Priority:** annotation on controller method > annotation on Resource class > auto-inference.
|
|
321
|
+
|
|
322
|
+
### When to annotate manually
|
|
323
|
+
|
|
324
|
+
| Situation | Solution |
|
|
325
|
+
|---|---|
|
|
326
|
+
| Resource name doesn't match model (`PublicProfileResource` → `User`) | `#[RouteSyncResponse(model: User::class)]` |
|
|
327
|
+
| Response is a DTO, not an Eloquent model | Add model manually after generate |
|
|
328
|
+
| Controller returns `response()->json([...])` without a Resource | `#[RouteSyncResponse(model: User::class)]` |
|
|
329
|
+
| Multiple models in one response | Add model manually after generate |
|
|
330
|
+
| Response is still `unknown` after scan | Add `#[RouteSyncResponse]` attribute |
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
203
334
|
## Data Transformation
|
|
204
335
|
|
|
205
336
|
RouteSync handles all data mapping automatically:
|
package/dist/cli.js
CHANGED
|
@@ -8361,25 +8361,189 @@ var import_path = __toESM(require("path"));
|
|
|
8361
8361
|
var LaravelRouteParser = class {
|
|
8362
8362
|
async parse(filePath, options = {}) {
|
|
8363
8363
|
const projectRoot = import_path.default.resolve(import_path.default.dirname(filePath), "..");
|
|
8364
|
+
const extractModels = options.extractModels ? "true" : "false";
|
|
8364
8365
|
const phpScript = `<?php
|
|
8365
8366
|
require __DIR__.'/vendor/autoload.php';
|
|
8366
8367
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
|
8367
|
-
$kernel = $app->make(
|
|
8368
|
+
$kernel = $app->make(IlluminateContractsConsoleKernel::class);
|
|
8368
8369
|
$kernel->bootstrap();
|
|
8369
8370
|
|
|
8370
|
-
$result = [
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8371
|
+
$result = ['routes' => [], 'models' => []];
|
|
8372
|
+
|
|
8373
|
+
// \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
8374
|
+
|
|
8375
|
+
function rsync_get_source(ReflectionFunctionAbstract $ref): ?string {
|
|
8376
|
+
$file = $ref->getFileName();
|
|
8377
|
+
$start = $ref->getStartLine();
|
|
8378
|
+
$end = $ref->getEndLine();
|
|
8379
|
+
if (!$file || $start === false || $end === false) return null;
|
|
8380
|
+
return implode('', array_slice(file($file), $start - 1, $end - $start + 1));
|
|
8381
|
+
}
|
|
8382
|
+
|
|
8383
|
+
/**
|
|
8384
|
+
* Try to resolve responseMetadata from a Resource class.
|
|
8385
|
+
* Checks (in order):
|
|
8386
|
+
* 1. PHP 8 #[RouteSyncResponse] attribute on class
|
|
8387
|
+
* 2. @mixin docblock
|
|
8388
|
+
* 3. Constructor parameter type hint \u2190 NEW
|
|
8389
|
+
* 4. $this->resource @var docblock \u2190 NEW
|
|
8390
|
+
* 5. Strip Resource suffix \u2192 match AppModels*
|
|
8391
|
+
* 6. toArray() field vs DB column intersection
|
|
8392
|
+
*/
|
|
8393
|
+
function rsync_infer_from_resource(string $resourceClass, bool $collection): ?array {
|
|
8394
|
+
if (!class_exists($resourceClass)) return null;
|
|
8395
|
+
$resRef = new ReflectionClass($resourceClass);
|
|
8396
|
+
|
|
8397
|
+
// 1. PHP 8 attribute
|
|
8398
|
+
foreach ($resRef->getAttributes() as $attr) {
|
|
8399
|
+
$short = class_basename($attr->getName());
|
|
8400
|
+
if (in_array($short, ['Response', 'RouteSyncResponse'])) {
|
|
8401
|
+
$args = $attr->getArguments();
|
|
8402
|
+
$type = $args[0] ?? $args['type'] ?? $args['model'] ?? $args['response'] ?? null;
|
|
8403
|
+
if ($type) return ['type' => class_basename($type), 'collection' => $collection];
|
|
8404
|
+
}
|
|
8405
|
+
}
|
|
8406
|
+
|
|
8407
|
+
// 2. @mixin docblock
|
|
8408
|
+
$doc = $resRef->getDocComment();
|
|
8409
|
+
if ($doc && preg_match('/@mixins+([\\\\w]+)/', $doc, $m)) {
|
|
8410
|
+
return ['type' => class_basename($m[1]), 'collection' => $collection];
|
|
8411
|
+
}
|
|
8412
|
+
|
|
8413
|
+
// 3. Constructor parameter type hint: __construct(User $user)
|
|
8414
|
+
if ($resRef->hasMethod('__construct')) {
|
|
8415
|
+
$ctor = $resRef->getMethod('__construct');
|
|
8416
|
+
foreach ($ctor->getParameters() as $param) {
|
|
8417
|
+
$ptype = $param->getType();
|
|
8418
|
+
if ($ptype && !$ptype->isBuiltin()) {
|
|
8419
|
+
$cn = $ptype->getName();
|
|
8420
|
+
if (is_subclass_of($cn, 'IlluminateDatabaseEloquentModel')) {
|
|
8421
|
+
return ['type' => class_basename($cn), 'collection' => $collection];
|
|
8422
|
+
}
|
|
8423
|
+
}
|
|
8424
|
+
}
|
|
8425
|
+
}
|
|
8426
|
+
|
|
8427
|
+
// 4. $this->resource @var docblock in class body or toArray()
|
|
8428
|
+
$classDoc = $resRef->getDocComment() ?: '';
|
|
8429
|
+
foreach (['toArray', 'toResponse'] as $mname) {
|
|
8430
|
+
if ($resRef->hasMethod($mname)) {
|
|
8431
|
+
$src = rsync_get_source($resRef->getMethod($mname)) ?? '';
|
|
8432
|
+
$classDoc .= $src;
|
|
8433
|
+
}
|
|
8434
|
+
}
|
|
8435
|
+
if (preg_match('/@vars+([\\\\w]+)s+$(?:resource|model)/', $classDoc, $m)) {
|
|
8436
|
+
$cn = ltrim($m[1], '\\');
|
|
8437
|
+
$fqcn = str_contains($cn, '\\') ? $cn : 'App\\Models\\' . $cn;
|
|
8438
|
+
if (class_exists($fqcn)) {
|
|
8439
|
+
return ['type' => class_basename($fqcn), 'collection' => $collection];
|
|
8440
|
+
}
|
|
8441
|
+
}
|
|
8442
|
+
|
|
8443
|
+
// 5. Strip Resource suffix \u2192 AppModels<Name>
|
|
8444
|
+
$inferredName = preg_replace('/Resource$/', '', class_basename($resourceClass));
|
|
8445
|
+
if ($inferredName) {
|
|
8446
|
+
$mc = 'App\\Models\\' . $inferredName;
|
|
8447
|
+
if (class_exists($mc)) {
|
|
8448
|
+
return ['type' => $inferredName, 'collection' => $collection];
|
|
8449
|
+
}
|
|
8450
|
+
}
|
|
8451
|
+
|
|
8452
|
+
// 6. toArray() field vs DB column intersection
|
|
8453
|
+
if ($resRef->hasMethod('toArray')) {
|
|
8454
|
+
$src = rsync_get_source($resRef->getMethod('toArray')) ?? '';
|
|
8455
|
+
preg_match_all('/['"]([a-zA-Z0-9_]+)['"]s*=>/', $src, $km);
|
|
8456
|
+
$resFields = array_unique($km[1] ?? []);
|
|
8457
|
+
if (!empty($resFields)) {
|
|
8458
|
+
$bestModel = null; $bestScore = 0;
|
|
8459
|
+
$modelsPath = app_path('Models');
|
|
8460
|
+
if (is_dir($modelsPath)) {
|
|
8461
|
+
foreach (IlluminateSupportFacadesFile::allFiles($modelsPath) as $mf) {
|
|
8462
|
+
$mn = preg_replace('/.php$/', '', $mf->getFilename());
|
|
8463
|
+
$mc = 'App\\Models\\' . $mn;
|
|
8464
|
+
if (!class_exists($mc)) continue;
|
|
8465
|
+
try {
|
|
8466
|
+
$mi = new $mc();
|
|
8467
|
+
$cols = array_column(IlluminateSupportFacadesSchema::getColumns($mi->getTable()), 'name');
|
|
8468
|
+
$score = count(array_intersect($resFields, $cols));
|
|
8469
|
+
if ($score > $bestScore) { $bestScore = $score; $bestModel = $mn; }
|
|
8470
|
+
} catch (Exception $e) {}
|
|
8471
|
+
}
|
|
8472
|
+
}
|
|
8473
|
+
if ($bestModel && $bestScore > 0) {
|
|
8474
|
+
return ['type' => $bestModel, 'collection' => $collection];
|
|
8475
|
+
}
|
|
8476
|
+
}
|
|
8477
|
+
}
|
|
8478
|
+
|
|
8479
|
+
return null;
|
|
8480
|
+
}
|
|
8481
|
+
|
|
8482
|
+
/**
|
|
8483
|
+
* Try to resolve responseMetadata directly from controller method source.
|
|
8484
|
+
* Handles cases where no Resource class is used at all.
|
|
8485
|
+
*/
|
|
8486
|
+
function rsync_infer_from_source(?string $source): ?array {
|
|
8487
|
+
if (!$source) return null;
|
|
8488
|
+
|
|
8489
|
+
// return new SomeResource($x)
|
|
8490
|
+
if (preg_match('/returns+news+([a-zA-Z0-9_]+Resource)s*(/', $source, $m)) {
|
|
8491
|
+
$rc = 'App\\Http\\Resources\\' . $m[1];
|
|
8492
|
+
$result = rsync_infer_from_resource($rc, false);
|
|
8493
|
+
if ($result) return $result;
|
|
8494
|
+
}
|
|
8495
|
+
|
|
8496
|
+
// SomeResource::collection(...)
|
|
8497
|
+
if (preg_match('/([a-zA-Z0-9_]+Resource)::collections*(/', $source, $m)) {
|
|
8498
|
+
$rc = 'App\\Http\\Resources\\' . $m[1];
|
|
8499
|
+
$result = rsync_infer_from_resource($rc, true);
|
|
8500
|
+
if ($result) return $result;
|
|
8501
|
+
}
|
|
8502
|
+
|
|
8503
|
+
// ->paginate() or ->simplePaginate() with Resource
|
|
8504
|
+
if (preg_match('/([a-zA-Z0-9_]+Resource)::collection.*paginate/s', $source, $m)) {
|
|
8505
|
+
$rc = 'App\\Http\\Resources\\' . $m[1];
|
|
8506
|
+
$result = rsync_infer_from_resource($rc, true);
|
|
8507
|
+
if ($result) { $result['paginated'] = true; return $result; }
|
|
8508
|
+
}
|
|
8509
|
+
|
|
8510
|
+
// response()->json(['token' => ..., 'user' => ...]) \u2014 inline array
|
|
8511
|
+
// Extract top-level keys and try to match a model
|
|
8512
|
+
if (preg_match('/response()s*->s*jsons*(s*[([^]]{0,800})]/', $source, $jsonMatch)) {
|
|
8513
|
+
preg_match_all('/['"]([a-zA-Z0-9_]+)['"]s*=>/', $jsonMatch[1], $km);
|
|
8514
|
+
$keys = array_unique($km[1] ?? []);
|
|
8515
|
+
if (!empty($keys)) {
|
|
8516
|
+
$bestModel = null; $bestScore = 0;
|
|
8517
|
+
foreach (IlluminateSupportFacadesFile::allFiles(app_path('Models')) as $mf) {
|
|
8518
|
+
$mn = preg_replace('/.php$/', '', $mf->getFilename());
|
|
8519
|
+
$mc = 'App\\Models\\' . $mn;
|
|
8520
|
+
if (!class_exists($mc)) continue;
|
|
8521
|
+
try {
|
|
8522
|
+
$mi = new $mc();
|
|
8523
|
+
$cols = array_column(IlluminateSupportFacadesSchema::getColumns($mi->getTable()), 'name');
|
|
8524
|
+
$camelCols = array_map(fn($c) => lcfirst(str_replace('_', '', ucwords($c, '_'))), $cols);
|
|
8525
|
+
$score = count(array_intersect($keys, array_merge($cols, $camelCols)));
|
|
8526
|
+
if ($score > $bestScore) { $bestScore = $score; $bestModel = $mn; }
|
|
8527
|
+
} catch (Exception $e) {}
|
|
8528
|
+
}
|
|
8529
|
+
if ($bestModel && $bestScore >= 2) {
|
|
8530
|
+
return ['type' => $bestModel, 'collection' => false];
|
|
8531
|
+
}
|
|
8532
|
+
}
|
|
8533
|
+
}
|
|
8534
|
+
|
|
8535
|
+
return null;
|
|
8536
|
+
}
|
|
8537
|
+
|
|
8538
|
+
// \u2500\u2500\u2500 Main Route Loop \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
8374
8539
|
|
|
8375
|
-
// Extract Routes
|
|
8376
8540
|
$routes = app('router')->getRoutes();
|
|
8377
8541
|
foreach ($routes as $route) {
|
|
8378
8542
|
if (!str_starts_with($route->uri(), 'api/')) continue;
|
|
8379
|
-
|
|
8543
|
+
|
|
8380
8544
|
$methods = array_diff($route->methods(), ['HEAD']);
|
|
8381
8545
|
$middlewares = $route->gatherMiddleware();
|
|
8382
|
-
|
|
8546
|
+
|
|
8383
8547
|
$auth = false;
|
|
8384
8548
|
foreach ($middlewares as $mw) {
|
|
8385
8549
|
if (is_string($mw) && (str_contains($mw, 'auth') || str_contains($mw, 'sanctum'))) {
|
|
@@ -8388,17 +8552,21 @@ foreach ($routes as $route) {
|
|
|
8388
8552
|
}
|
|
8389
8553
|
|
|
8390
8554
|
$schema = [];
|
|
8555
|
+
$responseMetadata = null;
|
|
8391
8556
|
$action = $route->getAction();
|
|
8557
|
+
|
|
8392
8558
|
if (isset($action['uses']) && is_string($action['uses']) && str_contains($action['uses'], '@')) {
|
|
8393
8559
|
list($controller, $method) = explode('@', $action['uses']);
|
|
8394
8560
|
if (class_exists($controller)) {
|
|
8395
8561
|
try {
|
|
8396
8562
|
$reflector = new ReflectionMethod($controller, $method);
|
|
8563
|
+
|
|
8564
|
+
// \u2500\u2500 Request schema from FormRequest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
8397
8565
|
foreach ($reflector->getParameters() as $param) {
|
|
8398
8566
|
$type = $param->getType();
|
|
8399
8567
|
if ($type && !$type->isBuiltin()) {
|
|
8400
8568
|
$className = $type->getName();
|
|
8401
|
-
if (is_subclass_of($className, '
|
|
8569
|
+
if (is_subclass_of($className, 'IlluminateFoundationHttpFormRequest')) {
|
|
8402
8570
|
$request = new $className();
|
|
8403
8571
|
if (method_exists($request, 'rules')) {
|
|
8404
8572
|
$schema = $request->rules();
|
|
@@ -8406,174 +8574,97 @@ foreach ($routes as $route) {
|
|
|
8406
8574
|
}
|
|
8407
8575
|
}
|
|
8408
8576
|
}
|
|
8409
|
-
|
|
8410
|
-
//
|
|
8411
|
-
$
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
$attrName = $attr->getName();
|
|
8415
|
-
$shortName = class_basename($attrName);
|
|
8416
|
-
|
|
8417
|
-
if (in_array($shortName, ['Response', 'RouteSyncResponse'])) {
|
|
8577
|
+
|
|
8578
|
+
// \u2500\u2500 Stage 1: PHP 8 Attribute on controller method \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
8579
|
+
foreach ($reflector->getAttributes() as $attr) {
|
|
8580
|
+
$short = class_basename($attr->getName());
|
|
8581
|
+
if (in_array($short, ['Response', 'RouteSyncResponse'])) {
|
|
8418
8582
|
$args = $attr->getArguments();
|
|
8419
|
-
|
|
8420
|
-
$type = null;
|
|
8421
|
-
if (isset($args[0])) {
|
|
8422
|
-
$type = $args[0];
|
|
8423
|
-
} elseif (isset($args['type'])) {
|
|
8424
|
-
$type = $args['type'];
|
|
8425
|
-
} elseif (isset($args['model'])) {
|
|
8426
|
-
$type = $args['model'];
|
|
8427
|
-
} elseif (isset($args['response'])) {
|
|
8428
|
-
$type = $args['response'];
|
|
8429
|
-
}
|
|
8430
|
-
|
|
8431
|
-
$collection = false;
|
|
8432
|
-
if (isset($args[1])) {
|
|
8433
|
-
$collection = (bool) $args[1];
|
|
8434
|
-
} elseif (isset($args['collection'])) {
|
|
8435
|
-
$collection = (bool) $args['collection'];
|
|
8436
|
-
}
|
|
8437
|
-
|
|
8583
|
+
$type = $args[0] ?? $args['type'] ?? $args['model'] ?? $args['response'] ?? null;
|
|
8438
8584
|
if ($type) {
|
|
8439
|
-
$
|
|
8440
|
-
|
|
8441
|
-
'collection' => $collection
|
|
8442
|
-
];
|
|
8585
|
+
$collection = (bool)($args[1] ?? $args['collection'] ?? false);
|
|
8586
|
+
$responseMetadata = ['type' => class_basename($type), 'collection' => $collection];
|
|
8443
8587
|
break;
|
|
8444
8588
|
}
|
|
8445
8589
|
}
|
|
8446
8590
|
}
|
|
8447
|
-
|
|
8448
|
-
|
|
8449
|
-
$fileName = $reflector->getFileName();
|
|
8450
|
-
$startLine = $reflector->getStartLine();
|
|
8451
|
-
$endLine = $reflector->getEndLine();
|
|
8452
|
-
$methodSource = null;
|
|
8453
|
-
|
|
8454
|
-
if ($fileName && $startLine !== false && $endLine !== false) {
|
|
8455
|
-
$lines = file($fileName);
|
|
8456
|
-
$methodSource = implode("", array_slice($lines, $startLine - 1, $endLine - $startLine + 1));
|
|
8457
|
-
}
|
|
8458
8591
|
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
if (preg_match('/return\\s+new\\s+([a-zA-Z0-9_]+Resource)/', $methodSource, $matches)) {
|
|
8465
|
-
$resourceName = $matches[1];
|
|
8466
|
-
} elseif (preg_match('/return\\s+([a-zA-Z0-9_]+Resource)::collection/', $methodSource, $matches)) {
|
|
8467
|
-
$resourceName = $matches[1];
|
|
8468
|
-
$collection = true;
|
|
8469
|
-
}
|
|
8470
|
-
|
|
8471
|
-
if ($resourceName) {
|
|
8472
|
-
$resourceClass = 'App\\\\Http\\\\Resources\\\\' . $resourceName;
|
|
8473
|
-
if (class_exists($resourceClass)) {
|
|
8474
|
-
$resReflector = new ReflectionClass($resourceClass);
|
|
8475
|
-
$resAttrs = $resReflector->getAttributes();
|
|
8476
|
-
foreach ($resAttrs as $attr) {
|
|
8477
|
-
$shortName = class_basename($attr->getName());
|
|
8478
|
-
if (in_array($shortName, ['Response', 'RouteSyncResponse'])) {
|
|
8479
|
-
$args = $attr->getArguments();
|
|
8480
|
-
$type = $args[0] ?? $args['type'] ?? $args['model'] ?? $args['response'] ?? null;
|
|
8481
|
-
if ($type) {
|
|
8482
|
-
$responseMetadata = [
|
|
8483
|
-
'type' => class_basename($type),
|
|
8484
|
-
'collection' => $collection
|
|
8485
|
-
];
|
|
8486
|
-
}
|
|
8487
|
-
}
|
|
8488
|
-
}
|
|
8489
|
-
|
|
8490
|
-
if (!$responseMetadata) {
|
|
8491
|
-
$docComment = $resReflector->getDocComment();
|
|
8492
|
-
if ($docComment && preg_match('/@mixin\\s+([\\\\\\\\a-zA-Z0-9_]+)/', $docComment, $mixinMatches)) {
|
|
8493
|
-
$responseMetadata = [
|
|
8494
|
-
'type' => class_basename($mixinMatches[1]),
|
|
8495
|
-
'collection' => $collection
|
|
8496
|
-
];
|
|
8497
|
-
}
|
|
8498
|
-
}
|
|
8499
|
-
}
|
|
8500
|
-
}
|
|
8592
|
+
$methodSource = rsync_get_source($reflector);
|
|
8593
|
+
|
|
8594
|
+
// \u2500\u2500 Stage 2: Source-based inference \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
8595
|
+
if (!$responseMetadata) {
|
|
8596
|
+
$responseMetadata = rsync_infer_from_source($methodSource);
|
|
8501
8597
|
}
|
|
8502
|
-
|
|
8503
|
-
//
|
|
8598
|
+
|
|
8599
|
+
// \u2500\u2500 Stage 3: Fallback $request->validate([...]) for schema \u2500\u2500
|
|
8504
8600
|
if (empty($schema) && $methodSource) {
|
|
8505
|
-
|
|
8506
|
-
|
|
8507
|
-
|
|
8508
|
-
|
|
8509
|
-
preg_match_all('~[\\'"]([a-zA-Z0-9_.*]+)[\\'"]\\\\s*=>\\\\s*[\\'"](.*?)[\\'"]~', $rulesString, $ruleMatches);
|
|
8510
|
-
if (!empty($ruleMatches[1])) {
|
|
8511
|
-
foreach ($ruleMatches[1] as $index => $field) {
|
|
8512
|
-
$schema[$field] = $ruleMatches[2][$index];
|
|
8513
|
-
}
|
|
8601
|
+
if (preg_match('/$request->validates*(s*[(.*?)]s*)/s', $methodSource, $vm)) {
|
|
8602
|
+
preg_match_all('/['"]([a-zA-Z0-9_.*]+)['"]s*=>s*['"]([^'"]*)['"]/', $vm[1], $rm);
|
|
8603
|
+
foreach ($rm[1] as $i => $field) {
|
|
8604
|
+
$schema[$field] = $rm[2][$i];
|
|
8514
8605
|
}
|
|
8515
8606
|
}
|
|
8516
8607
|
}
|
|
8517
|
-
|
|
8608
|
+
|
|
8609
|
+
} catch (Exception $e) {}
|
|
8518
8610
|
}
|
|
8519
8611
|
}
|
|
8520
8612
|
|
|
8521
8613
|
foreach ($methods as $method) {
|
|
8522
|
-
$nameParts = explode('/', preg_replace('/^api
|
|
8523
|
-
$resource = preg_replace('
|
|
8614
|
+
$nameParts = explode('/', preg_replace('/^api//', '', $route->uri()));
|
|
8615
|
+
$resource = preg_replace('/{.*}/', '', $nameParts[0]);
|
|
8524
8616
|
if (empty($resource)) $resource = 'api';
|
|
8525
|
-
|
|
8526
|
-
$name = $resource . '.' . strtolower($method);
|
|
8527
|
-
|
|
8617
|
+
|
|
8528
8618
|
$result['routes'][] = [
|
|
8529
|
-
'name'
|
|
8530
|
-
'method'
|
|
8531
|
-
'path'
|
|
8532
|
-
'auth'
|
|
8619
|
+
'name' => $route->getName() ?: ($resource . '.' . strtolower($method)),
|
|
8620
|
+
'method' => $method,
|
|
8621
|
+
'path' => '/' . preg_replace('/^api//', '', $route->uri()),
|
|
8622
|
+
'auth' => $auth,
|
|
8533
8623
|
'middleware' => $middlewares,
|
|
8534
|
-
'schema'
|
|
8535
|
-
'response'
|
|
8624
|
+
'schema' => empty($schema) ? null : ['rules' => $schema],
|
|
8625
|
+
'response' => $responseMetadata,
|
|
8536
8626
|
];
|
|
8537
8627
|
}
|
|
8538
8628
|
}
|
|
8539
8629
|
|
|
8540
|
-
// Extract Models
|
|
8541
|
-
|
|
8630
|
+
// \u2500\u2500\u2500 Extract Models \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
8631
|
+
|
|
8632
|
+
$extractModels = ${extractModels};
|
|
8542
8633
|
if ($extractModels) {
|
|
8543
8634
|
$modelsPath = app_path('Models');
|
|
8544
8635
|
if (is_dir($modelsPath)) {
|
|
8545
|
-
$files =
|
|
8636
|
+
$files = IlluminateSupportFacadesFile::allFiles($modelsPath);
|
|
8546
8637
|
foreach ($files as $file) {
|
|
8547
|
-
$class = 'App
|
|
8548
|
-
$class = preg_replace('
|
|
8549
|
-
|
|
8550
|
-
if (class_exists($class) && is_subclass_of($class, 'Illuminate
|
|
8638
|
+
$class = 'App\\Models\\' . str_replace('/', '\\', $file->getRelativePathname());
|
|
8639
|
+
$class = preg_replace('/.php$/', '', $class);
|
|
8640
|
+
|
|
8641
|
+
if (class_exists($class) && is_subclass_of($class, 'Illuminate\\Database\\Eloquent\\Model')) {
|
|
8551
8642
|
try {
|
|
8552
8643
|
$reflection = new ReflectionClass($class);
|
|
8553
8644
|
if ($reflection->isAbstract()) continue;
|
|
8554
|
-
|
|
8555
|
-
$model
|
|
8556
|
-
$table
|
|
8557
|
-
$columns
|
|
8558
|
-
|
|
8645
|
+
|
|
8646
|
+
$model = new $class();
|
|
8647
|
+
$table = $model->getTable();
|
|
8648
|
+
$columns = IlluminateSupportFacadesSchema::getColumns($table);
|
|
8649
|
+
|
|
8559
8650
|
$parsedColumns = [];
|
|
8560
8651
|
foreach ($columns as $col) {
|
|
8561
8652
|
$parsedColumns[] = [
|
|
8562
|
-
'name'
|
|
8563
|
-
'type'
|
|
8564
|
-
'nullable' => $col['nullable']
|
|
8653
|
+
'name' => $col['name'],
|
|
8654
|
+
'type' => $col['type'],
|
|
8655
|
+
'nullable' => $col['nullable'],
|
|
8565
8656
|
];
|
|
8566
8657
|
}
|
|
8567
|
-
|
|
8658
|
+
|
|
8568
8659
|
$result['models'][] = [
|
|
8569
|
-
'name'
|
|
8570
|
-
'table'
|
|
8660
|
+
'name' => class_basename($class),
|
|
8661
|
+
'table' => $table,
|
|
8571
8662
|
'columns' => $parsedColumns,
|
|
8572
|
-
'hidden'
|
|
8663
|
+
'hidden' => $model->getHidden(),
|
|
8573
8664
|
'appends' => $model->getAppends(),
|
|
8574
|
-
'casts'
|
|
8665
|
+
'casts' => $model->getCasts(),
|
|
8575
8666
|
];
|
|
8576
|
-
} catch (
|
|
8667
|
+
} catch (Exception $e) {}
|
|
8577
8668
|
}
|
|
8578
8669
|
}
|
|
8579
8670
|
}
|
|
@@ -8726,26 +8817,10 @@ var SDKGenerator = class {
|
|
|
8726
8817
|
const lines = [];
|
|
8727
8818
|
let usesZod = false;
|
|
8728
8819
|
let usesTypes = false;
|
|
8729
|
-
const
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
if (route.schema?.rules) {
|
|
8734
|
-
usesZod = true;
|
|
8735
|
-
zodRoutes.add(`${group}.${route.actionName}`);
|
|
8736
|
-
}
|
|
8737
|
-
if (route.response) {
|
|
8738
|
-
usesTypes = true;
|
|
8739
|
-
}
|
|
8740
|
-
}
|
|
8741
|
-
}
|
|
8742
|
-
} else {
|
|
8743
|
-
for (const [group, routes] of Object.entries(grouped)) {
|
|
8744
|
-
for (const route of routes) {
|
|
8745
|
-
if (route.response) {
|
|
8746
|
-
usesTypes = true;
|
|
8747
|
-
}
|
|
8748
|
-
}
|
|
8820
|
+
for (const routes of Object.values(grouped)) {
|
|
8821
|
+
for (const route of routes) {
|
|
8822
|
+
if (options.zod && route.schema?.rules) usesZod = true;
|
|
8823
|
+
if (route.response) usesTypes = true;
|
|
8749
8824
|
}
|
|
8750
8825
|
}
|
|
8751
8826
|
lines.push(`// Auto-generated by routesync. Do not edit manually.`);
|
|
@@ -8761,15 +8836,15 @@ var SDKGenerator = class {
|
|
|
8761
8836
|
lines.push(`import * as Types from './types'`);
|
|
8762
8837
|
}
|
|
8763
8838
|
lines.push(``);
|
|
8764
|
-
for (const [
|
|
8839
|
+
for (const [groupName, routes] of Object.entries(grouped)) {
|
|
8765
8840
|
for (const route of routes) {
|
|
8766
|
-
const TitleCaseGroup =
|
|
8841
|
+
const TitleCaseGroup = groupName.charAt(0).toUpperCase() + groupName.slice(1);
|
|
8767
8842
|
const TitleCaseAction = route.actionName.charAt(0).toUpperCase() + route.actionName.slice(1);
|
|
8768
8843
|
const ContractName = `${TitleCaseGroup}${TitleCaseAction}Contract`;
|
|
8844
|
+
const SchemaName = `${TitleCaseGroup}${TitleCaseAction}Schema`;
|
|
8769
8845
|
const pathParams = Array.from(route.runtimePath.matchAll(/:([a-zA-Z0-9_]+)/g)).map((m) => m[1]);
|
|
8770
8846
|
const paramsType = pathParams.length > 0 ? `{ ${pathParams.map((p) => `${p}: string`).join(", ")} }` : `unknown`;
|
|
8771
|
-
const
|
|
8772
|
-
const bodyType = route.schema?.rules && usesZod ? `z.infer<typeof Schemas.${methodActionName}Schema>` : `unknown`;
|
|
8847
|
+
const bodyType = route.schema?.rules && usesZod ? `z.infer<typeof Schemas.${SchemaName}>` : `unknown`;
|
|
8773
8848
|
const responseType = route.response ? `Types.${route.response.type}${route.response.collection ? "[]" : ""}` : `unknown`;
|
|
8774
8849
|
lines.push(`export type ${ContractName} = {`);
|
|
8775
8850
|
lines.push(` request: {`);
|
|
@@ -8783,23 +8858,23 @@ var SDKGenerator = class {
|
|
|
8783
8858
|
}
|
|
8784
8859
|
lines.push(``);
|
|
8785
8860
|
lines.push(`export const api = defineApi({`);
|
|
8786
|
-
for (const [
|
|
8787
|
-
lines.push(` ${
|
|
8861
|
+
for (const [groupName, routes] of Object.entries(grouped)) {
|
|
8862
|
+
lines.push(` ${groupName}: {`);
|
|
8788
8863
|
for (const route of routes) {
|
|
8789
|
-
const TitleCaseGroup =
|
|
8864
|
+
const TitleCaseGroup = groupName.charAt(0).toUpperCase() + groupName.slice(1);
|
|
8790
8865
|
const TitleCaseAction = route.actionName.charAt(0).toUpperCase() + route.actionName.slice(1);
|
|
8791
8866
|
const ContractName = `${TitleCaseGroup}${TitleCaseAction}Contract`;
|
|
8867
|
+
const SchemaName = `${TitleCaseGroup}${TitleCaseAction}Schema`;
|
|
8792
8868
|
lines.push(` ${route.actionName}: endpoint<${ContractName}['response'], ${ContractName}['request']['params'], ${ContractName}['request']['body']>({`);
|
|
8793
8869
|
lines.push(` method: '${route.method}',`);
|
|
8794
8870
|
lines.push(` path: '${route.runtimePath}',`);
|
|
8795
8871
|
if (route.auth) lines.push(` auth: true,`);
|
|
8796
8872
|
if (options.zod && route.schema?.rules) {
|
|
8797
|
-
lines.push(` schema: {
|
|
8798
|
-
lines.push(` body: Schemas.${ContractName.replace("Contract", "Schema")}`);
|
|
8799
|
-
lines.push(` },`);
|
|
8873
|
+
lines.push(` schema: { body: Schemas.${SchemaName} },`);
|
|
8800
8874
|
}
|
|
8801
|
-
if (options.zod &&
|
|
8802
|
-
|
|
8875
|
+
if (options.zod && options.models && route.response) {
|
|
8876
|
+
const responseSchema = `Schemas.${route.response.type}Schema${route.response.collection ? ".array()" : ""}`;
|
|
8877
|
+
lines.push(` responseSchema: ${responseSchema},`);
|
|
8803
8878
|
}
|
|
8804
8879
|
lines.push(` }),`);
|
|
8805
8880
|
}
|
|
@@ -8857,7 +8932,6 @@ var import_fs_extra4 = __toESM(require_lib());
|
|
|
8857
8932
|
var TypeGenerator = class {
|
|
8858
8933
|
static async generate(manifest, outputDir) {
|
|
8859
8934
|
const lines = [];
|
|
8860
|
-
const hasSchemas = manifest.routes.some((r) => r.schema && Object.keys(r.schema).length > 0);
|
|
8861
8935
|
lines.push(`// Auto-generated by routesync. Do not edit manually.`);
|
|
8862
8936
|
lines.push(``);
|
|
8863
8937
|
lines.push(`export interface ApiResponse<T = unknown> {`);
|
|
@@ -8885,15 +8959,17 @@ var TypeGenerator = class {
|
|
|
8885
8959
|
for (const model of manifest.models) {
|
|
8886
8960
|
lines.push(`export interface ${model.name} {`);
|
|
8887
8961
|
for (const col of model.columns) {
|
|
8888
|
-
if (model.hidden
|
|
8962
|
+
if (model.hidden?.includes(col.name)) continue;
|
|
8889
8963
|
const tsType = this.mapSqlTypeToTs(col.type);
|
|
8890
8964
|
const finalTsType = col.nullable ? `${tsType} | null` : tsType;
|
|
8891
|
-
const
|
|
8965
|
+
const key = camelCase(col.name);
|
|
8966
|
+
const safeName = key.match(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/) ? key : `"${key}"`;
|
|
8892
8967
|
lines.push(` ${safeName}: ${finalTsType}`);
|
|
8893
8968
|
}
|
|
8894
8969
|
if (model.appends && model.appends.length > 0) {
|
|
8895
8970
|
for (const append of model.appends) {
|
|
8896
|
-
const
|
|
8971
|
+
const key = camelCase(append);
|
|
8972
|
+
const safeAppend = key.match(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/) ? key : `"${key}"`;
|
|
8897
8973
|
lines.push(` ${safeAppend}?: unknown`);
|
|
8898
8974
|
}
|
|
8899
8975
|
}
|
|
@@ -8908,7 +8984,7 @@ var TypeGenerator = class {
|
|
|
8908
8984
|
const typeName = toTypeName(resource ?? "");
|
|
8909
8985
|
lines.push(`export interface ${typeName} {`);
|
|
8910
8986
|
lines.push(` id: number`);
|
|
8911
|
-
lines.push(` // TODO: Add ${resource} fields`);
|
|
8987
|
+
lines.push(` // TODO: Add ${resource} fields \u2014 run scan with --models for real types`);
|
|
8912
8988
|
lines.push(` createdAt?: string`);
|
|
8913
8989
|
lines.push(` updatedAt?: string`);
|
|
8914
8990
|
lines.push(`}`);
|