@yrest/cli 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -53
- package/assets/yrest-banner.png +0 -0
- package/assets/yrest-lockup.png +0 -0
- package/assets/yrest-lockup.svg +20 -0
- package/assets/yrest-logo.png +0 -0
- package/assets/yrest-monochrome.png +0 -0
- package/dist/cli/index.js +1273 -145
- package/dist/cli/index.mjs +1270 -142
- package/dist/index.d.mts +52 -10
- package/dist/index.d.ts +52 -10
- package/dist/index.js +1223 -94
- package/dist/index.mjs +1220 -91
- package/package.json +1 -1
- package/assets/logo-color.png +0 -0
- package/assets/logo-figure.png +0 -0
- package/assets/logo-text.png +0 -0
- package/assets/logo-white.png +0 -0
- package/assets/yRest-banner.png +0 -0
package/dist/cli/index.mjs
CHANGED
|
@@ -97,11 +97,11 @@ _rel:
|
|
|
97
97
|
userId: users
|
|
98
98
|
# many2many \u2014 posts can have multiple tags via the post_tags pivot
|
|
99
99
|
tags:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
_type: many2many
|
|
101
|
+
_target: tags
|
|
102
|
+
_through: post_tags
|
|
103
|
+
_foreignKey: postId
|
|
104
|
+
_otherKey: tagId
|
|
105
105
|
comments:
|
|
106
106
|
postId: posts
|
|
107
107
|
userId: users
|
|
@@ -109,8 +109,8 @@ _rel:
|
|
|
109
109
|
# one2one \u2014 each user has exactly one profile
|
|
110
110
|
profiles:
|
|
111
111
|
userId:
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
_type: one2one
|
|
113
|
+
_target: users
|
|
114
114
|
|
|
115
115
|
users:
|
|
116
116
|
- id: 1
|
|
@@ -259,52 +259,52 @@ _rel:
|
|
|
259
259
|
# many2many \u2014 products belong to multiple categories via pivot
|
|
260
260
|
products:
|
|
261
261
|
categories:
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
262
|
+
_type: many2many
|
|
263
|
+
_target: categories
|
|
264
|
+
_through: product_categories
|
|
265
|
+
_foreignKey: productId
|
|
266
|
+
_otherKey: categoryId
|
|
267
267
|
|
|
268
268
|
_routes:
|
|
269
269
|
# Login with conditional scenarios
|
|
270
|
-
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
-
|
|
270
|
+
- _method: POST
|
|
271
|
+
_path: /auth/login
|
|
272
|
+
_scenarios:
|
|
273
|
+
- _when:
|
|
274
274
|
body.email: admin@example.com
|
|
275
275
|
body.password: secret
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
276
|
+
_response:
|
|
277
|
+
_status: 200
|
|
278
|
+
_body:
|
|
279
279
|
token: tok-admin-abc123
|
|
280
280
|
role: admin
|
|
281
281
|
userId: 1
|
|
282
|
-
-
|
|
282
|
+
- _when:
|
|
283
283
|
body.email: user@example.com
|
|
284
284
|
body.password: secret
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
285
|
+
_response:
|
|
286
|
+
_status: 200
|
|
287
|
+
_body:
|
|
288
288
|
token: tok-user-xyz789
|
|
289
289
|
role: user
|
|
290
290
|
userId: 2
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
291
|
+
_otherwise:
|
|
292
|
+
_status: 401
|
|
293
|
+
_body:
|
|
294
294
|
error: Invalid credentials
|
|
295
295
|
|
|
296
296
|
# Logout \u2014 always 204
|
|
297
|
-
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
297
|
+
- _method: POST
|
|
298
|
+
_path: /auth/logout
|
|
299
|
+
_response:
|
|
300
|
+
_status: 204
|
|
301
301
|
|
|
302
302
|
# Static featured products list
|
|
303
|
-
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
303
|
+
- _method: GET
|
|
304
|
+
_path: /store/featured
|
|
305
|
+
_response:
|
|
306
|
+
_status: 200
|
|
307
|
+
_body:
|
|
308
308
|
- id: 1
|
|
309
309
|
name: Laptop Pro 15
|
|
310
310
|
price: 1299.99
|
|
@@ -319,31 +319,31 @@ _routes:
|
|
|
319
319
|
badge: On Sale
|
|
320
320
|
|
|
321
321
|
# Template variables \u2014 echoes the requested product id
|
|
322
|
-
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
322
|
+
- _method: GET
|
|
323
|
+
_path: /products/:id/summary
|
|
324
|
+
_response:
|
|
325
|
+
_status: 200
|
|
326
|
+
_body:
|
|
327
327
|
productId: "{{params.id}}"
|
|
328
328
|
requestedAt: "{{now}}"
|
|
329
329
|
source: mock
|
|
330
330
|
|
|
331
331
|
# Cancel order with simulated latency and template vars
|
|
332
|
-
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
332
|
+
- _method: POST
|
|
333
|
+
_path: /orders/:id/cancel
|
|
334
|
+
_delay: 500
|
|
335
|
+
_response:
|
|
336
|
+
_status: 200
|
|
337
|
+
_body:
|
|
338
338
|
orderId: "{{params.id}}"
|
|
339
339
|
status: cancelled
|
|
340
340
|
cancelledAt: "{{now}}"
|
|
341
341
|
|
|
342
342
|
# Simulate a service outage for testing error handling
|
|
343
|
-
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
343
|
+
- _method: GET
|
|
344
|
+
_path: /store/inventory/sync
|
|
345
|
+
_error: 503
|
|
346
|
+
_errorBody:
|
|
347
347
|
message: Inventory service temporarily unavailable
|
|
348
348
|
retryAfter: 30
|
|
349
349
|
|
|
@@ -538,33 +538,173 @@ function parseRelations(raw) {
|
|
|
538
538
|
}
|
|
539
539
|
function normaliseRelationDef(key, value) {
|
|
540
540
|
if (typeof value === "string") {
|
|
541
|
-
return { type: "many2one", target: value };
|
|
541
|
+
return value.includes(":") ? parseDslString(value) : { type: "many2one", target: value };
|
|
542
542
|
}
|
|
543
543
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
544
544
|
const v = value;
|
|
545
|
-
const type = v["
|
|
546
|
-
const nested = v["
|
|
545
|
+
const type = v["_type"];
|
|
546
|
+
const nested = v["_nested"] === true ? true : void 0;
|
|
547
|
+
const carDirect = typeof v["_car-direct"] === "string" ? v["_car-direct"] : void 0;
|
|
548
|
+
const carInverse = typeof v["_car-inverse"] === "string" ? v["_car-inverse"] : void 0;
|
|
547
549
|
if (type === "many2one" || type === void 0) {
|
|
548
|
-
const target = v["
|
|
550
|
+
const target = v["_target"];
|
|
549
551
|
if (typeof target !== "string") return null;
|
|
550
|
-
|
|
552
|
+
const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
|
|
553
|
+
return compact({ type: "many2one", target, foreignKey, nested, carDirect, carInverse });
|
|
551
554
|
}
|
|
552
555
|
if (type === "one2one") {
|
|
553
|
-
const target = v["
|
|
556
|
+
const target = v["_target"];
|
|
554
557
|
if (typeof target !== "string") return null;
|
|
555
|
-
|
|
558
|
+
const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
|
|
559
|
+
return compact({ type: "one2one", target, foreignKey, nested, carDirect, carInverse });
|
|
556
560
|
}
|
|
557
561
|
if (type === "many2many") {
|
|
558
|
-
const target = typeof v["
|
|
559
|
-
const through = v["
|
|
560
|
-
const foreignKey = v["
|
|
561
|
-
const otherKey = v["
|
|
562
|
+
const target = typeof v["_target"] === "string" ? v["_target"] : key;
|
|
563
|
+
const through = v["_through"];
|
|
564
|
+
const foreignKey = v["_foreignKey"];
|
|
565
|
+
const otherKey = v["_otherKey"];
|
|
562
566
|
if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
|
|
563
567
|
return null;
|
|
564
|
-
return
|
|
568
|
+
return compact({
|
|
569
|
+
type: "many2many",
|
|
570
|
+
target,
|
|
571
|
+
through,
|
|
572
|
+
foreignKey,
|
|
573
|
+
otherKey,
|
|
574
|
+
nested,
|
|
575
|
+
carDirect,
|
|
576
|
+
carInverse
|
|
577
|
+
});
|
|
565
578
|
}
|
|
566
579
|
return null;
|
|
567
580
|
}
|
|
581
|
+
var TYPE_ALIASES = {
|
|
582
|
+
many2one: "many2one",
|
|
583
|
+
m2o: "many2one",
|
|
584
|
+
one2one: "one2one",
|
|
585
|
+
o2o: "one2one",
|
|
586
|
+
many2many: "many2many",
|
|
587
|
+
m2m: "many2many"
|
|
588
|
+
};
|
|
589
|
+
function parseDslString(raw) {
|
|
590
|
+
const nested = raw.endsWith("+nested") ? true : void 0;
|
|
591
|
+
let s = nested ? raw.slice(0, -7) : raw;
|
|
592
|
+
let carDirect;
|
|
593
|
+
let carInverse;
|
|
594
|
+
const carMatch = s.match(/\[([^[\]]+)->([^[\]]+)\]$/);
|
|
595
|
+
if (carMatch) {
|
|
596
|
+
carDirect = carMatch[1];
|
|
597
|
+
carInverse = carMatch[2];
|
|
598
|
+
s = s.slice(0, s.length - carMatch[0].length);
|
|
599
|
+
}
|
|
600
|
+
const colonIdx = s.indexOf(":");
|
|
601
|
+
if (colonIdx === -1) return null;
|
|
602
|
+
const typeAlias = s.slice(0, colonIdx);
|
|
603
|
+
const body = s.slice(colonIdx + 1);
|
|
604
|
+
const type = TYPE_ALIASES[typeAlias];
|
|
605
|
+
if (!type) return null;
|
|
606
|
+
if (type === "many2many") {
|
|
607
|
+
const m = body.match(/^([^@()[\]]+)@([^@()[\]]+)\(([^,)]+),\s*([^,)]+)\)$/);
|
|
608
|
+
if (!m) return null;
|
|
609
|
+
const [, target2, through, foreignKey2, otherKey] = m;
|
|
610
|
+
return compact({
|
|
611
|
+
type: "many2many",
|
|
612
|
+
target: target2,
|
|
613
|
+
through,
|
|
614
|
+
foreignKey: foreignKey2,
|
|
615
|
+
otherKey,
|
|
616
|
+
nested,
|
|
617
|
+
carDirect,
|
|
618
|
+
carInverse
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
const atIdx = body.indexOf("@");
|
|
622
|
+
const target = atIdx === -1 ? body : body.slice(0, atIdx);
|
|
623
|
+
const foreignKey = atIdx === -1 ? void 0 : body.slice(atIdx + 1);
|
|
624
|
+
if (!target) return null;
|
|
625
|
+
return compact({ type, target, foreignKey, nested, carDirect, carInverse });
|
|
626
|
+
}
|
|
627
|
+
function compact(obj) {
|
|
628
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/storage/parseRoutes.ts
|
|
632
|
+
function parseRoutes(raw) {
|
|
633
|
+
if (!Array.isArray(raw)) return [];
|
|
634
|
+
return raw.map(normaliseRoute).filter((r) => r !== null);
|
|
635
|
+
}
|
|
636
|
+
function normaliseRoute(raw) {
|
|
637
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
638
|
+
const v = raw;
|
|
639
|
+
const method = v["_method"];
|
|
640
|
+
const path = v["_path"];
|
|
641
|
+
if (typeof method !== "string" || typeof path !== "string") return null;
|
|
642
|
+
const route = { method, path };
|
|
643
|
+
if (typeof v["_handler"] === "string") route.handler = v["_handler"];
|
|
644
|
+
if (typeof v["_delay"] === "number") route.delay = v["_delay"];
|
|
645
|
+
if (typeof v["_error"] === "number") route.error = v["_error"];
|
|
646
|
+
if (v["_errorBody"] !== void 0) route.errorBody = v["_errorBody"];
|
|
647
|
+
const response = normaliseResponse(v["_response"]);
|
|
648
|
+
if (response) route.response = response;
|
|
649
|
+
const otherwise = normaliseResponse(v["_otherwise"]);
|
|
650
|
+
if (otherwise) route.otherwise = otherwise;
|
|
651
|
+
const scenarios = normaliseScenarios(v["_scenarios"]);
|
|
652
|
+
if (scenarios.length > 0) route.scenarios = scenarios;
|
|
653
|
+
return route;
|
|
654
|
+
}
|
|
655
|
+
function normaliseResponse(raw) {
|
|
656
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
657
|
+
const v = raw;
|
|
658
|
+
const response = {};
|
|
659
|
+
if (typeof v["_status"] === "number") response.status = v["_status"];
|
|
660
|
+
if (v["_body"] !== void 0) response.body = v["_body"];
|
|
661
|
+
if (v["_headers"] && typeof v["_headers"] === "object" && !Array.isArray(v["_headers"]))
|
|
662
|
+
response.headers = v["_headers"];
|
|
663
|
+
return Object.keys(response).length > 0 ? response : void 0;
|
|
664
|
+
}
|
|
665
|
+
function normaliseScenarios(raw) {
|
|
666
|
+
if (!Array.isArray(raw)) return [];
|
|
667
|
+
return raw.map(normaliseScenario).filter((s) => s !== null);
|
|
668
|
+
}
|
|
669
|
+
function normaliseScenario(raw) {
|
|
670
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
671
|
+
const v = raw;
|
|
672
|
+
const when = v["_when"];
|
|
673
|
+
if (!when || typeof when !== "object") return null;
|
|
674
|
+
const response = normaliseResponse(v["_response"]);
|
|
675
|
+
if (!response) return null;
|
|
676
|
+
return { when, response };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/storage/parseSchema.ts
|
|
680
|
+
function parseSchema(raw) {
|
|
681
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
682
|
+
const result = {};
|
|
683
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
684
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
685
|
+
result[collection] = {};
|
|
686
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
687
|
+
const def = normaliseFieldDef(value);
|
|
688
|
+
if (def) result[collection][field] = def;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return result;
|
|
692
|
+
}
|
|
693
|
+
function normaliseFieldDef(value) {
|
|
694
|
+
if (value === "required") return { required: true };
|
|
695
|
+
if (value === "optional") return { required: false };
|
|
696
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
697
|
+
const v = value;
|
|
698
|
+
const def = {};
|
|
699
|
+
if (v["_required"] === true || v["_required"] === false) def.required = v["_required"];
|
|
700
|
+
if (typeof v["_type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["_type"]))
|
|
701
|
+
def.type = v["_type"];
|
|
702
|
+
if (typeof v["_format"] === "string") def.format = v["_format"];
|
|
703
|
+
if (Array.isArray(v["_enum"])) def.enum = v["_enum"];
|
|
704
|
+
if (typeof v["_description"] === "string") def.description = v["_description"];
|
|
705
|
+
if (v["_default"] !== void 0) def.default = v["_default"];
|
|
706
|
+
return def;
|
|
707
|
+
}
|
|
568
708
|
|
|
569
709
|
// src/utils/deepCopy.ts
|
|
570
710
|
function deepCopyData(source) {
|
|
@@ -574,13 +714,69 @@ function deepCopyData(source) {
|
|
|
574
714
|
}
|
|
575
715
|
|
|
576
716
|
// src/storage/yrestStorage.ts
|
|
717
|
+
function serializeRelations(relations) {
|
|
718
|
+
const out = {};
|
|
719
|
+
for (const [col, fields] of Object.entries(relations)) {
|
|
720
|
+
out[col] = {};
|
|
721
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
722
|
+
out[col][key] = serializeRelationDef(def);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
function serializeRelationDef(def) {
|
|
728
|
+
if (def.type === "many2many") {
|
|
729
|
+
const entry2 = {
|
|
730
|
+
_type: def.type,
|
|
731
|
+
_target: def.target,
|
|
732
|
+
_through: def.through,
|
|
733
|
+
_foreignKey: def.foreignKey,
|
|
734
|
+
_otherKey: def.otherKey
|
|
735
|
+
};
|
|
736
|
+
if (def.nested) entry2._nested = true;
|
|
737
|
+
if (def.carDirect) entry2["_car-direct"] = def.carDirect;
|
|
738
|
+
if (def.carInverse) entry2["_car-inverse"] = def.carInverse;
|
|
739
|
+
return entry2;
|
|
740
|
+
}
|
|
741
|
+
const entry = { _type: def.type, _target: def.target };
|
|
742
|
+
if (def.foreignKey) entry._foreignKey = def.foreignKey;
|
|
743
|
+
if (def.nested) entry._nested = true;
|
|
744
|
+
if (def.carDirect) entry["_car-direct"] = def.carDirect;
|
|
745
|
+
if (def.carInverse) entry["_car-inverse"] = def.carInverse;
|
|
746
|
+
return entry;
|
|
747
|
+
}
|
|
748
|
+
function serializeRoutes(routes) {
|
|
749
|
+
return routes.map((r) => {
|
|
750
|
+
const entry = { _method: r.method, _path: r.path };
|
|
751
|
+
if (r.handler) entry._handler = r.handler;
|
|
752
|
+
if (r.delay !== void 0) entry._delay = r.delay;
|
|
753
|
+
if (r.error !== void 0) entry._error = r.error;
|
|
754
|
+
if (r.errorBody !== void 0) entry._errorBody = r.errorBody;
|
|
755
|
+
if (r.response) entry._response = serializeResponse(r.response);
|
|
756
|
+
if (r.otherwise) entry._otherwise = serializeResponse(r.otherwise);
|
|
757
|
+
if (r.scenarios?.length) entry._scenarios = r.scenarios.map(serializeScenario);
|
|
758
|
+
return entry;
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
function serializeResponse(res) {
|
|
762
|
+
const out = {};
|
|
763
|
+
if (res.status !== void 0) out._status = res.status;
|
|
764
|
+
if (res.body !== void 0) out._body = res.body;
|
|
765
|
+
if (res.headers) out._headers = res.headers;
|
|
766
|
+
return out;
|
|
767
|
+
}
|
|
768
|
+
function serializeScenario(s) {
|
|
769
|
+
return { _when: s.when, _response: serializeResponse(s.response) };
|
|
770
|
+
}
|
|
577
771
|
function createYrestStorage(filePath) {
|
|
578
772
|
const absPath = resolve2(filePath);
|
|
579
773
|
const raw = parse(readFileSync(absPath, "utf8")) ?? {};
|
|
774
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
580
775
|
const relations = parseRelations(raw["_rel"]);
|
|
581
|
-
const routes =
|
|
776
|
+
const routes = parseRoutes(raw["_routes"]);
|
|
777
|
+
const schema = parseSchema(raw["_schema"]);
|
|
582
778
|
const data = Object.fromEntries(
|
|
583
|
-
Object.entries(raw).filter(([key]) => key
|
|
779
|
+
Object.entries(raw).filter(([key]) => !RESERVED.has(key))
|
|
584
780
|
);
|
|
585
781
|
let snapshot = {
|
|
586
782
|
data: deepCopyData(data),
|
|
@@ -594,6 +790,9 @@ function createYrestStorage(filePath) {
|
|
|
594
790
|
getRelations() {
|
|
595
791
|
return relations;
|
|
596
792
|
},
|
|
793
|
+
getSchema() {
|
|
794
|
+
return schema;
|
|
795
|
+
},
|
|
597
796
|
getRoutes() {
|
|
598
797
|
return routes;
|
|
599
798
|
},
|
|
@@ -605,8 +804,8 @@ function createYrestStorage(filePath) {
|
|
|
605
804
|
},
|
|
606
805
|
persist() {
|
|
607
806
|
const payload = {};
|
|
608
|
-
if (Object.keys(relations).length > 0) payload._rel = relations;
|
|
609
|
-
if (routes.length > 0) payload._routes = routes;
|
|
807
|
+
if (Object.keys(relations).length > 0) payload._rel = serializeRelations(relations);
|
|
808
|
+
if (routes.length > 0) payload._routes = serializeRoutes(routes);
|
|
610
809
|
Object.assign(payload, data);
|
|
611
810
|
const tmp = resolve2(dirname(absPath), `.yrest-${randomUUID()}.tmp`);
|
|
612
811
|
writeFileSync2(tmp, stringify(payload), "utf8");
|
|
@@ -616,7 +815,7 @@ function createYrestStorage(filePath) {
|
|
|
616
815
|
const fresh = parse(readFileSync(absPath, "utf8")) ?? {};
|
|
617
816
|
const freshRelations = parseRelations(fresh["_rel"]);
|
|
618
817
|
const freshData = Object.fromEntries(
|
|
619
|
-
Object.entries(fresh).filter(([key]) => key
|
|
818
|
+
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
620
819
|
);
|
|
621
820
|
for (const key of Object.keys(data)) delete data[key];
|
|
622
821
|
Object.assign(data, freshData);
|
|
@@ -728,7 +927,7 @@ function endpointRow(method, path, desc) {
|
|
|
728
927
|
}
|
|
729
928
|
function resourceAccordion(name, base, isOpen) {
|
|
730
929
|
const p = `${base}/${name}`;
|
|
731
|
-
const
|
|
930
|
+
const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
732
931
|
const rows = [
|
|
733
932
|
endpointRow(
|
|
734
933
|
"GET",
|
|
@@ -738,20 +937,20 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
738
937
|
endpointRow(
|
|
739
938
|
"POST",
|
|
740
939
|
p,
|
|
741
|
-
`Create a new ${
|
|
940
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
742
941
|
),
|
|
743
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
942
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
|
|
744
943
|
endpointRow(
|
|
745
944
|
"PUT",
|
|
746
945
|
`${p}/:id`,
|
|
747
|
-
`Fully replace a ${
|
|
946
|
+
`Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
|
|
748
947
|
),
|
|
749
948
|
endpointRow(
|
|
750
949
|
"PATCH",
|
|
751
950
|
`${p}/:id`,
|
|
752
|
-
`Partially update a ${
|
|
951
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
753
952
|
),
|
|
754
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
953
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
|
|
755
954
|
].join("");
|
|
756
955
|
return `
|
|
757
956
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
@@ -770,13 +969,13 @@ function nestedRoutesAccordion(relations, base) {
|
|
|
770
969
|
for (const [key, def] of Object.entries(fields)) {
|
|
771
970
|
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
772
971
|
if (def.type === "many2many") {
|
|
773
|
-
const
|
|
972
|
+
const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
774
973
|
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
775
974
|
rows.push(
|
|
776
975
|
endpointRow(
|
|
777
976
|
"GET",
|
|
778
977
|
`${base}/${source}/:id/${key}`,
|
|
779
|
-
`List ${def.target} linked to a ${
|
|
978
|
+
`List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
780
979
|
)
|
|
781
980
|
);
|
|
782
981
|
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
@@ -902,7 +1101,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
|
|
|
902
1101
|
const firstCol = collections[0];
|
|
903
1102
|
if (firstCol) {
|
|
904
1103
|
const p = `${host}${base}/${firstCol}`;
|
|
905
|
-
const
|
|
1104
|
+
const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
906
1105
|
examples.push(
|
|
907
1106
|
`# List all ${firstCol}
|
|
908
1107
|
curl ${p}`,
|
|
@@ -910,17 +1109,17 @@ curl ${p}`,
|
|
|
910
1109
|
curl "${p}?name=value"`,
|
|
911
1110
|
`# Sort and paginate
|
|
912
1111
|
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
913
|
-
`# Get single ${
|
|
1112
|
+
`# Get single ${singular2}
|
|
914
1113
|
curl ${p}/1`,
|
|
915
|
-
`# Create ${
|
|
1114
|
+
`# Create ${singular2}
|
|
916
1115
|
curl -X POST ${p} \\
|
|
917
1116
|
-H "Content-Type: application/json" \\
|
|
918
1117
|
-d '{"name":"example"}'`,
|
|
919
|
-
`# Partially update ${
|
|
1118
|
+
`# Partially update ${singular2}
|
|
920
1119
|
curl -X PATCH ${p}/1 \\
|
|
921
1120
|
-H "Content-Type: application/json" \\
|
|
922
1121
|
-d '{"name":"updated"}'`,
|
|
923
|
-
`# Delete ${
|
|
1122
|
+
`# Delete ${singular2}
|
|
924
1123
|
curl -X DELETE ${p}/1`
|
|
925
1124
|
);
|
|
926
1125
|
}
|
|
@@ -983,12 +1182,508 @@ curl ${curlFlag}${fullPath}`);
|
|
|
983
1182
|
return `<pre>${highlighted}</pre>`;
|
|
984
1183
|
}
|
|
985
1184
|
|
|
1185
|
+
// src/router/templates/about.diagram.ts
|
|
1186
|
+
function inferType(value) {
|
|
1187
|
+
if (value === null || value === void 0) return "string";
|
|
1188
|
+
if (typeof value === "boolean") return "boolean";
|
|
1189
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1190
|
+
if (Array.isArray(value)) return "array";
|
|
1191
|
+
if (typeof value === "object") return "object";
|
|
1192
|
+
return "string";
|
|
1193
|
+
}
|
|
1194
|
+
function generateERData(storage) {
|
|
1195
|
+
const data = storage.getData();
|
|
1196
|
+
const relations = storage.getRelations();
|
|
1197
|
+
const schema = storage.getSchema();
|
|
1198
|
+
const pivotTables = /* @__PURE__ */ new Set();
|
|
1199
|
+
for (const fields of Object.values(relations)) {
|
|
1200
|
+
for (const def of Object.values(fields)) {
|
|
1201
|
+
if (def.type === "many2many") pivotTables.add(def.through);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const fkFields = /* @__PURE__ */ new Map();
|
|
1205
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
1206
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1207
|
+
if (def.type !== "many2many") {
|
|
1208
|
+
if (!fkFields.has(source)) fkFields.set(source, /* @__PURE__ */ new Set());
|
|
1209
|
+
fkFields.get(source).add(key);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
const entities = Object.entries(data).map(([name, items]) => {
|
|
1214
|
+
const sample = items[0] ?? {};
|
|
1215
|
+
const schemaFields = schema[name] ?? {};
|
|
1216
|
+
const allFields = /* @__PURE__ */ new Set([...Object.keys(sample), ...Object.keys(schemaFields)]);
|
|
1217
|
+
const fields = [...allFields].map((fieldName) => ({
|
|
1218
|
+
name: fieldName,
|
|
1219
|
+
type: schemaFields[fieldName]?.type ?? inferType(sample[fieldName]),
|
|
1220
|
+
pk: fieldName === "id",
|
|
1221
|
+
fk: fkFields.get(name)?.has(fieldName) ?? false,
|
|
1222
|
+
required: schemaFields[fieldName]?.required ?? false
|
|
1223
|
+
}));
|
|
1224
|
+
fields.sort((a, b) => {
|
|
1225
|
+
if (a.pk) return -1;
|
|
1226
|
+
if (b.pk) return 1;
|
|
1227
|
+
if (a.fk && !b.fk) return 1;
|
|
1228
|
+
if (!a.fk && b.fk) return -1;
|
|
1229
|
+
return 0;
|
|
1230
|
+
});
|
|
1231
|
+
return { name, fields, isPivot: pivotTables.has(name) };
|
|
1232
|
+
});
|
|
1233
|
+
const erRelations = [];
|
|
1234
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
1235
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1236
|
+
if (def.type === "many2many") {
|
|
1237
|
+
erRelations.push({
|
|
1238
|
+
source,
|
|
1239
|
+
target: def.target,
|
|
1240
|
+
type: "many2many",
|
|
1241
|
+
label: def.through,
|
|
1242
|
+
through: def.through,
|
|
1243
|
+
foreignKey: def.foreignKey,
|
|
1244
|
+
otherKey: def.otherKey
|
|
1245
|
+
});
|
|
1246
|
+
} else {
|
|
1247
|
+
erRelations.push({
|
|
1248
|
+
source,
|
|
1249
|
+
target: def.target,
|
|
1250
|
+
type: def.type,
|
|
1251
|
+
label: key,
|
|
1252
|
+
carDirect: def.carDirect,
|
|
1253
|
+
carInverse: def.carInverse
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return { entities, relations: erRelations };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// src/router/templates/about.diagram.canvas.ts
|
|
1262
|
+
function erDiagramSection(data) {
|
|
1263
|
+
if (!data.entities.length) return "";
|
|
1264
|
+
const json = JSON.stringify(data);
|
|
1265
|
+
return (
|
|
1266
|
+
/*html*/
|
|
1267
|
+
`
|
|
1268
|
+
<h2>Data Model</h2>
|
|
1269
|
+
|
|
1270
|
+
<div class="card" style="padding:0;overflow:hidden;position:relative;">
|
|
1271
|
+
<canvas id="er-canvas" style="width:100%;display:block;cursor:grab;"></canvas>
|
|
1272
|
+
<button id="er-dl" title="Download PNG" style="position:absolute;top:10px;right:14px;background:#161b22;border:1px solid #30363d;color:#7d8590;padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;line-height:1.4;">\u2193 PNG</button>
|
|
1273
|
+
</div>
|
|
1274
|
+
|
|
1275
|
+
<script>${/*js*/
|
|
1276
|
+
`
|
|
1277
|
+
(function(){
|
|
1278
|
+
|
|
1279
|
+
var D=${json};
|
|
1280
|
+
|
|
1281
|
+
// \u2500\u2500 Constants \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
|
|
1282
|
+
var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=8,BO=14,LGH=56;
|
|
1283
|
+
|
|
1284
|
+
var C={bg:'#0d1117',nb:'#161b22',nh:'#1c2128',pb:'#0d1117',ph:'#161b22',
|
|
1285
|
+
bo:'#30363d',bs:'#58a6ff',tx:'#e6edf3',mu:'#7d8590',
|
|
1286
|
+
pk:'#e3b341',fk:'#79c0ff',m2o:'#58a6ff',o2o:'#34d399',m2m:'#818cf8'};
|
|
1287
|
+
|
|
1288
|
+
// \u2500\u2500 Canvas \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\u2500\u2500
|
|
1289
|
+
var cv = document.getElementById('er-canvas');
|
|
1290
|
+
var dpr = window.devicePixelRatio||1;
|
|
1291
|
+
var ctx = cv.getContext('2d');
|
|
1292
|
+
|
|
1293
|
+
function resize(h){
|
|
1294
|
+
if(h)cv.style.height = h + 'px';
|
|
1295
|
+
var r = cv.getBoundingClientRect();
|
|
1296
|
+
cv.width = Math.round(r.width*dpr);
|
|
1297
|
+
cv.height = Math.round(r.height*dpr);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// \u2500\u2500 Nodes & adjacency \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
|
|
1301
|
+
var nodes = D.entities.map(function(e){
|
|
1302
|
+
return{e:e,x:0,y:0,w:NW,h:HH+e.fields.length*FH+6};
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
var nIdx={};D.entities.forEach(function(e,i){nIdx[e.name]=i;});
|
|
1306
|
+
|
|
1307
|
+
var adj=nodes.map(function(){return[];}); // forward
|
|
1308
|
+
var radj=nodes.map(function(){return[];}); // reverse
|
|
1309
|
+
|
|
1310
|
+
D.relations.forEach(function(r){
|
|
1311
|
+
if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
|
|
1312
|
+
var s=nIdx[r.source],p=nIdx[r.through],t=nIdx[r.target];
|
|
1313
|
+
if(s!=null&&p!=null&&s!==p){adj[s].push(p);radj[p].push(s);}
|
|
1314
|
+
if(t!=null&&p!=null&&t!==p){adj[t].push(p);radj[p].push(t);}
|
|
1315
|
+
}else{
|
|
1316
|
+
var s=nIdx[r.source],t=nIdx[r.target];
|
|
1317
|
+
if(s==null||t==null||s===t)return;
|
|
1318
|
+
adj[s].push(t);radj[t].push(s);
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
// \u2500\u2500 Layered layout (simplified Sugiyama) \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
|
|
1323
|
+
var groups=[];
|
|
1324
|
+
|
|
1325
|
+
function median(arr){
|
|
1326
|
+
if(!arr.length)return 0;
|
|
1327
|
+
var s=[].concat(arr).sort(function(a,b){return a-b;});
|
|
1328
|
+
return s[Math.floor(s.length/2)];
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function computeLayout(W,H){
|
|
1332
|
+
var layer=nodes.map(function(){return 0;});
|
|
1333
|
+
var inDeg=nodes.map(function(){return 0;});
|
|
1334
|
+
adj.forEach(function(ts){ts.forEach(function(t){inDeg[t]++;});});
|
|
1335
|
+
var q=[],qi=0;
|
|
1336
|
+
for(var i=0;i<nodes.length;i++)if(inDeg[i]===0)q.push(i);
|
|
1337
|
+
if(!q.length)for(var i=0;i<nodes.length;i++)q.push(i);
|
|
1338
|
+
while(qi<q.length){
|
|
1339
|
+
var u=q[qi++];
|
|
1340
|
+
adj[u].forEach(function(v){if(layer[v]<=layer[u]){layer[v]=layer[u]+1;q.push(v);}});
|
|
1341
|
+
}
|
|
1342
|
+
var maxL=layer.reduce(function(m,l){return Math.max(m,l);},0);
|
|
1343
|
+
groups=[];
|
|
1344
|
+
for(var l=0;l<=maxL;l++)groups.push([]);
|
|
1345
|
+
for(var i=0;i<nodes.length;i++)groups[layer[i]].push(i);
|
|
1346
|
+
for(var l=1;l<=maxL;l++){
|
|
1347
|
+
var pp={};groups[l-1].forEach(function(i,p){pp[i]=p;});
|
|
1348
|
+
groups[l].sort(function(a,b){
|
|
1349
|
+
return median(radj[a].map(function(x){return pp[x]!=null?pp[x]:0;}))-
|
|
1350
|
+
median(radj[b].map(function(x){return pp[x]!=null?pp[x]:0;}));
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
for(var l=maxL-1;l>=0;l--){
|
|
1354
|
+
var np={};groups[l+1].forEach(function(i,p){np[i]=p;});
|
|
1355
|
+
groups[l].sort(function(a,b){
|
|
1356
|
+
return median(adj[a].map(function(x){return np[x]!=null?np[x]:0;}))-
|
|
1357
|
+
median(adj[b].map(function(x){return np[x]!=null?np[x]:0;}));
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
var colW=NW+COL_GAP;
|
|
1361
|
+
var totalW=(maxL+1)*colW-COL_GAP;
|
|
1362
|
+
var startX=Math.max(20,(W-totalW)/2);
|
|
1363
|
+
for(var l=0;l<=maxL;l++){
|
|
1364
|
+
var g=groups[l];
|
|
1365
|
+
var totalH=g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP;
|
|
1366
|
+
var y=Math.max(20,(H-totalH)/2);
|
|
1367
|
+
g.forEach(function(i){nodes[i].x=startX+l*colW;nodes[i].y=y;y+=nodes[i].h+ROW_GAP;});
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function requiredH(){
|
|
1372
|
+
return Math.max(340,Math.min(720,groups.reduce(function(m,g){
|
|
1373
|
+
return Math.max(m,g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP+LGH+70);
|
|
1374
|
+
},300)));
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// \u2500\u2500 View \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\u2500\u2500\u2500\u2500
|
|
1378
|
+
var pan={x:0,y:0},zm=1;
|
|
1379
|
+
var origPos=[];
|
|
1380
|
+
|
|
1381
|
+
function savePos(){origPos=nodes.map(function(n){return{x:n.x,y:n.y};});}
|
|
1382
|
+
|
|
1383
|
+
function fitView(W,H){
|
|
1384
|
+
var x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
|
|
1385
|
+
nodes.forEach(function(n){x0=Math.min(x0,n.x);y0=Math.min(y0,n.y);x1=Math.max(x1,n.x+n.w);y1=Math.max(y1,n.y+n.h);});
|
|
1386
|
+
var pad=28,gW=x1-x0+pad*2,gH=y1-y0+pad*2;
|
|
1387
|
+
zm=Math.min(W/gW,(H-LGH)/gH,1);
|
|
1388
|
+
pan.x=(W-gW*zm)/2+pad*zm-x0*zm;
|
|
1389
|
+
pan.y=((H-LGH)-gH*zm)/2+pad*zm-y0*zm;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// \u2500\u2500 Rendering \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
|
|
1393
|
+
function rr(x,y,w,h,r){
|
|
1394
|
+
if(ctx.roundRect){ctx.beginPath();ctx.roundRect(x,y,w,h,r);return;}
|
|
1395
|
+
ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
|
|
1396
|
+
ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function edgePt(n,tx,ty){
|
|
1400
|
+
var cx=n.x+n.w/2,cy=n.y+n.h/2,dx=tx-cx,dy=ty-cy;
|
|
1401
|
+
if(!dx&&!dy)return{x:cx,y:cy};
|
|
1402
|
+
var s=Math.min((n.w/2)/Math.abs(dx||.001),(n.h/2)/Math.abs(dy||.001));
|
|
1403
|
+
return{x:cx+dx*s,y:cy+dy*s};
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
var badges=[];
|
|
1407
|
+
|
|
1408
|
+
function pushBadge(x,y,ux,uy,many,col){
|
|
1409
|
+
badges.push({x:x,y:y,ux:ux,uy:uy,many:many,col:col});
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Badge: solid colored circle; crow's foot (many) or double-bar (one) in dark ink inside.
|
|
1413
|
+
// ux,uy points outward from the node \u2014 badge is offset BO px into the gap.
|
|
1414
|
+
// Crow's foot tines open toward the node (-x in rotated frame), tip toward line (+x).
|
|
1415
|
+
function drawBadge(b){
|
|
1416
|
+
var bx=b.x+b.ux*BO,by=b.y+b.uy*BO;
|
|
1417
|
+
ctx.fillStyle=b.col;ctx.strokeStyle=b.col;ctx.lineWidth=1;
|
|
1418
|
+
ctx.beginPath();ctx.arc(bx,by,BR,0,Math.PI*2);ctx.fill();ctx.stroke();
|
|
1419
|
+
ctx.save();ctx.translate(bx,by);ctx.rotate(Math.atan2(b.uy,b.ux));
|
|
1420
|
+
ctx.strokeStyle=C.bg;ctx.lineWidth=1.4;ctx.lineCap='round';
|
|
1421
|
+
ctx.beginPath();
|
|
1422
|
+
if(b.many){
|
|
1423
|
+
ctx.moveTo(-5,-4);ctx.lineTo(5,0); // upper tine \u2192 tip
|
|
1424
|
+
ctx.moveTo(-5,4);ctx.lineTo(5,0); // lower tine \u2192 tip
|
|
1425
|
+
ctx.moveTo(-5,0);ctx.lineTo(5,0); // center line
|
|
1426
|
+
ctx.moveTo(-5,-4);ctx.lineTo(-5,4); // base bar (near node)
|
|
1427
|
+
}else{
|
|
1428
|
+
ctx.moveTo(-3,-5);ctx.lineTo(-3,5); // near bar
|
|
1429
|
+
ctx.moveTo(2,-5);ctx.lineTo(2,5); // far bar
|
|
1430
|
+
}
|
|
1431
|
+
ctx.stroke();
|
|
1432
|
+
ctx.restore();
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function ecol(t){return t==='one2one'?C.o2o:t==='many2many'?C.m2m:C.m2o;}
|
|
1436
|
+
|
|
1437
|
+
// offset: pixels to shift perpendicular to the edge direction (for parallel edges)
|
|
1438
|
+
function drawEdgeLine(a,b,col,dashed,label,manyA,manyB,off){
|
|
1439
|
+
var p1=edgePt(a,b.x+b.w/2,b.y+b.h/2);
|
|
1440
|
+
var p2=edgePt(b,a.x+a.w/2,a.y+a.h/2);
|
|
1441
|
+
var dx=p2.x-p1.x,dy=p2.y-p1.y,d=Math.sqrt(dx*dx+dy*dy)||1;
|
|
1442
|
+
var ux=dx/d,uy=dy/d;
|
|
1443
|
+
var px=-uy*off,py=ux*off; // perpendicular shift
|
|
1444
|
+
var q1x=p1.x+px,q1y=p1.y+py,q2x=p2.x+px,q2y=p2.y+py;
|
|
1445
|
+
ctx.strokeStyle=col+'55';ctx.lineWidth=1.5;
|
|
1446
|
+
ctx.setLineDash(dashed?[5,4]:[]);
|
|
1447
|
+
ctx.beginPath();ctx.moveTo(q1x,q1y);ctx.lineTo(q2x,q2y);ctx.stroke();
|
|
1448
|
+
ctx.setLineDash([]);
|
|
1449
|
+
pushBadge(q1x,q1y,ux,uy,manyA,col);
|
|
1450
|
+
pushBadge(q2x,q2y,-ux,-uy,manyB,col);
|
|
1451
|
+
if(label){
|
|
1452
|
+
var mx=(q1x+q2x)/2,my=(q1y+q2y)/2;
|
|
1453
|
+
ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='center';ctx.textBaseline='middle';
|
|
1454
|
+
ctx.fillText(label,mx-uy*14,my+ux*14);
|
|
1455
|
+
ctx.textBaseline='alphabetic';
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// canonical key for a node pair regardless of direction
|
|
1460
|
+
function pairKey(a,b){
|
|
1461
|
+
var ai=nodes.indexOf(a),bi=nodes.indexOf(b);
|
|
1462
|
+
return ai<bi?ai+'-'+bi:bi+'-'+ai;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function collectEdges(){
|
|
1466
|
+
var edges=[];
|
|
1467
|
+
D.relations.forEach(function(r){
|
|
1468
|
+
var col=ecol(r.type);
|
|
1469
|
+
if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
|
|
1470
|
+
var sn=nodes[nIdx[r.source]],pn=nodes[nIdx[r.through]],tn=nodes[nIdx[r.target]];
|
|
1471
|
+
if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',manyA:false,manyB:true,off:0});
|
|
1472
|
+
if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',manyA:false,manyB:true,off:0});
|
|
1473
|
+
}else{
|
|
1474
|
+
var sn=nodes[nIdx[r.source]],tn=nodes[nIdx[r.target]];
|
|
1475
|
+
if(sn&&tn&&sn!==tn){
|
|
1476
|
+
var manyA=r.carInverse?r.carInverse.endsWith('n'):r.type!=='one2one';
|
|
1477
|
+
var manyB=r.carDirect?r.carDirect.endsWith('n'):r.type==='many2many';
|
|
1478
|
+
edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,manyA:manyA,manyB:manyB,off:0});
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
// assign perpendicular offsets to parallel edges (same node pair)
|
|
1483
|
+
var cnt={},idx={};
|
|
1484
|
+
edges.forEach(function(e){var k=pairKey(e.a,e.b);cnt[k]=(cnt[k]||0)+1;});
|
|
1485
|
+
edges.forEach(function(e){
|
|
1486
|
+
var k=pairKey(e.a,e.b),n=cnt[k];
|
|
1487
|
+
var i=idx[k]=(idx[k]||0);
|
|
1488
|
+
e.off=n>1?(i-(n-1)/2)*16:0;
|
|
1489
|
+
idx[k]++;
|
|
1490
|
+
});
|
|
1491
|
+
return edges;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function drawNode(n){
|
|
1495
|
+
var x=n.x,y=n.y,w=n.w,h=n.h,e=n.e;
|
|
1496
|
+
var bg=e.isPivot?C.pb:C.nb,hd=e.isPivot?C.ph:C.nh;
|
|
1497
|
+
ctx.save();ctx.shadowColor='rgba(0,0,0,0.45)';ctx.shadowBlur=14;ctx.shadowOffsetY=4;
|
|
1498
|
+
ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();ctx.restore();
|
|
1499
|
+
ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();
|
|
1500
|
+
ctx.strokeStyle=n===drag?C.bs:C.bo;ctx.lineWidth=1.2;rr(x,y,w,h,7);ctx.stroke();
|
|
1501
|
+
ctx.fillStyle=hd;rr(x,y,w,HH,7);ctx.fill();ctx.fillRect(x,y+HH-7,w,7);
|
|
1502
|
+
ctx.strokeStyle=C.bo;ctx.lineWidth=1;
|
|
1503
|
+
ctx.beginPath();ctx.moveTo(x,y+HH);ctx.lineTo(x+w,y+HH);ctx.stroke();
|
|
1504
|
+
ctx.fillStyle=e.isPivot?C.mu:C.tx;
|
|
1505
|
+
ctx.font='600 12px -apple-system,BlinkMacSystemFont,sans-serif';
|
|
1506
|
+
ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1507
|
+
ctx.fillText(e.name,x+FP,y+HH/2);
|
|
1508
|
+
if(e.isPivot){
|
|
1509
|
+
ctx.fillStyle='#3d444d';ctx.font='9px monospace';ctx.textAlign='right';
|
|
1510
|
+
ctx.fillText('pivot',x+w-FP,y+HH/2);
|
|
1511
|
+
}
|
|
1512
|
+
e.fields.forEach(function(f,fi){
|
|
1513
|
+
var fy=y+HH+fi*FH+FH/2+3;
|
|
1514
|
+
ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
|
|
1515
|
+
ctx.fillText(f.type,x+w-FP-(f.required&&!f.pk?14:0),fy);
|
|
1516
|
+
if(f.pk||f.fk){
|
|
1517
|
+
var tag=f.pk?'PK':'FK',tc=f.pk?C.pk:C.fk;
|
|
1518
|
+
ctx.fillStyle=tc+'22';ctx.strokeStyle=tc+'66';ctx.lineWidth=1;
|
|
1519
|
+
rr(x+FP,fy-6,12,12,2);ctx.fill();ctx.stroke();
|
|
1520
|
+
ctx.fillStyle=tc;ctx.font='7px monospace';ctx.textAlign='center';
|
|
1521
|
+
ctx.fillText(tag,x+FP+6,fy+0.5);
|
|
1522
|
+
}
|
|
1523
|
+
ctx.fillStyle=f.pk?C.pk:f.fk?C.fk:C.tx;
|
|
1524
|
+
ctx.font=(f.required&&!f.pk?'600 ':'')+'11px monospace';
|
|
1525
|
+
ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1526
|
+
ctx.fillText(f.name,x+FP+(f.pk||f.fk?16:0),fy);
|
|
1527
|
+
if(f.required&&!f.pk){
|
|
1528
|
+
ctx.fillStyle='#f85149';ctx.beginPath();
|
|
1529
|
+
ctx.arc(x+w-FP-6,fy,2.5,0,Math.PI*2);ctx.fill();
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
ctx.textBaseline='alphabetic';
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function drawLegend(){
|
|
1536
|
+
var cvW=cv.width/dpr,cvH=cv.height/dpr;
|
|
1537
|
+
var y0=cvH-LGH,y1=cvH-LGH+18,y2=cvH-LGH+38;
|
|
1538
|
+
|
|
1539
|
+
ctx.fillStyle='#161b22';
|
|
1540
|
+
ctx.fillRect(0,y0,cvW,LGH);
|
|
1541
|
+
ctx.strokeStyle='#30363d';ctx.lineWidth=1;
|
|
1542
|
+
ctx.beginPath();ctx.moveTo(0,y0);ctx.lineTo(cvW,y0);ctx.stroke();
|
|
1543
|
+
|
|
1544
|
+
// Row 1: relation line types
|
|
1545
|
+
var types=[
|
|
1546
|
+
{c:C.m2o,l:'many2one',d:false},
|
|
1547
|
+
{c:C.o2o,l:'one2one',d:false},
|
|
1548
|
+
{c:C.m2m,l:'many2many',d:true}
|
|
1549
|
+
];
|
|
1550
|
+
ctx.textAlign='left';ctx.textBaseline='middle';ctx.font='10px monospace';
|
|
1551
|
+
var x=14;
|
|
1552
|
+
types.forEach(function(it){
|
|
1553
|
+
ctx.strokeStyle=it.c;ctx.lineWidth=1.5;ctx.setLineDash(it.d?[4,3]:[]);
|
|
1554
|
+
ctx.beginPath();ctx.moveTo(x,y1);ctx.lineTo(x+14,y1);ctx.stroke();
|
|
1555
|
+
ctx.setLineDash([]);
|
|
1556
|
+
ctx.fillStyle=C.mu;
|
|
1557
|
+
ctx.fillText(it.l,x+18,y1);
|
|
1558
|
+
x+=18+ctx.measureText(it.l).width+22;
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
// Row 2: cardinality symbols
|
|
1562
|
+
function badge2(cx,cy,many,col){
|
|
1563
|
+
ctx.fillStyle=col;ctx.strokeStyle=col;ctx.lineWidth=1;
|
|
1564
|
+
ctx.beginPath();ctx.arc(cx,cy,6,0,Math.PI*2);ctx.fill();
|
|
1565
|
+
ctx.save();ctx.translate(cx,cy);
|
|
1566
|
+
ctx.strokeStyle=C.bg;ctx.lineWidth=1.2;ctx.lineCap='round';
|
|
1567
|
+
ctx.beginPath();
|
|
1568
|
+
if(many){
|
|
1569
|
+
ctx.moveTo(-4,-3);ctx.lineTo(4,0);ctx.moveTo(-4,3);ctx.lineTo(4,0);
|
|
1570
|
+
ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(-4,-3);ctx.lineTo(-4,3);
|
|
1571
|
+
}else{
|
|
1572
|
+
ctx.moveTo(-2,-3);ctx.lineTo(-2,3);ctx.moveTo(2,-3);ctx.lineTo(2,3);
|
|
1573
|
+
}
|
|
1574
|
+
ctx.stroke();ctx.restore();
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1578
|
+
x=14;
|
|
1579
|
+
var pairs=[
|
|
1580
|
+
{a:false,b:true,col:C.m2o,l:'many2one (N:1)'},
|
|
1581
|
+
{a:false,b:false,col:C.o2o,l:'one2one (1:1)'},
|
|
1582
|
+
{a:false,b:true,col:C.m2m,l:'pivot edge (1:N)'}
|
|
1583
|
+
];
|
|
1584
|
+
pairs.forEach(function(p){
|
|
1585
|
+
ctx.strokeStyle=p.col+'55';ctx.lineWidth=1.2;ctx.setLineDash(p.col===C.m2m?[4,3]:[]);
|
|
1586
|
+
ctx.beginPath();ctx.moveTo(x+8,y2);ctx.lineTo(x+56,y2);ctx.stroke();
|
|
1587
|
+
ctx.setLineDash([]);
|
|
1588
|
+
badge2(x+8,y2,p.a,p.col);
|
|
1589
|
+
badge2(x+56,y2,p.b,p.col);
|
|
1590
|
+
ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1591
|
+
ctx.fillText(p.l,x+66,y2);
|
|
1592
|
+
x+=66+ctx.measureText(p.l).width+24;
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
ctx.fillStyle='#3d444d';ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
|
|
1596
|
+
ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y2);
|
|
1597
|
+
ctx.textBaseline='alphabetic';
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function draw(){
|
|
1601
|
+
var W=cv.width/dpr,H=cv.height/dpr;
|
|
1602
|
+
ctx.setTransform(dpr,0,0,dpr,0,0);
|
|
1603
|
+
ctx.clearRect(0,0,W,H);
|
|
1604
|
+
ctx.fillStyle=C.bg;ctx.fillRect(0,0,W,H);
|
|
1605
|
+
ctx.save();ctx.translate(pan.x,pan.y);ctx.scale(zm,zm);
|
|
1606
|
+
badges=[];
|
|
1607
|
+
var edges=collectEdges();
|
|
1608
|
+
edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.manyA,e.manyB,e.off);});
|
|
1609
|
+
nodes.forEach(function(n){drawNode(n);});
|
|
1610
|
+
badges.forEach(function(b){drawBadge(b);});
|
|
1611
|
+
ctx.restore();
|
|
1612
|
+
drawLegend();
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// \u2500\u2500 Interaction \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
|
|
1616
|
+
var drag=null,dox=0,doy=0,isPan=false,ps={x:0,y:0};
|
|
1617
|
+
|
|
1618
|
+
function toW(mx,my){return{x:(mx-pan.x)/zm,y:(my-pan.y)/zm};}
|
|
1619
|
+
function hitTest(wx,wy){
|
|
1620
|
+
for(var i=nodes.length-1;i>=0;i--){var n=nodes[i];if(wx>=n.x&&wx<=n.x+n.w&&wy>=n.y&&wy<=n.y+n.h)return n;}
|
|
1621
|
+
return null;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
cv.addEventListener('mousedown',function(ev){
|
|
1625
|
+
var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
|
|
1626
|
+
var w=toW(mx,my),h=hitTest(w.x,w.y);
|
|
1627
|
+
if(h){drag=h;dox=w.x-h.x;doy=w.y-h.y;}else{isPan=true;ps={x:mx-pan.x,y:my-pan.y};}
|
|
1628
|
+
cv.style.cursor='grabbing';
|
|
1629
|
+
});
|
|
1630
|
+
window.addEventListener('mousemove',function(ev){
|
|
1631
|
+
if(!drag&&!isPan)return;
|
|
1632
|
+
var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
|
|
1633
|
+
if(drag){var w=toW(mx,my);drag.x=w.x-dox;drag.y=w.y-doy;}
|
|
1634
|
+
else{pan.x=mx-ps.x;pan.y=my-ps.y;}
|
|
1635
|
+
draw();
|
|
1636
|
+
});
|
|
1637
|
+
window.addEventListener('mouseup',function(){drag=null;isPan=false;cv.style.cursor='grab';});
|
|
1638
|
+
cv.addEventListener('wheel',function(ev){
|
|
1639
|
+
ev.preventDefault();
|
|
1640
|
+
var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
|
|
1641
|
+
var d=ev.deltaY<0?1.1:0.91,nz=Math.max(0.25,Math.min(3,zm*d));
|
|
1642
|
+
pan.x=mx-(mx-pan.x)*(nz/zm);pan.y=my-(my-pan.y)*(nz/zm);zm=nz;draw();
|
|
1643
|
+
},{passive:false});
|
|
1644
|
+
cv.addEventListener('dblclick',function(){
|
|
1645
|
+
nodes.forEach(function(n,i){n.x=origPos[i].x;n.y=origPos[i].y;});
|
|
1646
|
+
var r=cv.getBoundingClientRect();fitView(r.width,r.height);draw();
|
|
1647
|
+
});
|
|
1648
|
+
window.addEventListener('resize',function(){
|
|
1649
|
+
resize();var r=cv.getBoundingClientRect();
|
|
1650
|
+
computeLayout(r.width,r.height);savePos();fitView(r.width,r.height);draw();
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
document.getElementById('er-dl').addEventListener('click',function(){
|
|
1654
|
+
var SCALE=3;
|
|
1655
|
+
var cw=cv.width/dpr,ch=cv.height/dpr;
|
|
1656
|
+
var oc=document.createElement('canvas');
|
|
1657
|
+
oc.width=Math.round(cw*SCALE);oc.height=Math.round(ch*SCALE);
|
|
1658
|
+
var sav={cv:cv,ctx:ctx,dpr:dpr};
|
|
1659
|
+
cv=oc;ctx=oc.getContext('2d');dpr=SCALE;
|
|
1660
|
+
draw();
|
|
1661
|
+
cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;
|
|
1662
|
+
var a=document.createElement('a');
|
|
1663
|
+
a.download='er-diagram.png';a.href=oc.toDataURL('image/png');a.click();
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
// \u2500\u2500 Init \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\u2500\u2500\u2500\u2500
|
|
1667
|
+
requestAnimationFrame(function(){
|
|
1668
|
+
resize();
|
|
1669
|
+
var r=cv.getBoundingClientRect();
|
|
1670
|
+
computeLayout(r.width,r.height);
|
|
1671
|
+
var h=requiredH();
|
|
1672
|
+
resize(h);r=cv.getBoundingClientRect();
|
|
1673
|
+
computeLayout(r.width,r.height);
|
|
1674
|
+
savePos();fitView(r.width,r.height);draw();
|
|
1675
|
+
});
|
|
1676
|
+
})();
|
|
1677
|
+
`}</script>`
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
986
1681
|
// src/router/templates/about.template.ts
|
|
987
1682
|
var _dir = dirname2(fileURLToPath(import.meta.url));
|
|
988
1683
|
var LOGO_SRC = (() => {
|
|
989
1684
|
try {
|
|
990
|
-
const buf = readFileSync2(join(_dir, "../../assets/
|
|
991
|
-
return `data:image/
|
|
1685
|
+
const buf = readFileSync2(join(_dir, "../../assets/yrest-lockup.svg"));
|
|
1686
|
+
return `data:image/svg+xml;base64,${buf.toString("base64")}`;
|
|
992
1687
|
} catch {
|
|
993
1688
|
return "";
|
|
994
1689
|
}
|
|
@@ -999,6 +1694,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
999
1694
|
const customRoutes = storage.getRoutes();
|
|
1000
1695
|
const base = options.base;
|
|
1001
1696
|
const host = `http://${options.host}:${options.port}`;
|
|
1697
|
+
const swaggerUrl = `https://petstore.swagger.io/?url=${encodeURIComponent(`${host}/_openapi.json`)}`;
|
|
1002
1698
|
const modes = [];
|
|
1003
1699
|
if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
|
|
1004
1700
|
if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
|
|
@@ -1009,9 +1705,12 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1009
1705
|
if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
|
|
1010
1706
|
if (options.idStrategy !== "increment")
|
|
1011
1707
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
1708
|
+
const erDiagram = erDiagramSection(generateERData(storage));
|
|
1012
1709
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
1013
1710
|
const paginationDesc = options.pageable.enabled ? `Pageable mode active \u2014 default limit <code>${options.pageable.limit}</code>. Response wrapped in <code>{ data, pagination }</code>.` : `Returns the requested slice. <code>X-Total-Count</code> header reflects the total before pagination.`;
|
|
1014
|
-
return
|
|
1711
|
+
return (
|
|
1712
|
+
/*html*/
|
|
1713
|
+
`<!DOCTYPE html>
|
|
1015
1714
|
<html lang="en">
|
|
1016
1715
|
<head>
|
|
1017
1716
|
<meta charset="UTF-8">
|
|
@@ -1038,20 +1737,30 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1038
1737
|
width: 100%;
|
|
1039
1738
|
background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
|
|
1040
1739
|
border-bottom: 1px solid var(--border);
|
|
1041
|
-
padding:
|
|
1740
|
+
padding: 20px 32px 16px;
|
|
1042
1741
|
}
|
|
1043
|
-
.banner-inner {
|
|
1044
|
-
.banner
|
|
1742
|
+
.banner-inner { margin: 0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
|
|
1743
|
+
.banner-left { flex:1; min-width:0; }
|
|
1744
|
+
.banner h1 { font-size: clamp(24px, 4vw, 40px); font-weight: 800; letter-spacing: -1.5px; line-height: 1; }
|
|
1045
1745
|
.banner h1 .y { color: var(--text); }
|
|
1046
1746
|
.banner h1 .rest { color: var(--accent); }
|
|
1047
|
-
.banner p { color: var(--text-muted); margin-top:
|
|
1048
|
-
.banner-meta { display: flex; gap:
|
|
1049
|
-
.banner-meta span { color: var(--text-muted); font-size:
|
|
1747
|
+
.banner p { color: var(--text-muted); margin-top: 6px; font-size: 14px; }
|
|
1748
|
+
.banner-meta { display: flex; gap: 20px; margin-top: 10px; flex-wrap: wrap; }
|
|
1749
|
+
.banner-meta span { color: var(--text-muted); font-size: 12px; }
|
|
1050
1750
|
.banner-meta span strong { color: var(--text); font-family: monospace; }
|
|
1751
|
+
.api-links { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
|
1752
|
+
.btn-api { display:inline-block; padding:5px 12px; border-radius:6px; border:1px solid var(--border); color:var(--text-muted); text-decoration:none; font-size:12px; font-weight:500; background:var(--bg-card); white-space:nowrap; transition:border-color .15s,color .15s; }
|
|
1753
|
+
.btn-api:hover { border-color:var(--border-hi); color:var(--text); }
|
|
1754
|
+
.btn-swagger { border-color:var(--accent); color:var(--accent); }
|
|
1755
|
+
.btn-swagger:hover { background:#58a6ff18; }
|
|
1051
1756
|
|
|
1052
1757
|
/* \u2500\u2500 Layout \u2500\u2500 */
|
|
1053
|
-
.wrap {
|
|
1758
|
+
.wrap { padding: 24px 32px 48px; }
|
|
1054
1759
|
h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
|
|
1760
|
+
.about-grid { display:grid; grid-template-columns:5fr 7fr; gap:28px; align-items:start; }
|
|
1761
|
+
@media(max-width:960px) { .about-grid { grid-template-columns:1fr; } }
|
|
1762
|
+
.col-left, .col-right { min-width:0; }
|
|
1763
|
+
.col-left > h2:first-child, .col-right > h2:first-child { margin-top:0; }
|
|
1055
1764
|
|
|
1056
1765
|
/* \u2500\u2500 Cards \u2500\u2500 */
|
|
1057
1766
|
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
|
|
@@ -1067,10 +1776,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1067
1776
|
.modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
|
|
1068
1777
|
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
|
1069
1778
|
|
|
1070
|
-
/* \u2500\u2500 Endpoints
|
|
1071
|
-
.endpoints-grid { display:
|
|
1072
|
-
@media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
|
|
1073
|
-
.nested-card { grid-column: 1 / -1; }
|
|
1779
|
+
/* \u2500\u2500 Endpoints list \u2500\u2500 */
|
|
1780
|
+
.endpoints-grid { display: flex; flex-direction: column; gap: 10px; }
|
|
1074
1781
|
|
|
1075
1782
|
/* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
|
|
1076
1783
|
.resource-card {
|
|
@@ -1135,64 +1842,80 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1135
1842
|
|
|
1136
1843
|
<div class="banner">
|
|
1137
1844
|
<div class="banner-inner">
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
<
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1845
|
+
<div class="banner-left">
|
|
1846
|
+
${LOGO_SRC ? `<img src="${LOGO_SRC}" alt="yRest" height="36" style="display:block;margin-bottom:4px" />` : `<h1><span class="y">y</span><span class="rest">Rest</span></h1>`}
|
|
1847
|
+
<p>Zero-config REST API mock server</p>
|
|
1848
|
+
<div class="banner-meta">
|
|
1849
|
+
<span>URL <strong>${host}</strong></span>
|
|
1850
|
+
<span>Base <strong>${base || "/"}</strong></span>
|
|
1851
|
+
<span>File <strong>${options.file}</strong></span>
|
|
1852
|
+
<span>Collections <strong>${collections.length}</strong></span>
|
|
1853
|
+
</div>
|
|
1854
|
+
</div>
|
|
1855
|
+
<div class="api-links">
|
|
1856
|
+
<a href="/_openapi" target="_blank" class="btn-api">OpenAPI YAML</a>
|
|
1857
|
+
<a href="/_openapi.json" target="_blank" class="btn-api">OpenAPI JSON</a>
|
|
1858
|
+
<a href="${swaggerUrl}" target="_blank" class="btn-api btn-swagger">Swagger UI \u2197</a>
|
|
1145
1859
|
</div>
|
|
1146
1860
|
</div>
|
|
1147
1861
|
</div>
|
|
1148
1862
|
|
|
1149
1863
|
<div class="wrap">
|
|
1864
|
+
<div class="about-grid">
|
|
1865
|
+
|
|
1866
|
+
<div class="col-left">
|
|
1867
|
+
<h2>Active Modes</h2>
|
|
1868
|
+
<div class="card">
|
|
1869
|
+
<div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
|
|
1870
|
+
${options.watch ? `<div class="warn">\u26A0 <strong>Watch mode:</strong> data changes in existing collections reload automatically. Adding or removing entire collections requires a server restart.</div>` : ""}
|
|
1871
|
+
</div>
|
|
1872
|
+
|
|
1873
|
+
<h2>Endpoints</h2>
|
|
1874
|
+
<div class="endpoints-grid">
|
|
1875
|
+
${accordions}
|
|
1876
|
+
${nestedRoutesAccordion(relations, base)}
|
|
1877
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
1878
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1879
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
1880
|
+
</div>
|
|
1881
|
+
|
|
1882
|
+
<h2>Query Parameters</h2>
|
|
1883
|
+
<div class="card">
|
|
1884
|
+
<table class="param-table">
|
|
1885
|
+
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
1886
|
+
<tbody>
|
|
1887
|
+
<tr><td><code>?field=value</code></td><td><code>?name=Ana&role=admin</code></td><td>Filter by any field. Multiple params are ANDed.</td></tr>
|
|
1888
|
+
<tr><td><code>?field_gte / _lte</code></td><td><code>?price_gte=10&price_lte=50</code></td><td>Numeric or lexicographic range. Works with any comparable field.</td></tr>
|
|
1889
|
+
<tr><td><code>?field_ne</code></td><td><code>?status_ne=inactive</code></td><td>Exclude items where the field equals the value.</td></tr>
|
|
1890
|
+
<tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
|
|
1891
|
+
<tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
|
|
1892
|
+
<tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
|
|
1893
|
+
<tr><td><code>?_q</code></td><td><code>?_q=ana</code></td><td>Full-text search across all scalar fields (case-insensitive substring).</td></tr>
|
|
1894
|
+
<tr><td><code>?_sort & ?_order</code></td><td><code>?_sort=name&_order=desc</code></td><td>Sort by field. <code>_order</code>: <code>asc</code> (default) or <code>desc</code>.</td></tr>
|
|
1895
|
+
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
1896
|
+
<tr><td><code>?_expand</code></td><td><code>?_expand=user</code></td><td>Embed a related parent object inline. Requires <code>_rel</code> in the YAML file.</td></tr>
|
|
1897
|
+
<tr><td><code>?_embed</code></td><td><code>?_embed=posts</code></td><td>Embed child collections into each parent item. Requires <code>_rel</code> in the YAML file.</td></tr>
|
|
1898
|
+
<tr><td><code>?_fields</code></td><td><code>?_fields=id,name</code></td><td>Return only the specified fields. Applied last \u2014 can include embedded/expanded keys.</td></tr>
|
|
1899
|
+
</tbody>
|
|
1900
|
+
</table>
|
|
1901
|
+
</div>
|
|
1902
|
+
</div>
|
|
1150
1903
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
</div>
|
|
1156
|
-
|
|
1157
|
-
<h2>Endpoints</h2>
|
|
1158
|
-
<div class="endpoints-grid">
|
|
1159
|
-
${accordions}
|
|
1160
|
-
${nestedRoutesAccordion(relations, base)}
|
|
1161
|
-
${options.snapshot ? snapshotAccordion() : ""}
|
|
1162
|
-
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1163
|
-
${handlersAccordion(handlers, customRoutes, base)}
|
|
1164
|
-
</div>
|
|
1904
|
+
<div class="col-right">
|
|
1905
|
+
${erDiagram}
|
|
1906
|
+
${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
|
|
1907
|
+
</div>
|
|
1165
1908
|
|
|
1166
|
-
<h2>Query Parameters</h2>
|
|
1167
|
-
<div class="card">
|
|
1168
|
-
<table class="param-table">
|
|
1169
|
-
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
1170
|
-
<tbody>
|
|
1171
|
-
<tr><td><code>?field=value</code></td><td><code>?name=Ana&role=admin</code></td><td>Filter by any field. Multiple params are ANDed.</td></tr>
|
|
1172
|
-
<tr><td><code>?field_gte / _lte</code></td><td><code>?price_gte=10&price_lte=50</code></td><td>Numeric or lexicographic range. Works with any comparable field.</td></tr>
|
|
1173
|
-
<tr><td><code>?field_ne</code></td><td><code>?status_ne=inactive</code></td><td>Exclude items where the field equals the value.</td></tr>
|
|
1174
|
-
<tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
|
|
1175
|
-
<tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
|
|
1176
|
-
<tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
|
|
1177
|
-
<tr><td><code>?_q</code></td><td><code>?_q=ana</code></td><td>Full-text search across all scalar fields (case-insensitive substring).</td></tr>
|
|
1178
|
-
<tr><td><code>?_sort & ?_order</code></td><td><code>?_sort=name&_order=desc</code></td><td>Sort by field. <code>_order</code>: <code>asc</code> (default) or <code>desc</code>.</td></tr>
|
|
1179
|
-
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
1180
|
-
<tr><td><code>?_expand</code></td><td><code>?_expand=user</code></td><td>Embed a related parent object inline. Requires <code>_rel</code> in the YAML file.</td></tr>
|
|
1181
|
-
<tr><td><code>?_embed</code></td><td><code>?_embed=posts</code></td><td>Embed child collections into each parent item. Requires <code>_rel</code> in the YAML file.</td></tr>
|
|
1182
|
-
<tr><td><code>?_fields</code></td><td><code>?_fields=id,name</code></td><td>Return only the specified fields. Applied last \u2014 can include embedded/expanded keys.</td></tr>
|
|
1183
|
-
</tbody>
|
|
1184
|
-
</table>
|
|
1185
1909
|
</div>
|
|
1186
1910
|
|
|
1187
|
-
${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
|
|
1188
|
-
|
|
1189
1911
|
<footer>
|
|
1190
1912
|
Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> \xB7 <a href="/_about">/_about</a>
|
|
1191
1913
|
</footer>
|
|
1192
1914
|
|
|
1193
1915
|
</div>
|
|
1194
1916
|
</body>
|
|
1195
|
-
</html
|
|
1917
|
+
</html>`
|
|
1918
|
+
);
|
|
1196
1919
|
}
|
|
1197
1920
|
|
|
1198
1921
|
// src/router/routes/about.routes.ts
|
|
@@ -1656,7 +2379,7 @@ var CustomRouteCommand = class {
|
|
|
1656
2379
|
url,
|
|
1657
2380
|
handler: async (req, reply) => {
|
|
1658
2381
|
if (route.delay && route.delay > 0) {
|
|
1659
|
-
await new Promise((
|
|
2382
|
+
await new Promise((resolve6) => setTimeout(resolve6, route.delay));
|
|
1660
2383
|
}
|
|
1661
2384
|
if (route.error) {
|
|
1662
2385
|
const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
|
|
@@ -1780,12 +2503,23 @@ var NestedRouteCommand = class {
|
|
|
1780
2503
|
relations;
|
|
1781
2504
|
base;
|
|
1782
2505
|
register(server) {
|
|
2506
|
+
const registered = /* @__PURE__ */ new Set();
|
|
1783
2507
|
for (const [source, fields] of Object.entries(this.relations)) {
|
|
1784
2508
|
for (const [key, def] of Object.entries(fields)) {
|
|
1785
2509
|
if (def.type === "many2many") {
|
|
1786
|
-
this.
|
|
2510
|
+
const fwd = `${this.base}/${source}/:id/${key}`;
|
|
2511
|
+
const inv = `${this.base}/${def.target}/:id/${source}`;
|
|
2512
|
+
if (!registered.has(fwd) && !registered.has(inv)) {
|
|
2513
|
+
registered.add(fwd);
|
|
2514
|
+
registered.add(inv);
|
|
2515
|
+
this.registerMany2Many(server, source, key, def);
|
|
2516
|
+
}
|
|
1787
2517
|
} else {
|
|
1788
|
-
|
|
2518
|
+
const collPath = `${this.base}/${def.target}/:id/${source}`;
|
|
2519
|
+
if (!registered.has(collPath)) {
|
|
2520
|
+
registered.add(collPath);
|
|
2521
|
+
this.registerFkRelation(server, source, key, def.target, def.type);
|
|
2522
|
+
}
|
|
1789
2523
|
}
|
|
1790
2524
|
}
|
|
1791
2525
|
}
|
|
@@ -1844,6 +2578,371 @@ var NestedRouteCommand = class {
|
|
|
1844
2578
|
}
|
|
1845
2579
|
};
|
|
1846
2580
|
|
|
2581
|
+
// src/openapi/inferSchema.ts
|
|
2582
|
+
function buildCollectionSchema(items, fieldDefs = {}) {
|
|
2583
|
+
const sample = items.slice(0, 10);
|
|
2584
|
+
const inferredTypes = /* @__PURE__ */ new Map();
|
|
2585
|
+
for (const item of sample) {
|
|
2586
|
+
for (const [key, value] of Object.entries(item)) {
|
|
2587
|
+
if (!inferredTypes.has(key)) {
|
|
2588
|
+
inferredTypes.set(key, jsToOpenApiType(value));
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
|
|
2593
|
+
const properties = {};
|
|
2594
|
+
const required = [];
|
|
2595
|
+
for (const field of allFields) {
|
|
2596
|
+
const def = fieldDefs[field];
|
|
2597
|
+
const inferred = inferredTypes.get(field) ?? "string";
|
|
2598
|
+
const prop = {
|
|
2599
|
+
type: def?.type ?? inferred
|
|
2600
|
+
};
|
|
2601
|
+
if (def?.format) prop.format = def.format;
|
|
2602
|
+
if (def?.description) prop.description = def.description;
|
|
2603
|
+
if (def?.enum) prop.enum = def.enum;
|
|
2604
|
+
if (def?.default !== void 0) prop.default = def.default;
|
|
2605
|
+
properties[field] = prop;
|
|
2606
|
+
if (def?.required === true) required.push(field);
|
|
2607
|
+
}
|
|
2608
|
+
const schema = { type: "object", properties };
|
|
2609
|
+
if (required.length > 0) schema.required = required;
|
|
2610
|
+
return schema;
|
|
2611
|
+
}
|
|
2612
|
+
function jsToOpenApiType(value) {
|
|
2613
|
+
if (value === null || value === void 0) return "string";
|
|
2614
|
+
if (typeof value === "boolean") return "boolean";
|
|
2615
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
2616
|
+
if (Array.isArray(value)) return "array";
|
|
2617
|
+
if (typeof value === "object") return "object";
|
|
2618
|
+
return "string";
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
// src/openapi/buildPaths.ts
|
|
2622
|
+
var COLLECTION_QUERY_PARAMS = [
|
|
2623
|
+
{ name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
|
|
2624
|
+
{ name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
2625
|
+
{ name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
|
|
2626
|
+
{
|
|
2627
|
+
name: "_order",
|
|
2628
|
+
in: "query",
|
|
2629
|
+
schema: { type: "string", enum: ["asc", "desc"] },
|
|
2630
|
+
description: "Sort direction"
|
|
2631
|
+
},
|
|
2632
|
+
{
|
|
2633
|
+
name: "_q",
|
|
2634
|
+
in: "query",
|
|
2635
|
+
schema: { type: "string" },
|
|
2636
|
+
description: "Full-text search across all scalar fields (case-insensitive)"
|
|
2637
|
+
},
|
|
2638
|
+
{
|
|
2639
|
+
name: "_expand",
|
|
2640
|
+
in: "query",
|
|
2641
|
+
schema: { type: "string" },
|
|
2642
|
+
description: "Embed related parent object inline (e.g. ?_expand=user)"
|
|
2643
|
+
},
|
|
2644
|
+
{
|
|
2645
|
+
name: "_embed",
|
|
2646
|
+
in: "query",
|
|
2647
|
+
schema: { type: "string" },
|
|
2648
|
+
description: "Embed child collection into each item (e.g. ?_embed=posts)"
|
|
2649
|
+
},
|
|
2650
|
+
{
|
|
2651
|
+
name: "_fields",
|
|
2652
|
+
in: "query",
|
|
2653
|
+
schema: { type: "string" },
|
|
2654
|
+
description: "Comma-separated field projection (e.g. ?_fields=id,name)"
|
|
2655
|
+
}
|
|
2656
|
+
];
|
|
2657
|
+
var ID_PATH_PARAM = {
|
|
2658
|
+
name: "id",
|
|
2659
|
+
in: "path",
|
|
2660
|
+
required: true,
|
|
2661
|
+
schema: { type: "string" },
|
|
2662
|
+
description: "Item id"
|
|
2663
|
+
};
|
|
2664
|
+
function toOpenApiPath(fastifyPath) {
|
|
2665
|
+
return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
2666
|
+
}
|
|
2667
|
+
function extractPathParams(fastifyPath) {
|
|
2668
|
+
const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
|
|
2669
|
+
return matches.map((m) => ({
|
|
2670
|
+
name: m.slice(1),
|
|
2671
|
+
in: "path",
|
|
2672
|
+
required: true,
|
|
2673
|
+
schema: { type: "string" }
|
|
2674
|
+
}));
|
|
2675
|
+
}
|
|
2676
|
+
function singular(name) {
|
|
2677
|
+
return name.endsWith("s") ? name.slice(0, -1) : name;
|
|
2678
|
+
}
|
|
2679
|
+
function schemaRef(name) {
|
|
2680
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
2681
|
+
}
|
|
2682
|
+
function jsonContent(schema) {
|
|
2683
|
+
return { "application/json": { schema } };
|
|
2684
|
+
}
|
|
2685
|
+
function ok(schema, description = "OK") {
|
|
2686
|
+
return { description, content: jsonContent(schema) };
|
|
2687
|
+
}
|
|
2688
|
+
function buildCrudPaths(collection, base, schemaName) {
|
|
2689
|
+
const ref = schemaRef(schemaName);
|
|
2690
|
+
const tag = collection;
|
|
2691
|
+
const sing = singular(collection);
|
|
2692
|
+
const collPath = `${base}/${collection}`;
|
|
2693
|
+
const itemPath = `${base}/${collection}/{id}`;
|
|
2694
|
+
return {
|
|
2695
|
+
[collPath]: {
|
|
2696
|
+
get: {
|
|
2697
|
+
summary: `List ${collection}`,
|
|
2698
|
+
tags: [tag],
|
|
2699
|
+
parameters: COLLECTION_QUERY_PARAMS,
|
|
2700
|
+
responses: {
|
|
2701
|
+
"200": {
|
|
2702
|
+
description: "OK",
|
|
2703
|
+
content: jsonContent({ type: "array", items: ref }),
|
|
2704
|
+
headers: {
|
|
2705
|
+
"X-Total-Count": {
|
|
2706
|
+
description: "Total items (when using ?_page / ?_limit)",
|
|
2707
|
+
schema: { type: "integer" }
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
},
|
|
2713
|
+
post: {
|
|
2714
|
+
summary: `Create ${sing}`,
|
|
2715
|
+
tags: [tag],
|
|
2716
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2717
|
+
responses: { "201": ok(ref, "Created") }
|
|
2718
|
+
}
|
|
2719
|
+
},
|
|
2720
|
+
[itemPath]: {
|
|
2721
|
+
get: {
|
|
2722
|
+
summary: `Get ${sing}`,
|
|
2723
|
+
tags: [tag],
|
|
2724
|
+
parameters: [
|
|
2725
|
+
ID_PATH_PARAM,
|
|
2726
|
+
...COLLECTION_QUERY_PARAMS.filter(
|
|
2727
|
+
(p) => ["_expand", "_embed", "_fields"].includes(p.name)
|
|
2728
|
+
)
|
|
2729
|
+
],
|
|
2730
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2731
|
+
},
|
|
2732
|
+
put: {
|
|
2733
|
+
summary: `Replace ${sing}`,
|
|
2734
|
+
tags: [tag],
|
|
2735
|
+
parameters: [ID_PATH_PARAM],
|
|
2736
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2737
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2738
|
+
},
|
|
2739
|
+
patch: {
|
|
2740
|
+
summary: `Update ${sing}`,
|
|
2741
|
+
tags: [tag],
|
|
2742
|
+
parameters: [ID_PATH_PARAM],
|
|
2743
|
+
requestBody: { required: false, content: jsonContent(ref) },
|
|
2744
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2745
|
+
},
|
|
2746
|
+
delete: {
|
|
2747
|
+
summary: `Delete ${sing}`,
|
|
2748
|
+
tags: [tag],
|
|
2749
|
+
parameters: [ID_PATH_PARAM],
|
|
2750
|
+
responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
function buildRelationPaths(relations, base) {
|
|
2756
|
+
const paths = {};
|
|
2757
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
2758
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
2759
|
+
if (def.type === "many2many") {
|
|
2760
|
+
const forwardPath = `${base}/${source}/{id}/${key}`;
|
|
2761
|
+
const inversePath = `${base}/${def.target}/{id}/${source}`;
|
|
2762
|
+
paths[forwardPath] = {
|
|
2763
|
+
get: {
|
|
2764
|
+
summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
|
|
2765
|
+
tags: [source],
|
|
2766
|
+
parameters: [ID_PATH_PARAM],
|
|
2767
|
+
responses: {
|
|
2768
|
+
"200": {
|
|
2769
|
+
description: "OK",
|
|
2770
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2771
|
+
},
|
|
2772
|
+
"404": { description: `${singular(source)} not found` }
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
};
|
|
2776
|
+
paths[inversePath] = {
|
|
2777
|
+
get: {
|
|
2778
|
+
summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
|
|
2779
|
+
tags: [def.target],
|
|
2780
|
+
parameters: [ID_PATH_PARAM],
|
|
2781
|
+
responses: {
|
|
2782
|
+
"200": {
|
|
2783
|
+
description: "OK",
|
|
2784
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2785
|
+
},
|
|
2786
|
+
"404": { description: `${singular(def.target)} not found` }
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
} else {
|
|
2791
|
+
const parentSing = singular(def.target);
|
|
2792
|
+
const collPath = `${base}/${def.target}/{id}/${source}`;
|
|
2793
|
+
const isOne2One = def.type === "one2one";
|
|
2794
|
+
const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
|
|
2795
|
+
paths[collPath] = {
|
|
2796
|
+
get: {
|
|
2797
|
+
summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
|
|
2798
|
+
tags: [def.target],
|
|
2799
|
+
parameters: [ID_PATH_PARAM],
|
|
2800
|
+
responses: {
|
|
2801
|
+
"200": { description: "OK", content: jsonContent(responseSchema) },
|
|
2802
|
+
"404": { description: `${parentSing} not found` }
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
};
|
|
2806
|
+
if (!isOne2One) {
|
|
2807
|
+
const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
|
|
2808
|
+
paths[itemPath] = {
|
|
2809
|
+
get: {
|
|
2810
|
+
summary: `Get single ${singular(source)} scoped to ${parentSing}`,
|
|
2811
|
+
tags: [def.target],
|
|
2812
|
+
parameters: [
|
|
2813
|
+
ID_PATH_PARAM,
|
|
2814
|
+
{ name: "childId", in: "path", required: true, schema: { type: "string" } }
|
|
2815
|
+
],
|
|
2816
|
+
responses: {
|
|
2817
|
+
"200": { description: "OK", content: jsonContent({ type: "object" }) },
|
|
2818
|
+
"404": { description: "Not found" }
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
return paths;
|
|
2827
|
+
}
|
|
2828
|
+
function buildCustomRoutePaths(routes, base) {
|
|
2829
|
+
const paths = {};
|
|
2830
|
+
for (const route of routes) {
|
|
2831
|
+
const openApiPath = toOpenApiPath(`${base}${route.path}`);
|
|
2832
|
+
const method = route.method.toLowerCase();
|
|
2833
|
+
const pathParams = extractPathParams(route.path);
|
|
2834
|
+
const responses = {};
|
|
2835
|
+
if (route.error) {
|
|
2836
|
+
responses[String(route.error)] = { description: `Forced error ${route.error}` };
|
|
2837
|
+
} else {
|
|
2838
|
+
const statuses = /* @__PURE__ */ new Set();
|
|
2839
|
+
for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
|
|
2840
|
+
if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
|
|
2841
|
+
if (route.response) statuses.add(route.response.status ?? 200);
|
|
2842
|
+
if (statuses.size === 0) statuses.add(200);
|
|
2843
|
+
for (const status of statuses) {
|
|
2844
|
+
const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
|
|
2845
|
+
responses[String(status)] = {
|
|
2846
|
+
description: status < 400 ? "OK" : "Error",
|
|
2847
|
+
...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
|
|
2852
|
+
const operation = {
|
|
2853
|
+
summary: `${route.method.toUpperCase()} ${route.path}`,
|
|
2854
|
+
description: desc,
|
|
2855
|
+
tags: ["custom"],
|
|
2856
|
+
...pathParams.length > 0 ? { parameters: pathParams } : {},
|
|
2857
|
+
responses
|
|
2858
|
+
};
|
|
2859
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
2860
|
+
paths[openApiPath][method] = operation;
|
|
2861
|
+
}
|
|
2862
|
+
return paths;
|
|
2863
|
+
}
|
|
2864
|
+
function inferResponseSchema(body) {
|
|
2865
|
+
if (body === null || body === void 0) return {};
|
|
2866
|
+
if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
|
|
2867
|
+
const properties = {};
|
|
2868
|
+
for (const [key, value] of Object.entries(body)) {
|
|
2869
|
+
properties[key] = { type: jsToOpenApiType2(value) };
|
|
2870
|
+
}
|
|
2871
|
+
return { type: "object", properties };
|
|
2872
|
+
}
|
|
2873
|
+
function jsToOpenApiType2(value) {
|
|
2874
|
+
if (value === null || value === void 0) return "string";
|
|
2875
|
+
if (typeof value === "boolean") return "boolean";
|
|
2876
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
2877
|
+
if (Array.isArray(value)) return "array";
|
|
2878
|
+
if (typeof value === "object") return "object";
|
|
2879
|
+
return "string";
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// src/openapi/generateOpenApi.ts
|
|
2883
|
+
function generateOpenApi(storage, options, title = "yRest API") {
|
|
2884
|
+
const collections = Object.keys(storage.getData());
|
|
2885
|
+
const relations = storage.getRelations();
|
|
2886
|
+
const schemaBlock = storage.getSchema();
|
|
2887
|
+
const customRoutes = storage.getRoutes();
|
|
2888
|
+
const base = options.base ?? "";
|
|
2889
|
+
const schemas = {};
|
|
2890
|
+
for (const collection of collections) {
|
|
2891
|
+
const items = storage.getCollection(collection) ?? [];
|
|
2892
|
+
const fieldDefs = schemaBlock[collection] ?? {};
|
|
2893
|
+
const schemaName = toSchemaName(collection);
|
|
2894
|
+
schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
|
|
2895
|
+
}
|
|
2896
|
+
const paths = {};
|
|
2897
|
+
for (const collection of collections) {
|
|
2898
|
+
const schemaName = toSchemaName(collection);
|
|
2899
|
+
Object.assign(paths, buildCrudPaths(collection, base, schemaName));
|
|
2900
|
+
}
|
|
2901
|
+
Object.assign(paths, buildRelationPaths(relations, base));
|
|
2902
|
+
Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
|
|
2903
|
+
return {
|
|
2904
|
+
openapi: "3.0.3",
|
|
2905
|
+
info: {
|
|
2906
|
+
title,
|
|
2907
|
+
version: "1.0.0",
|
|
2908
|
+
description: "Generated by yRest from db.yml"
|
|
2909
|
+
},
|
|
2910
|
+
servers: [
|
|
2911
|
+
{
|
|
2912
|
+
url: `http://${options.host}:${options.port}${base}`,
|
|
2913
|
+
description: "yRest mock server"
|
|
2914
|
+
}
|
|
2915
|
+
],
|
|
2916
|
+
paths,
|
|
2917
|
+
components: { schemas }
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
function toSchemaName(collection) {
|
|
2921
|
+
const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
|
|
2922
|
+
return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
// src/router/routes/openapi.routes.ts
|
|
2926
|
+
import { stringify as stringify2 } from "yaml";
|
|
2927
|
+
var OpenApiRouteCommand = class {
|
|
2928
|
+
constructor(storage, options) {
|
|
2929
|
+
this.storage = storage;
|
|
2930
|
+
this.options = options;
|
|
2931
|
+
}
|
|
2932
|
+
storage;
|
|
2933
|
+
options;
|
|
2934
|
+
register(server) {
|
|
2935
|
+
server.get("/_openapi", (_req, reply) => {
|
|
2936
|
+
const doc = generateOpenApi(this.storage, this.options);
|
|
2937
|
+
reply.header("Content-Type", "text/yaml; charset=utf-8");
|
|
2938
|
+
return reply.send(stringify2(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
|
|
2939
|
+
});
|
|
2940
|
+
server.get("/_openapi.json", (_req, reply) => {
|
|
2941
|
+
return reply.send(generateOpenApi(this.storage, this.options));
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
};
|
|
2945
|
+
|
|
1847
2946
|
// src/router/routes/snapshot.routes.ts
|
|
1848
2947
|
var SnapshotRouteCommand = class {
|
|
1849
2948
|
constructor(storage) {
|
|
@@ -1924,6 +3023,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
1924
3023
|
}
|
|
1925
3024
|
const commands = [
|
|
1926
3025
|
new AboutRouteCommand(storage, options, handlers),
|
|
3026
|
+
new OpenApiRouteCommand(storage, options),
|
|
1927
3027
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1928
3028
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
1929
3029
|
...buildResourceRouteCommands(storage, options)
|
|
@@ -2168,7 +3268,7 @@ function registerServe(program2) {
|
|
|
2168
3268
|
// src/cli/commands/handler.ts
|
|
2169
3269
|
import { existsSync as existsSync4, readFileSync as readFileSync4, appendFileSync, writeFileSync as writeFileSync3 } from "fs";
|
|
2170
3270
|
import { join as join3, resolve as resolve4, basename } from "path";
|
|
2171
|
-
import { parse as parse3, stringify as
|
|
3271
|
+
import { parse as parse3, stringify as stringify3 } from "yaml";
|
|
2172
3272
|
var HANDLERS_FILE_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
|
|
2173
3273
|
// Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
|
|
2174
3274
|
// See https://github.com/aggiovato/yaml-rest for full documentation
|
|
@@ -2230,7 +3330,7 @@ function registerHandler(program2) {
|
|
|
2230
3330
|
const alreadyRegistered = routes.some((r) => r["handler"] === name);
|
|
2231
3331
|
if (!alreadyRegistered) {
|
|
2232
3332
|
routes.push({ method: flags.method.toUpperCase(), path: flags.path, handler: name });
|
|
2233
|
-
writeFileSync3(dbPath,
|
|
3333
|
+
writeFileSync3(dbPath, stringify3(raw), "utf8");
|
|
2234
3334
|
console.log(` Added _routes entry to ${basename(dbPath)}`);
|
|
2235
3335
|
} else {
|
|
2236
3336
|
console.log(` Handler "${name}" already in _routes \u2014 skipped`);
|
|
@@ -2253,6 +3353,33 @@ function registerHandler(program2) {
|
|
|
2253
3353
|
});
|
|
2254
3354
|
}
|
|
2255
3355
|
|
|
3356
|
+
// src/cli/commands/openapi.ts
|
|
3357
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
3358
|
+
import { resolve as resolve5 } from "path";
|
|
3359
|
+
import { stringify as stringify4 } from "yaml";
|
|
3360
|
+
function registerOpenApi(program2) {
|
|
3361
|
+
program2.command("openapi <file>").description("Generate an OpenAPI 3.0 spec from a db.yml file").option("-o, --output <file>", "Output file (default: openapi.yaml / openapi.json)").option("--format <fmt>", "Output format: yaml (default) or json", "yaml").option("--stdout", "Print to stdout instead of writing a file").option("--base <base>", "Base path prefix applied to all routes", "").option("--port <port>", "Server port shown in the servers block", "3070").option("--host <host>", "Server host shown in the servers block", "localhost").option("--title <title>", "API title for the info block", "yRest API").action((file, opts) => {
|
|
3362
|
+
const storage = createYrestStorage(resolve5(file));
|
|
3363
|
+
const options = yrestOptionsSchema.parse({
|
|
3364
|
+
file,
|
|
3365
|
+
base: opts["base"] || void 0,
|
|
3366
|
+
port: Number(opts["port"]) || 3070,
|
|
3367
|
+
host: opts["host"] || "localhost"
|
|
3368
|
+
});
|
|
3369
|
+
const doc = generateOpenApi(storage, options, opts["title"]);
|
|
3370
|
+
const isJson = opts["format"] === "json";
|
|
3371
|
+
const output = isJson ? JSON.stringify(doc, null, 2) : stringify4(doc, { lineWidth: 0, aliasDuplicateObjects: false });
|
|
3372
|
+
if (opts["stdout"]) {
|
|
3373
|
+
process.stdout.write(output);
|
|
3374
|
+
return;
|
|
3375
|
+
}
|
|
3376
|
+
const defaultFile = isJson ? "openapi.json" : "openapi.yaml";
|
|
3377
|
+
const outFile = resolve5(opts["output"] ?? defaultFile);
|
|
3378
|
+
writeFileSync4(outFile, output, "utf8");
|
|
3379
|
+
console.log(`\u2713 OpenAPI spec written to ${outFile}`);
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
3382
|
+
|
|
2256
3383
|
// src/cli/index.ts
|
|
2257
3384
|
var require2 = createRequire(import.meta.url);
|
|
2258
3385
|
var { version } = require2("../../package.json");
|
|
@@ -2260,4 +3387,5 @@ program.name("yrest").description("Zero-config REST API mock server powered by a
|
|
|
2260
3387
|
registerInit(program);
|
|
2261
3388
|
registerServe(program);
|
|
2262
3389
|
registerHandler(program);
|
|
3390
|
+
registerOpenApi(program);
|
|
2263
3391
|
program.parse();
|