@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.js
CHANGED
|
@@ -124,11 +124,11 @@ _rel:
|
|
|
124
124
|
userId: users
|
|
125
125
|
# many2many \u2014 posts can have multiple tags via the post_tags pivot
|
|
126
126
|
tags:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
_type: many2many
|
|
128
|
+
_target: tags
|
|
129
|
+
_through: post_tags
|
|
130
|
+
_foreignKey: postId
|
|
131
|
+
_otherKey: tagId
|
|
132
132
|
comments:
|
|
133
133
|
postId: posts
|
|
134
134
|
userId: users
|
|
@@ -136,8 +136,8 @@ _rel:
|
|
|
136
136
|
# one2one \u2014 each user has exactly one profile
|
|
137
137
|
profiles:
|
|
138
138
|
userId:
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
_type: one2one
|
|
140
|
+
_target: users
|
|
141
141
|
|
|
142
142
|
users:
|
|
143
143
|
- id: 1
|
|
@@ -286,52 +286,52 @@ _rel:
|
|
|
286
286
|
# many2many \u2014 products belong to multiple categories via pivot
|
|
287
287
|
products:
|
|
288
288
|
categories:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
289
|
+
_type: many2many
|
|
290
|
+
_target: categories
|
|
291
|
+
_through: product_categories
|
|
292
|
+
_foreignKey: productId
|
|
293
|
+
_otherKey: categoryId
|
|
294
294
|
|
|
295
295
|
_routes:
|
|
296
296
|
# Login with conditional scenarios
|
|
297
|
-
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
-
|
|
297
|
+
- _method: POST
|
|
298
|
+
_path: /auth/login
|
|
299
|
+
_scenarios:
|
|
300
|
+
- _when:
|
|
301
301
|
body.email: admin@example.com
|
|
302
302
|
body.password: secret
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
303
|
+
_response:
|
|
304
|
+
_status: 200
|
|
305
|
+
_body:
|
|
306
306
|
token: tok-admin-abc123
|
|
307
307
|
role: admin
|
|
308
308
|
userId: 1
|
|
309
|
-
-
|
|
309
|
+
- _when:
|
|
310
310
|
body.email: user@example.com
|
|
311
311
|
body.password: secret
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
_response:
|
|
313
|
+
_status: 200
|
|
314
|
+
_body:
|
|
315
315
|
token: tok-user-xyz789
|
|
316
316
|
role: user
|
|
317
317
|
userId: 2
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
318
|
+
_otherwise:
|
|
319
|
+
_status: 401
|
|
320
|
+
_body:
|
|
321
321
|
error: Invalid credentials
|
|
322
322
|
|
|
323
323
|
# Logout \u2014 always 204
|
|
324
|
-
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
324
|
+
- _method: POST
|
|
325
|
+
_path: /auth/logout
|
|
326
|
+
_response:
|
|
327
|
+
_status: 204
|
|
328
328
|
|
|
329
329
|
# Static featured products list
|
|
330
|
-
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
330
|
+
- _method: GET
|
|
331
|
+
_path: /store/featured
|
|
332
|
+
_response:
|
|
333
|
+
_status: 200
|
|
334
|
+
_body:
|
|
335
335
|
- id: 1
|
|
336
336
|
name: Laptop Pro 15
|
|
337
337
|
price: 1299.99
|
|
@@ -346,31 +346,31 @@ _routes:
|
|
|
346
346
|
badge: On Sale
|
|
347
347
|
|
|
348
348
|
# Template variables \u2014 echoes the requested product id
|
|
349
|
-
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
349
|
+
- _method: GET
|
|
350
|
+
_path: /products/:id/summary
|
|
351
|
+
_response:
|
|
352
|
+
_status: 200
|
|
353
|
+
_body:
|
|
354
354
|
productId: "{{params.id}}"
|
|
355
355
|
requestedAt: "{{now}}"
|
|
356
356
|
source: mock
|
|
357
357
|
|
|
358
358
|
# Cancel order with simulated latency and template vars
|
|
359
|
-
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
359
|
+
- _method: POST
|
|
360
|
+
_path: /orders/:id/cancel
|
|
361
|
+
_delay: 500
|
|
362
|
+
_response:
|
|
363
|
+
_status: 200
|
|
364
|
+
_body:
|
|
365
365
|
orderId: "{{params.id}}"
|
|
366
366
|
status: cancelled
|
|
367
367
|
cancelledAt: "{{now}}"
|
|
368
368
|
|
|
369
369
|
# Simulate a service outage for testing error handling
|
|
370
|
-
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
370
|
+
- _method: GET
|
|
371
|
+
_path: /store/inventory/sync
|
|
372
|
+
_error: 503
|
|
373
|
+
_errorBody:
|
|
374
374
|
message: Inventory service temporarily unavailable
|
|
375
375
|
retryAfter: 30
|
|
376
376
|
|
|
@@ -565,33 +565,173 @@ function parseRelations(raw) {
|
|
|
565
565
|
}
|
|
566
566
|
function normaliseRelationDef(key, value) {
|
|
567
567
|
if (typeof value === "string") {
|
|
568
|
-
return { type: "many2one", target: value };
|
|
568
|
+
return value.includes(":") ? parseDslString(value) : { type: "many2one", target: value };
|
|
569
569
|
}
|
|
570
570
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
571
571
|
const v = value;
|
|
572
|
-
const type = v["
|
|
573
|
-
const nested = v["
|
|
572
|
+
const type = v["_type"];
|
|
573
|
+
const nested = v["_nested"] === true ? true : void 0;
|
|
574
|
+
const carDirect = typeof v["_car-direct"] === "string" ? v["_car-direct"] : void 0;
|
|
575
|
+
const carInverse = typeof v["_car-inverse"] === "string" ? v["_car-inverse"] : void 0;
|
|
574
576
|
if (type === "many2one" || type === void 0) {
|
|
575
|
-
const target = v["
|
|
577
|
+
const target = v["_target"];
|
|
576
578
|
if (typeof target !== "string") return null;
|
|
577
|
-
|
|
579
|
+
const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
|
|
580
|
+
return compact({ type: "many2one", target, foreignKey, nested, carDirect, carInverse });
|
|
578
581
|
}
|
|
579
582
|
if (type === "one2one") {
|
|
580
|
-
const target = v["
|
|
583
|
+
const target = v["_target"];
|
|
581
584
|
if (typeof target !== "string") return null;
|
|
582
|
-
|
|
585
|
+
const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
|
|
586
|
+
return compact({ type: "one2one", target, foreignKey, nested, carDirect, carInverse });
|
|
583
587
|
}
|
|
584
588
|
if (type === "many2many") {
|
|
585
|
-
const target = typeof v["
|
|
586
|
-
const through = v["
|
|
587
|
-
const foreignKey = v["
|
|
588
|
-
const otherKey = v["
|
|
589
|
+
const target = typeof v["_target"] === "string" ? v["_target"] : key;
|
|
590
|
+
const through = v["_through"];
|
|
591
|
+
const foreignKey = v["_foreignKey"];
|
|
592
|
+
const otherKey = v["_otherKey"];
|
|
589
593
|
if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
|
|
590
594
|
return null;
|
|
591
|
-
return
|
|
595
|
+
return compact({
|
|
596
|
+
type: "many2many",
|
|
597
|
+
target,
|
|
598
|
+
through,
|
|
599
|
+
foreignKey,
|
|
600
|
+
otherKey,
|
|
601
|
+
nested,
|
|
602
|
+
carDirect,
|
|
603
|
+
carInverse
|
|
604
|
+
});
|
|
592
605
|
}
|
|
593
606
|
return null;
|
|
594
607
|
}
|
|
608
|
+
var TYPE_ALIASES = {
|
|
609
|
+
many2one: "many2one",
|
|
610
|
+
m2o: "many2one",
|
|
611
|
+
one2one: "one2one",
|
|
612
|
+
o2o: "one2one",
|
|
613
|
+
many2many: "many2many",
|
|
614
|
+
m2m: "many2many"
|
|
615
|
+
};
|
|
616
|
+
function parseDslString(raw) {
|
|
617
|
+
const nested = raw.endsWith("+nested") ? true : void 0;
|
|
618
|
+
let s = nested ? raw.slice(0, -7) : raw;
|
|
619
|
+
let carDirect;
|
|
620
|
+
let carInverse;
|
|
621
|
+
const carMatch = s.match(/\[([^[\]]+)->([^[\]]+)\]$/);
|
|
622
|
+
if (carMatch) {
|
|
623
|
+
carDirect = carMatch[1];
|
|
624
|
+
carInverse = carMatch[2];
|
|
625
|
+
s = s.slice(0, s.length - carMatch[0].length);
|
|
626
|
+
}
|
|
627
|
+
const colonIdx = s.indexOf(":");
|
|
628
|
+
if (colonIdx === -1) return null;
|
|
629
|
+
const typeAlias = s.slice(0, colonIdx);
|
|
630
|
+
const body = s.slice(colonIdx + 1);
|
|
631
|
+
const type = TYPE_ALIASES[typeAlias];
|
|
632
|
+
if (!type) return null;
|
|
633
|
+
if (type === "many2many") {
|
|
634
|
+
const m = body.match(/^([^@()[\]]+)@([^@()[\]]+)\(([^,)]+),\s*([^,)]+)\)$/);
|
|
635
|
+
if (!m) return null;
|
|
636
|
+
const [, target2, through, foreignKey2, otherKey] = m;
|
|
637
|
+
return compact({
|
|
638
|
+
type: "many2many",
|
|
639
|
+
target: target2,
|
|
640
|
+
through,
|
|
641
|
+
foreignKey: foreignKey2,
|
|
642
|
+
otherKey,
|
|
643
|
+
nested,
|
|
644
|
+
carDirect,
|
|
645
|
+
carInverse
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
const atIdx = body.indexOf("@");
|
|
649
|
+
const target = atIdx === -1 ? body : body.slice(0, atIdx);
|
|
650
|
+
const foreignKey = atIdx === -1 ? void 0 : body.slice(atIdx + 1);
|
|
651
|
+
if (!target) return null;
|
|
652
|
+
return compact({ type, target, foreignKey, nested, carDirect, carInverse });
|
|
653
|
+
}
|
|
654
|
+
function compact(obj) {
|
|
655
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// src/storage/parseRoutes.ts
|
|
659
|
+
function parseRoutes(raw) {
|
|
660
|
+
if (!Array.isArray(raw)) return [];
|
|
661
|
+
return raw.map(normaliseRoute).filter((r) => r !== null);
|
|
662
|
+
}
|
|
663
|
+
function normaliseRoute(raw) {
|
|
664
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
665
|
+
const v = raw;
|
|
666
|
+
const method = v["_method"];
|
|
667
|
+
const path = v["_path"];
|
|
668
|
+
if (typeof method !== "string" || typeof path !== "string") return null;
|
|
669
|
+
const route = { method, path };
|
|
670
|
+
if (typeof v["_handler"] === "string") route.handler = v["_handler"];
|
|
671
|
+
if (typeof v["_delay"] === "number") route.delay = v["_delay"];
|
|
672
|
+
if (typeof v["_error"] === "number") route.error = v["_error"];
|
|
673
|
+
if (v["_errorBody"] !== void 0) route.errorBody = v["_errorBody"];
|
|
674
|
+
const response = normaliseResponse(v["_response"]);
|
|
675
|
+
if (response) route.response = response;
|
|
676
|
+
const otherwise = normaliseResponse(v["_otherwise"]);
|
|
677
|
+
if (otherwise) route.otherwise = otherwise;
|
|
678
|
+
const scenarios = normaliseScenarios(v["_scenarios"]);
|
|
679
|
+
if (scenarios.length > 0) route.scenarios = scenarios;
|
|
680
|
+
return route;
|
|
681
|
+
}
|
|
682
|
+
function normaliseResponse(raw) {
|
|
683
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
684
|
+
const v = raw;
|
|
685
|
+
const response = {};
|
|
686
|
+
if (typeof v["_status"] === "number") response.status = v["_status"];
|
|
687
|
+
if (v["_body"] !== void 0) response.body = v["_body"];
|
|
688
|
+
if (v["_headers"] && typeof v["_headers"] === "object" && !Array.isArray(v["_headers"]))
|
|
689
|
+
response.headers = v["_headers"];
|
|
690
|
+
return Object.keys(response).length > 0 ? response : void 0;
|
|
691
|
+
}
|
|
692
|
+
function normaliseScenarios(raw) {
|
|
693
|
+
if (!Array.isArray(raw)) return [];
|
|
694
|
+
return raw.map(normaliseScenario).filter((s) => s !== null);
|
|
695
|
+
}
|
|
696
|
+
function normaliseScenario(raw) {
|
|
697
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
698
|
+
const v = raw;
|
|
699
|
+
const when = v["_when"];
|
|
700
|
+
if (!when || typeof when !== "object") return null;
|
|
701
|
+
const response = normaliseResponse(v["_response"]);
|
|
702
|
+
if (!response) return null;
|
|
703
|
+
return { when, response };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/storage/parseSchema.ts
|
|
707
|
+
function parseSchema(raw) {
|
|
708
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
709
|
+
const result = {};
|
|
710
|
+
for (const [collection, fields] of Object.entries(raw)) {
|
|
711
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
|
|
712
|
+
result[collection] = {};
|
|
713
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
714
|
+
const def = normaliseFieldDef(value);
|
|
715
|
+
if (def) result[collection][field] = def;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return result;
|
|
719
|
+
}
|
|
720
|
+
function normaliseFieldDef(value) {
|
|
721
|
+
if (value === "required") return { required: true };
|
|
722
|
+
if (value === "optional") return { required: false };
|
|
723
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
724
|
+
const v = value;
|
|
725
|
+
const def = {};
|
|
726
|
+
if (v["_required"] === true || v["_required"] === false) def.required = v["_required"];
|
|
727
|
+
if (typeof v["_type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["_type"]))
|
|
728
|
+
def.type = v["_type"];
|
|
729
|
+
if (typeof v["_format"] === "string") def.format = v["_format"];
|
|
730
|
+
if (Array.isArray(v["_enum"])) def.enum = v["_enum"];
|
|
731
|
+
if (typeof v["_description"] === "string") def.description = v["_description"];
|
|
732
|
+
if (v["_default"] !== void 0) def.default = v["_default"];
|
|
733
|
+
return def;
|
|
734
|
+
}
|
|
595
735
|
|
|
596
736
|
// src/utils/deepCopy.ts
|
|
597
737
|
function deepCopyData(source) {
|
|
@@ -601,13 +741,69 @@ function deepCopyData(source) {
|
|
|
601
741
|
}
|
|
602
742
|
|
|
603
743
|
// src/storage/yrestStorage.ts
|
|
744
|
+
function serializeRelations(relations) {
|
|
745
|
+
const out = {};
|
|
746
|
+
for (const [col, fields] of Object.entries(relations)) {
|
|
747
|
+
out[col] = {};
|
|
748
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
749
|
+
out[col][key] = serializeRelationDef(def);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return out;
|
|
753
|
+
}
|
|
754
|
+
function serializeRelationDef(def) {
|
|
755
|
+
if (def.type === "many2many") {
|
|
756
|
+
const entry2 = {
|
|
757
|
+
_type: def.type,
|
|
758
|
+
_target: def.target,
|
|
759
|
+
_through: def.through,
|
|
760
|
+
_foreignKey: def.foreignKey,
|
|
761
|
+
_otherKey: def.otherKey
|
|
762
|
+
};
|
|
763
|
+
if (def.nested) entry2._nested = true;
|
|
764
|
+
if (def.carDirect) entry2["_car-direct"] = def.carDirect;
|
|
765
|
+
if (def.carInverse) entry2["_car-inverse"] = def.carInverse;
|
|
766
|
+
return entry2;
|
|
767
|
+
}
|
|
768
|
+
const entry = { _type: def.type, _target: def.target };
|
|
769
|
+
if (def.foreignKey) entry._foreignKey = def.foreignKey;
|
|
770
|
+
if (def.nested) entry._nested = true;
|
|
771
|
+
if (def.carDirect) entry["_car-direct"] = def.carDirect;
|
|
772
|
+
if (def.carInverse) entry["_car-inverse"] = def.carInverse;
|
|
773
|
+
return entry;
|
|
774
|
+
}
|
|
775
|
+
function serializeRoutes(routes) {
|
|
776
|
+
return routes.map((r) => {
|
|
777
|
+
const entry = { _method: r.method, _path: r.path };
|
|
778
|
+
if (r.handler) entry._handler = r.handler;
|
|
779
|
+
if (r.delay !== void 0) entry._delay = r.delay;
|
|
780
|
+
if (r.error !== void 0) entry._error = r.error;
|
|
781
|
+
if (r.errorBody !== void 0) entry._errorBody = r.errorBody;
|
|
782
|
+
if (r.response) entry._response = serializeResponse(r.response);
|
|
783
|
+
if (r.otherwise) entry._otherwise = serializeResponse(r.otherwise);
|
|
784
|
+
if (r.scenarios?.length) entry._scenarios = r.scenarios.map(serializeScenario);
|
|
785
|
+
return entry;
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
function serializeResponse(res) {
|
|
789
|
+
const out = {};
|
|
790
|
+
if (res.status !== void 0) out._status = res.status;
|
|
791
|
+
if (res.body !== void 0) out._body = res.body;
|
|
792
|
+
if (res.headers) out._headers = res.headers;
|
|
793
|
+
return out;
|
|
794
|
+
}
|
|
795
|
+
function serializeScenario(s) {
|
|
796
|
+
return { _when: s.when, _response: serializeResponse(s.response) };
|
|
797
|
+
}
|
|
604
798
|
function createYrestStorage(filePath) {
|
|
605
799
|
const absPath = (0, import_node_path2.resolve)(filePath);
|
|
606
800
|
const raw = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
|
|
801
|
+
const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
|
|
607
802
|
const relations = parseRelations(raw["_rel"]);
|
|
608
|
-
const routes =
|
|
803
|
+
const routes = parseRoutes(raw["_routes"]);
|
|
804
|
+
const schema = parseSchema(raw["_schema"]);
|
|
609
805
|
const data = Object.fromEntries(
|
|
610
|
-
Object.entries(raw).filter(([key]) => key
|
|
806
|
+
Object.entries(raw).filter(([key]) => !RESERVED.has(key))
|
|
611
807
|
);
|
|
612
808
|
let snapshot = {
|
|
613
809
|
data: deepCopyData(data),
|
|
@@ -621,6 +817,9 @@ function createYrestStorage(filePath) {
|
|
|
621
817
|
getRelations() {
|
|
622
818
|
return relations;
|
|
623
819
|
},
|
|
820
|
+
getSchema() {
|
|
821
|
+
return schema;
|
|
822
|
+
},
|
|
624
823
|
getRoutes() {
|
|
625
824
|
return routes;
|
|
626
825
|
},
|
|
@@ -632,8 +831,8 @@ function createYrestStorage(filePath) {
|
|
|
632
831
|
},
|
|
633
832
|
persist() {
|
|
634
833
|
const payload = {};
|
|
635
|
-
if (Object.keys(relations).length > 0) payload._rel = relations;
|
|
636
|
-
if (routes.length > 0) payload._routes = routes;
|
|
834
|
+
if (Object.keys(relations).length > 0) payload._rel = serializeRelations(relations);
|
|
835
|
+
if (routes.length > 0) payload._routes = serializeRoutes(routes);
|
|
637
836
|
Object.assign(payload, data);
|
|
638
837
|
const tmp = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(absPath), `.yrest-${(0, import_node_crypto.randomUUID)()}.tmp`);
|
|
639
838
|
(0, import_node_fs2.writeFileSync)(tmp, (0, import_yaml.stringify)(payload), "utf8");
|
|
@@ -643,7 +842,7 @@ function createYrestStorage(filePath) {
|
|
|
643
842
|
const fresh = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
|
|
644
843
|
const freshRelations = parseRelations(fresh["_rel"]);
|
|
645
844
|
const freshData = Object.fromEntries(
|
|
646
|
-
Object.entries(fresh).filter(([key]) => key
|
|
845
|
+
Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
|
|
647
846
|
);
|
|
648
847
|
for (const key of Object.keys(data)) delete data[key];
|
|
649
848
|
Object.assign(data, freshData);
|
|
@@ -755,7 +954,7 @@ function endpointRow(method, path, desc) {
|
|
|
755
954
|
}
|
|
756
955
|
function resourceAccordion(name, base, isOpen) {
|
|
757
956
|
const p = `${base}/${name}`;
|
|
758
|
-
const
|
|
957
|
+
const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
|
|
759
958
|
const rows = [
|
|
760
959
|
endpointRow(
|
|
761
960
|
"GET",
|
|
@@ -765,20 +964,20 @@ function resourceAccordion(name, base, isOpen) {
|
|
|
765
964
|
endpointRow(
|
|
766
965
|
"POST",
|
|
767
966
|
p,
|
|
768
|
-
`Create a new ${
|
|
967
|
+
`Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
|
|
769
968
|
),
|
|
770
|
-
endpointRow("GET", `${p}/:id`, `Get a single ${
|
|
969
|
+
endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
|
|
771
970
|
endpointRow(
|
|
772
971
|
"PUT",
|
|
773
972
|
`${p}/:id`,
|
|
774
|
-
`Fully replace a ${
|
|
973
|
+
`Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
|
|
775
974
|
),
|
|
776
975
|
endpointRow(
|
|
777
976
|
"PATCH",
|
|
778
977
|
`${p}/:id`,
|
|
779
|
-
`Partially update a ${
|
|
978
|
+
`Partially update a ${singular2} \u2014 only provided fields change.`
|
|
780
979
|
),
|
|
781
|
-
endpointRow("DELETE", `${p}/:id`, `Delete a ${
|
|
980
|
+
endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
|
|
782
981
|
].join("");
|
|
783
982
|
return `
|
|
784
983
|
<details class="resource-card" ${isOpen ? "open" : ""}>
|
|
@@ -797,13 +996,13 @@ function nestedRoutesAccordion(relations, base) {
|
|
|
797
996
|
for (const [key, def] of Object.entries(fields)) {
|
|
798
997
|
const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
|
|
799
998
|
if (def.type === "many2many") {
|
|
800
|
-
const
|
|
999
|
+
const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
|
|
801
1000
|
const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
|
|
802
1001
|
rows.push(
|
|
803
1002
|
endpointRow(
|
|
804
1003
|
"GET",
|
|
805
1004
|
`${base}/${source}/:id/${key}`,
|
|
806
|
-
`List ${def.target} linked to a ${
|
|
1005
|
+
`List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
|
|
807
1006
|
)
|
|
808
1007
|
);
|
|
809
1008
|
const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
|
|
@@ -929,7 +1128,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
|
|
|
929
1128
|
const firstCol = collections[0];
|
|
930
1129
|
if (firstCol) {
|
|
931
1130
|
const p = `${host}${base}/${firstCol}`;
|
|
932
|
-
const
|
|
1131
|
+
const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
|
|
933
1132
|
examples.push(
|
|
934
1133
|
`# List all ${firstCol}
|
|
935
1134
|
curl ${p}`,
|
|
@@ -937,17 +1136,17 @@ curl ${p}`,
|
|
|
937
1136
|
curl "${p}?name=value"`,
|
|
938
1137
|
`# Sort and paginate
|
|
939
1138
|
curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
|
|
940
|
-
`# Get single ${
|
|
1139
|
+
`# Get single ${singular2}
|
|
941
1140
|
curl ${p}/1`,
|
|
942
|
-
`# Create ${
|
|
1141
|
+
`# Create ${singular2}
|
|
943
1142
|
curl -X POST ${p} \\
|
|
944
1143
|
-H "Content-Type: application/json" \\
|
|
945
1144
|
-d '{"name":"example"}'`,
|
|
946
|
-
`# Partially update ${
|
|
1145
|
+
`# Partially update ${singular2}
|
|
947
1146
|
curl -X PATCH ${p}/1 \\
|
|
948
1147
|
-H "Content-Type: application/json" \\
|
|
949
1148
|
-d '{"name":"updated"}'`,
|
|
950
|
-
`# Delete ${
|
|
1149
|
+
`# Delete ${singular2}
|
|
951
1150
|
curl -X DELETE ${p}/1`
|
|
952
1151
|
);
|
|
953
1152
|
}
|
|
@@ -1010,12 +1209,508 @@ curl ${curlFlag}${fullPath}`);
|
|
|
1010
1209
|
return `<pre>${highlighted}</pre>`;
|
|
1011
1210
|
}
|
|
1012
1211
|
|
|
1212
|
+
// src/router/templates/about.diagram.ts
|
|
1213
|
+
function inferType(value) {
|
|
1214
|
+
if (value === null || value === void 0) return "string";
|
|
1215
|
+
if (typeof value === "boolean") return "boolean";
|
|
1216
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
1217
|
+
if (Array.isArray(value)) return "array";
|
|
1218
|
+
if (typeof value === "object") return "object";
|
|
1219
|
+
return "string";
|
|
1220
|
+
}
|
|
1221
|
+
function generateERData(storage) {
|
|
1222
|
+
const data = storage.getData();
|
|
1223
|
+
const relations = storage.getRelations();
|
|
1224
|
+
const schema = storage.getSchema();
|
|
1225
|
+
const pivotTables = /* @__PURE__ */ new Set();
|
|
1226
|
+
for (const fields of Object.values(relations)) {
|
|
1227
|
+
for (const def of Object.values(fields)) {
|
|
1228
|
+
if (def.type === "many2many") pivotTables.add(def.through);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
const fkFields = /* @__PURE__ */ new Map();
|
|
1232
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
1233
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1234
|
+
if (def.type !== "many2many") {
|
|
1235
|
+
if (!fkFields.has(source)) fkFields.set(source, /* @__PURE__ */ new Set());
|
|
1236
|
+
fkFields.get(source).add(key);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const entities = Object.entries(data).map(([name, items]) => {
|
|
1241
|
+
const sample = items[0] ?? {};
|
|
1242
|
+
const schemaFields = schema[name] ?? {};
|
|
1243
|
+
const allFields = /* @__PURE__ */ new Set([...Object.keys(sample), ...Object.keys(schemaFields)]);
|
|
1244
|
+
const fields = [...allFields].map((fieldName) => ({
|
|
1245
|
+
name: fieldName,
|
|
1246
|
+
type: schemaFields[fieldName]?.type ?? inferType(sample[fieldName]),
|
|
1247
|
+
pk: fieldName === "id",
|
|
1248
|
+
fk: fkFields.get(name)?.has(fieldName) ?? false,
|
|
1249
|
+
required: schemaFields[fieldName]?.required ?? false
|
|
1250
|
+
}));
|
|
1251
|
+
fields.sort((a, b) => {
|
|
1252
|
+
if (a.pk) return -1;
|
|
1253
|
+
if (b.pk) return 1;
|
|
1254
|
+
if (a.fk && !b.fk) return 1;
|
|
1255
|
+
if (!a.fk && b.fk) return -1;
|
|
1256
|
+
return 0;
|
|
1257
|
+
});
|
|
1258
|
+
return { name, fields, isPivot: pivotTables.has(name) };
|
|
1259
|
+
});
|
|
1260
|
+
const erRelations = [];
|
|
1261
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
1262
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1263
|
+
if (def.type === "many2many") {
|
|
1264
|
+
erRelations.push({
|
|
1265
|
+
source,
|
|
1266
|
+
target: def.target,
|
|
1267
|
+
type: "many2many",
|
|
1268
|
+
label: def.through,
|
|
1269
|
+
through: def.through,
|
|
1270
|
+
foreignKey: def.foreignKey,
|
|
1271
|
+
otherKey: def.otherKey
|
|
1272
|
+
});
|
|
1273
|
+
} else {
|
|
1274
|
+
erRelations.push({
|
|
1275
|
+
source,
|
|
1276
|
+
target: def.target,
|
|
1277
|
+
type: def.type,
|
|
1278
|
+
label: key,
|
|
1279
|
+
carDirect: def.carDirect,
|
|
1280
|
+
carInverse: def.carInverse
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
return { entities, relations: erRelations };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// src/router/templates/about.diagram.canvas.ts
|
|
1289
|
+
function erDiagramSection(data) {
|
|
1290
|
+
if (!data.entities.length) return "";
|
|
1291
|
+
const json = JSON.stringify(data);
|
|
1292
|
+
return (
|
|
1293
|
+
/*html*/
|
|
1294
|
+
`
|
|
1295
|
+
<h2>Data Model</h2>
|
|
1296
|
+
|
|
1297
|
+
<div class="card" style="padding:0;overflow:hidden;position:relative;">
|
|
1298
|
+
<canvas id="er-canvas" style="width:100%;display:block;cursor:grab;"></canvas>
|
|
1299
|
+
<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>
|
|
1300
|
+
</div>
|
|
1301
|
+
|
|
1302
|
+
<script>${/*js*/
|
|
1303
|
+
`
|
|
1304
|
+
(function(){
|
|
1305
|
+
|
|
1306
|
+
var D=${json};
|
|
1307
|
+
|
|
1308
|
+
// \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
|
|
1309
|
+
var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=8,BO=14,LGH=56;
|
|
1310
|
+
|
|
1311
|
+
var C={bg:'#0d1117',nb:'#161b22',nh:'#1c2128',pb:'#0d1117',ph:'#161b22',
|
|
1312
|
+
bo:'#30363d',bs:'#58a6ff',tx:'#e6edf3',mu:'#7d8590',
|
|
1313
|
+
pk:'#e3b341',fk:'#79c0ff',m2o:'#58a6ff',o2o:'#34d399',m2m:'#818cf8'};
|
|
1314
|
+
|
|
1315
|
+
// \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
|
|
1316
|
+
var cv = document.getElementById('er-canvas');
|
|
1317
|
+
var dpr = window.devicePixelRatio||1;
|
|
1318
|
+
var ctx = cv.getContext('2d');
|
|
1319
|
+
|
|
1320
|
+
function resize(h){
|
|
1321
|
+
if(h)cv.style.height = h + 'px';
|
|
1322
|
+
var r = cv.getBoundingClientRect();
|
|
1323
|
+
cv.width = Math.round(r.width*dpr);
|
|
1324
|
+
cv.height = Math.round(r.height*dpr);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// \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
|
|
1328
|
+
var nodes = D.entities.map(function(e){
|
|
1329
|
+
return{e:e,x:0,y:0,w:NW,h:HH+e.fields.length*FH+6};
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
var nIdx={};D.entities.forEach(function(e,i){nIdx[e.name]=i;});
|
|
1333
|
+
|
|
1334
|
+
var adj=nodes.map(function(){return[];}); // forward
|
|
1335
|
+
var radj=nodes.map(function(){return[];}); // reverse
|
|
1336
|
+
|
|
1337
|
+
D.relations.forEach(function(r){
|
|
1338
|
+
if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
|
|
1339
|
+
var s=nIdx[r.source],p=nIdx[r.through],t=nIdx[r.target];
|
|
1340
|
+
if(s!=null&&p!=null&&s!==p){adj[s].push(p);radj[p].push(s);}
|
|
1341
|
+
if(t!=null&&p!=null&&t!==p){adj[t].push(p);radj[p].push(t);}
|
|
1342
|
+
}else{
|
|
1343
|
+
var s=nIdx[r.source],t=nIdx[r.target];
|
|
1344
|
+
if(s==null||t==null||s===t)return;
|
|
1345
|
+
adj[s].push(t);radj[t].push(s);
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
// \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
|
|
1350
|
+
var groups=[];
|
|
1351
|
+
|
|
1352
|
+
function median(arr){
|
|
1353
|
+
if(!arr.length)return 0;
|
|
1354
|
+
var s=[].concat(arr).sort(function(a,b){return a-b;});
|
|
1355
|
+
return s[Math.floor(s.length/2)];
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function computeLayout(W,H){
|
|
1359
|
+
var layer=nodes.map(function(){return 0;});
|
|
1360
|
+
var inDeg=nodes.map(function(){return 0;});
|
|
1361
|
+
adj.forEach(function(ts){ts.forEach(function(t){inDeg[t]++;});});
|
|
1362
|
+
var q=[],qi=0;
|
|
1363
|
+
for(var i=0;i<nodes.length;i++)if(inDeg[i]===0)q.push(i);
|
|
1364
|
+
if(!q.length)for(var i=0;i<nodes.length;i++)q.push(i);
|
|
1365
|
+
while(qi<q.length){
|
|
1366
|
+
var u=q[qi++];
|
|
1367
|
+
adj[u].forEach(function(v){if(layer[v]<=layer[u]){layer[v]=layer[u]+1;q.push(v);}});
|
|
1368
|
+
}
|
|
1369
|
+
var maxL=layer.reduce(function(m,l){return Math.max(m,l);},0);
|
|
1370
|
+
groups=[];
|
|
1371
|
+
for(var l=0;l<=maxL;l++)groups.push([]);
|
|
1372
|
+
for(var i=0;i<nodes.length;i++)groups[layer[i]].push(i);
|
|
1373
|
+
for(var l=1;l<=maxL;l++){
|
|
1374
|
+
var pp={};groups[l-1].forEach(function(i,p){pp[i]=p;});
|
|
1375
|
+
groups[l].sort(function(a,b){
|
|
1376
|
+
return median(radj[a].map(function(x){return pp[x]!=null?pp[x]:0;}))-
|
|
1377
|
+
median(radj[b].map(function(x){return pp[x]!=null?pp[x]:0;}));
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
for(var l=maxL-1;l>=0;l--){
|
|
1381
|
+
var np={};groups[l+1].forEach(function(i,p){np[i]=p;});
|
|
1382
|
+
groups[l].sort(function(a,b){
|
|
1383
|
+
return median(adj[a].map(function(x){return np[x]!=null?np[x]:0;}))-
|
|
1384
|
+
median(adj[b].map(function(x){return np[x]!=null?np[x]:0;}));
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
var colW=NW+COL_GAP;
|
|
1388
|
+
var totalW=(maxL+1)*colW-COL_GAP;
|
|
1389
|
+
var startX=Math.max(20,(W-totalW)/2);
|
|
1390
|
+
for(var l=0;l<=maxL;l++){
|
|
1391
|
+
var g=groups[l];
|
|
1392
|
+
var totalH=g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP;
|
|
1393
|
+
var y=Math.max(20,(H-totalH)/2);
|
|
1394
|
+
g.forEach(function(i){nodes[i].x=startX+l*colW;nodes[i].y=y;y+=nodes[i].h+ROW_GAP;});
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function requiredH(){
|
|
1399
|
+
return Math.max(340,Math.min(720,groups.reduce(function(m,g){
|
|
1400
|
+
return Math.max(m,g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP+LGH+70);
|
|
1401
|
+
},300)));
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// \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
|
|
1405
|
+
var pan={x:0,y:0},zm=1;
|
|
1406
|
+
var origPos=[];
|
|
1407
|
+
|
|
1408
|
+
function savePos(){origPos=nodes.map(function(n){return{x:n.x,y:n.y};});}
|
|
1409
|
+
|
|
1410
|
+
function fitView(W,H){
|
|
1411
|
+
var x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
|
|
1412
|
+
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);});
|
|
1413
|
+
var pad=28,gW=x1-x0+pad*2,gH=y1-y0+pad*2;
|
|
1414
|
+
zm=Math.min(W/gW,(H-LGH)/gH,1);
|
|
1415
|
+
pan.x=(W-gW*zm)/2+pad*zm-x0*zm;
|
|
1416
|
+
pan.y=((H-LGH)-gH*zm)/2+pad*zm-y0*zm;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// \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
|
|
1420
|
+
function rr(x,y,w,h,r){
|
|
1421
|
+
if(ctx.roundRect){ctx.beginPath();ctx.roundRect(x,y,w,h,r);return;}
|
|
1422
|
+
ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
|
|
1423
|
+
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();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function edgePt(n,tx,ty){
|
|
1427
|
+
var cx=n.x+n.w/2,cy=n.y+n.h/2,dx=tx-cx,dy=ty-cy;
|
|
1428
|
+
if(!dx&&!dy)return{x:cx,y:cy};
|
|
1429
|
+
var s=Math.min((n.w/2)/Math.abs(dx||.001),(n.h/2)/Math.abs(dy||.001));
|
|
1430
|
+
return{x:cx+dx*s,y:cy+dy*s};
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
var badges=[];
|
|
1434
|
+
|
|
1435
|
+
function pushBadge(x,y,ux,uy,many,col){
|
|
1436
|
+
badges.push({x:x,y:y,ux:ux,uy:uy,many:many,col:col});
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Badge: solid colored circle; crow's foot (many) or double-bar (one) in dark ink inside.
|
|
1440
|
+
// ux,uy points outward from the node \u2014 badge is offset BO px into the gap.
|
|
1441
|
+
// Crow's foot tines open toward the node (-x in rotated frame), tip toward line (+x).
|
|
1442
|
+
function drawBadge(b){
|
|
1443
|
+
var bx=b.x+b.ux*BO,by=b.y+b.uy*BO;
|
|
1444
|
+
ctx.fillStyle=b.col;ctx.strokeStyle=b.col;ctx.lineWidth=1;
|
|
1445
|
+
ctx.beginPath();ctx.arc(bx,by,BR,0,Math.PI*2);ctx.fill();ctx.stroke();
|
|
1446
|
+
ctx.save();ctx.translate(bx,by);ctx.rotate(Math.atan2(b.uy,b.ux));
|
|
1447
|
+
ctx.strokeStyle=C.bg;ctx.lineWidth=1.4;ctx.lineCap='round';
|
|
1448
|
+
ctx.beginPath();
|
|
1449
|
+
if(b.many){
|
|
1450
|
+
ctx.moveTo(-5,-4);ctx.lineTo(5,0); // upper tine \u2192 tip
|
|
1451
|
+
ctx.moveTo(-5,4);ctx.lineTo(5,0); // lower tine \u2192 tip
|
|
1452
|
+
ctx.moveTo(-5,0);ctx.lineTo(5,0); // center line
|
|
1453
|
+
ctx.moveTo(-5,-4);ctx.lineTo(-5,4); // base bar (near node)
|
|
1454
|
+
}else{
|
|
1455
|
+
ctx.moveTo(-3,-5);ctx.lineTo(-3,5); // near bar
|
|
1456
|
+
ctx.moveTo(2,-5);ctx.lineTo(2,5); // far bar
|
|
1457
|
+
}
|
|
1458
|
+
ctx.stroke();
|
|
1459
|
+
ctx.restore();
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function ecol(t){return t==='one2one'?C.o2o:t==='many2many'?C.m2m:C.m2o;}
|
|
1463
|
+
|
|
1464
|
+
// offset: pixels to shift perpendicular to the edge direction (for parallel edges)
|
|
1465
|
+
function drawEdgeLine(a,b,col,dashed,label,manyA,manyB,off){
|
|
1466
|
+
var p1=edgePt(a,b.x+b.w/2,b.y+b.h/2);
|
|
1467
|
+
var p2=edgePt(b,a.x+a.w/2,a.y+a.h/2);
|
|
1468
|
+
var dx=p2.x-p1.x,dy=p2.y-p1.y,d=Math.sqrt(dx*dx+dy*dy)||1;
|
|
1469
|
+
var ux=dx/d,uy=dy/d;
|
|
1470
|
+
var px=-uy*off,py=ux*off; // perpendicular shift
|
|
1471
|
+
var q1x=p1.x+px,q1y=p1.y+py,q2x=p2.x+px,q2y=p2.y+py;
|
|
1472
|
+
ctx.strokeStyle=col+'55';ctx.lineWidth=1.5;
|
|
1473
|
+
ctx.setLineDash(dashed?[5,4]:[]);
|
|
1474
|
+
ctx.beginPath();ctx.moveTo(q1x,q1y);ctx.lineTo(q2x,q2y);ctx.stroke();
|
|
1475
|
+
ctx.setLineDash([]);
|
|
1476
|
+
pushBadge(q1x,q1y,ux,uy,manyA,col);
|
|
1477
|
+
pushBadge(q2x,q2y,-ux,-uy,manyB,col);
|
|
1478
|
+
if(label){
|
|
1479
|
+
var mx=(q1x+q2x)/2,my=(q1y+q2y)/2;
|
|
1480
|
+
ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='center';ctx.textBaseline='middle';
|
|
1481
|
+
ctx.fillText(label,mx-uy*14,my+ux*14);
|
|
1482
|
+
ctx.textBaseline='alphabetic';
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// canonical key for a node pair regardless of direction
|
|
1487
|
+
function pairKey(a,b){
|
|
1488
|
+
var ai=nodes.indexOf(a),bi=nodes.indexOf(b);
|
|
1489
|
+
return ai<bi?ai+'-'+bi:bi+'-'+ai;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function collectEdges(){
|
|
1493
|
+
var edges=[];
|
|
1494
|
+
D.relations.forEach(function(r){
|
|
1495
|
+
var col=ecol(r.type);
|
|
1496
|
+
if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
|
|
1497
|
+
var sn=nodes[nIdx[r.source]],pn=nodes[nIdx[r.through]],tn=nodes[nIdx[r.target]];
|
|
1498
|
+
if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',manyA:false,manyB:true,off:0});
|
|
1499
|
+
if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',manyA:false,manyB:true,off:0});
|
|
1500
|
+
}else{
|
|
1501
|
+
var sn=nodes[nIdx[r.source]],tn=nodes[nIdx[r.target]];
|
|
1502
|
+
if(sn&&tn&&sn!==tn){
|
|
1503
|
+
var manyA=r.carInverse?r.carInverse.endsWith('n'):r.type!=='one2one';
|
|
1504
|
+
var manyB=r.carDirect?r.carDirect.endsWith('n'):r.type==='many2many';
|
|
1505
|
+
edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,manyA:manyA,manyB:manyB,off:0});
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
// assign perpendicular offsets to parallel edges (same node pair)
|
|
1510
|
+
var cnt={},idx={};
|
|
1511
|
+
edges.forEach(function(e){var k=pairKey(e.a,e.b);cnt[k]=(cnt[k]||0)+1;});
|
|
1512
|
+
edges.forEach(function(e){
|
|
1513
|
+
var k=pairKey(e.a,e.b),n=cnt[k];
|
|
1514
|
+
var i=idx[k]=(idx[k]||0);
|
|
1515
|
+
e.off=n>1?(i-(n-1)/2)*16:0;
|
|
1516
|
+
idx[k]++;
|
|
1517
|
+
});
|
|
1518
|
+
return edges;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function drawNode(n){
|
|
1522
|
+
var x=n.x,y=n.y,w=n.w,h=n.h,e=n.e;
|
|
1523
|
+
var bg=e.isPivot?C.pb:C.nb,hd=e.isPivot?C.ph:C.nh;
|
|
1524
|
+
ctx.save();ctx.shadowColor='rgba(0,0,0,0.45)';ctx.shadowBlur=14;ctx.shadowOffsetY=4;
|
|
1525
|
+
ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();ctx.restore();
|
|
1526
|
+
ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();
|
|
1527
|
+
ctx.strokeStyle=n===drag?C.bs:C.bo;ctx.lineWidth=1.2;rr(x,y,w,h,7);ctx.stroke();
|
|
1528
|
+
ctx.fillStyle=hd;rr(x,y,w,HH,7);ctx.fill();ctx.fillRect(x,y+HH-7,w,7);
|
|
1529
|
+
ctx.strokeStyle=C.bo;ctx.lineWidth=1;
|
|
1530
|
+
ctx.beginPath();ctx.moveTo(x,y+HH);ctx.lineTo(x+w,y+HH);ctx.stroke();
|
|
1531
|
+
ctx.fillStyle=e.isPivot?C.mu:C.tx;
|
|
1532
|
+
ctx.font='600 12px -apple-system,BlinkMacSystemFont,sans-serif';
|
|
1533
|
+
ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1534
|
+
ctx.fillText(e.name,x+FP,y+HH/2);
|
|
1535
|
+
if(e.isPivot){
|
|
1536
|
+
ctx.fillStyle='#3d444d';ctx.font='9px monospace';ctx.textAlign='right';
|
|
1537
|
+
ctx.fillText('pivot',x+w-FP,y+HH/2);
|
|
1538
|
+
}
|
|
1539
|
+
e.fields.forEach(function(f,fi){
|
|
1540
|
+
var fy=y+HH+fi*FH+FH/2+3;
|
|
1541
|
+
ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
|
|
1542
|
+
ctx.fillText(f.type,x+w-FP-(f.required&&!f.pk?14:0),fy);
|
|
1543
|
+
if(f.pk||f.fk){
|
|
1544
|
+
var tag=f.pk?'PK':'FK',tc=f.pk?C.pk:C.fk;
|
|
1545
|
+
ctx.fillStyle=tc+'22';ctx.strokeStyle=tc+'66';ctx.lineWidth=1;
|
|
1546
|
+
rr(x+FP,fy-6,12,12,2);ctx.fill();ctx.stroke();
|
|
1547
|
+
ctx.fillStyle=tc;ctx.font='7px monospace';ctx.textAlign='center';
|
|
1548
|
+
ctx.fillText(tag,x+FP+6,fy+0.5);
|
|
1549
|
+
}
|
|
1550
|
+
ctx.fillStyle=f.pk?C.pk:f.fk?C.fk:C.tx;
|
|
1551
|
+
ctx.font=(f.required&&!f.pk?'600 ':'')+'11px monospace';
|
|
1552
|
+
ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1553
|
+
ctx.fillText(f.name,x+FP+(f.pk||f.fk?16:0),fy);
|
|
1554
|
+
if(f.required&&!f.pk){
|
|
1555
|
+
ctx.fillStyle='#f85149';ctx.beginPath();
|
|
1556
|
+
ctx.arc(x+w-FP-6,fy,2.5,0,Math.PI*2);ctx.fill();
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
ctx.textBaseline='alphabetic';
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function drawLegend(){
|
|
1563
|
+
var cvW=cv.width/dpr,cvH=cv.height/dpr;
|
|
1564
|
+
var y0=cvH-LGH,y1=cvH-LGH+18,y2=cvH-LGH+38;
|
|
1565
|
+
|
|
1566
|
+
ctx.fillStyle='#161b22';
|
|
1567
|
+
ctx.fillRect(0,y0,cvW,LGH);
|
|
1568
|
+
ctx.strokeStyle='#30363d';ctx.lineWidth=1;
|
|
1569
|
+
ctx.beginPath();ctx.moveTo(0,y0);ctx.lineTo(cvW,y0);ctx.stroke();
|
|
1570
|
+
|
|
1571
|
+
// Row 1: relation line types
|
|
1572
|
+
var types=[
|
|
1573
|
+
{c:C.m2o,l:'many2one',d:false},
|
|
1574
|
+
{c:C.o2o,l:'one2one',d:false},
|
|
1575
|
+
{c:C.m2m,l:'many2many',d:true}
|
|
1576
|
+
];
|
|
1577
|
+
ctx.textAlign='left';ctx.textBaseline='middle';ctx.font='10px monospace';
|
|
1578
|
+
var x=14;
|
|
1579
|
+
types.forEach(function(it){
|
|
1580
|
+
ctx.strokeStyle=it.c;ctx.lineWidth=1.5;ctx.setLineDash(it.d?[4,3]:[]);
|
|
1581
|
+
ctx.beginPath();ctx.moveTo(x,y1);ctx.lineTo(x+14,y1);ctx.stroke();
|
|
1582
|
+
ctx.setLineDash([]);
|
|
1583
|
+
ctx.fillStyle=C.mu;
|
|
1584
|
+
ctx.fillText(it.l,x+18,y1);
|
|
1585
|
+
x+=18+ctx.measureText(it.l).width+22;
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
// Row 2: cardinality symbols
|
|
1589
|
+
function badge2(cx,cy,many,col){
|
|
1590
|
+
ctx.fillStyle=col;ctx.strokeStyle=col;ctx.lineWidth=1;
|
|
1591
|
+
ctx.beginPath();ctx.arc(cx,cy,6,0,Math.PI*2);ctx.fill();
|
|
1592
|
+
ctx.save();ctx.translate(cx,cy);
|
|
1593
|
+
ctx.strokeStyle=C.bg;ctx.lineWidth=1.2;ctx.lineCap='round';
|
|
1594
|
+
ctx.beginPath();
|
|
1595
|
+
if(many){
|
|
1596
|
+
ctx.moveTo(-4,-3);ctx.lineTo(4,0);ctx.moveTo(-4,3);ctx.lineTo(4,0);
|
|
1597
|
+
ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(-4,-3);ctx.lineTo(-4,3);
|
|
1598
|
+
}else{
|
|
1599
|
+
ctx.moveTo(-2,-3);ctx.lineTo(-2,3);ctx.moveTo(2,-3);ctx.lineTo(2,3);
|
|
1600
|
+
}
|
|
1601
|
+
ctx.stroke();ctx.restore();
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1605
|
+
x=14;
|
|
1606
|
+
var pairs=[
|
|
1607
|
+
{a:false,b:true,col:C.m2o,l:'many2one (N:1)'},
|
|
1608
|
+
{a:false,b:false,col:C.o2o,l:'one2one (1:1)'},
|
|
1609
|
+
{a:false,b:true,col:C.m2m,l:'pivot edge (1:N)'}
|
|
1610
|
+
];
|
|
1611
|
+
pairs.forEach(function(p){
|
|
1612
|
+
ctx.strokeStyle=p.col+'55';ctx.lineWidth=1.2;ctx.setLineDash(p.col===C.m2m?[4,3]:[]);
|
|
1613
|
+
ctx.beginPath();ctx.moveTo(x+8,y2);ctx.lineTo(x+56,y2);ctx.stroke();
|
|
1614
|
+
ctx.setLineDash([]);
|
|
1615
|
+
badge2(x+8,y2,p.a,p.col);
|
|
1616
|
+
badge2(x+56,y2,p.b,p.col);
|
|
1617
|
+
ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
|
|
1618
|
+
ctx.fillText(p.l,x+66,y2);
|
|
1619
|
+
x+=66+ctx.measureText(p.l).width+24;
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
ctx.fillStyle='#3d444d';ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
|
|
1623
|
+
ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y2);
|
|
1624
|
+
ctx.textBaseline='alphabetic';
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function draw(){
|
|
1628
|
+
var W=cv.width/dpr,H=cv.height/dpr;
|
|
1629
|
+
ctx.setTransform(dpr,0,0,dpr,0,0);
|
|
1630
|
+
ctx.clearRect(0,0,W,H);
|
|
1631
|
+
ctx.fillStyle=C.bg;ctx.fillRect(0,0,W,H);
|
|
1632
|
+
ctx.save();ctx.translate(pan.x,pan.y);ctx.scale(zm,zm);
|
|
1633
|
+
badges=[];
|
|
1634
|
+
var edges=collectEdges();
|
|
1635
|
+
edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.manyA,e.manyB,e.off);});
|
|
1636
|
+
nodes.forEach(function(n){drawNode(n);});
|
|
1637
|
+
badges.forEach(function(b){drawBadge(b);});
|
|
1638
|
+
ctx.restore();
|
|
1639
|
+
drawLegend();
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// \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
|
|
1643
|
+
var drag=null,dox=0,doy=0,isPan=false,ps={x:0,y:0};
|
|
1644
|
+
|
|
1645
|
+
function toW(mx,my){return{x:(mx-pan.x)/zm,y:(my-pan.y)/zm};}
|
|
1646
|
+
function hitTest(wx,wy){
|
|
1647
|
+
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;}
|
|
1648
|
+
return null;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
cv.addEventListener('mousedown',function(ev){
|
|
1652
|
+
var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
|
|
1653
|
+
var w=toW(mx,my),h=hitTest(w.x,w.y);
|
|
1654
|
+
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};}
|
|
1655
|
+
cv.style.cursor='grabbing';
|
|
1656
|
+
});
|
|
1657
|
+
window.addEventListener('mousemove',function(ev){
|
|
1658
|
+
if(!drag&&!isPan)return;
|
|
1659
|
+
var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
|
|
1660
|
+
if(drag){var w=toW(mx,my);drag.x=w.x-dox;drag.y=w.y-doy;}
|
|
1661
|
+
else{pan.x=mx-ps.x;pan.y=my-ps.y;}
|
|
1662
|
+
draw();
|
|
1663
|
+
});
|
|
1664
|
+
window.addEventListener('mouseup',function(){drag=null;isPan=false;cv.style.cursor='grab';});
|
|
1665
|
+
cv.addEventListener('wheel',function(ev){
|
|
1666
|
+
ev.preventDefault();
|
|
1667
|
+
var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
|
|
1668
|
+
var d=ev.deltaY<0?1.1:0.91,nz=Math.max(0.25,Math.min(3,zm*d));
|
|
1669
|
+
pan.x=mx-(mx-pan.x)*(nz/zm);pan.y=my-(my-pan.y)*(nz/zm);zm=nz;draw();
|
|
1670
|
+
},{passive:false});
|
|
1671
|
+
cv.addEventListener('dblclick',function(){
|
|
1672
|
+
nodes.forEach(function(n,i){n.x=origPos[i].x;n.y=origPos[i].y;});
|
|
1673
|
+
var r=cv.getBoundingClientRect();fitView(r.width,r.height);draw();
|
|
1674
|
+
});
|
|
1675
|
+
window.addEventListener('resize',function(){
|
|
1676
|
+
resize();var r=cv.getBoundingClientRect();
|
|
1677
|
+
computeLayout(r.width,r.height);savePos();fitView(r.width,r.height);draw();
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
document.getElementById('er-dl').addEventListener('click',function(){
|
|
1681
|
+
var SCALE=3;
|
|
1682
|
+
var cw=cv.width/dpr,ch=cv.height/dpr;
|
|
1683
|
+
var oc=document.createElement('canvas');
|
|
1684
|
+
oc.width=Math.round(cw*SCALE);oc.height=Math.round(ch*SCALE);
|
|
1685
|
+
var sav={cv:cv,ctx:ctx,dpr:dpr};
|
|
1686
|
+
cv=oc;ctx=oc.getContext('2d');dpr=SCALE;
|
|
1687
|
+
draw();
|
|
1688
|
+
cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;
|
|
1689
|
+
var a=document.createElement('a');
|
|
1690
|
+
a.download='er-diagram.png';a.href=oc.toDataURL('image/png');a.click();
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
// \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
|
|
1694
|
+
requestAnimationFrame(function(){
|
|
1695
|
+
resize();
|
|
1696
|
+
var r=cv.getBoundingClientRect();
|
|
1697
|
+
computeLayout(r.width,r.height);
|
|
1698
|
+
var h=requiredH();
|
|
1699
|
+
resize(h);r=cv.getBoundingClientRect();
|
|
1700
|
+
computeLayout(r.width,r.height);
|
|
1701
|
+
savePos();fitView(r.width,r.height);draw();
|
|
1702
|
+
});
|
|
1703
|
+
})();
|
|
1704
|
+
`}</script>`
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1013
1708
|
// src/router/templates/about.template.ts
|
|
1014
1709
|
var _dir = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
1015
1710
|
var LOGO_SRC = (() => {
|
|
1016
1711
|
try {
|
|
1017
|
-
const buf = (0, import_node_fs3.readFileSync)((0, import_node_path3.join)(_dir, "../../assets/
|
|
1018
|
-
return `data:image/
|
|
1712
|
+
const buf = (0, import_node_fs3.readFileSync)((0, import_node_path3.join)(_dir, "../../assets/yrest-lockup.svg"));
|
|
1713
|
+
return `data:image/svg+xml;base64,${buf.toString("base64")}`;
|
|
1019
1714
|
} catch {
|
|
1020
1715
|
return "";
|
|
1021
1716
|
}
|
|
@@ -1026,6 +1721,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1026
1721
|
const customRoutes = storage.getRoutes();
|
|
1027
1722
|
const base = options.base;
|
|
1028
1723
|
const host = `http://${options.host}:${options.port}`;
|
|
1724
|
+
const swaggerUrl = `https://petstore.swagger.io/?url=${encodeURIComponent(`${host}/_openapi.json`)}`;
|
|
1029
1725
|
const modes = [];
|
|
1030
1726
|
if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
|
|
1031
1727
|
if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
|
|
@@ -1036,9 +1732,12 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1036
1732
|
if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
|
|
1037
1733
|
if (options.idStrategy !== "increment")
|
|
1038
1734
|
modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
|
|
1735
|
+
const erDiagram = erDiagramSection(generateERData(storage));
|
|
1039
1736
|
const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
|
|
1040
1737
|
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.`;
|
|
1041
|
-
return
|
|
1738
|
+
return (
|
|
1739
|
+
/*html*/
|
|
1740
|
+
`<!DOCTYPE html>
|
|
1042
1741
|
<html lang="en">
|
|
1043
1742
|
<head>
|
|
1044
1743
|
<meta charset="UTF-8">
|
|
@@ -1065,20 +1764,30 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1065
1764
|
width: 100%;
|
|
1066
1765
|
background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
|
|
1067
1766
|
border-bottom: 1px solid var(--border);
|
|
1068
|
-
padding:
|
|
1767
|
+
padding: 20px 32px 16px;
|
|
1069
1768
|
}
|
|
1070
|
-
.banner-inner {
|
|
1071
|
-
.banner
|
|
1769
|
+
.banner-inner { margin: 0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
|
|
1770
|
+
.banner-left { flex:1; min-width:0; }
|
|
1771
|
+
.banner h1 { font-size: clamp(24px, 4vw, 40px); font-weight: 800; letter-spacing: -1.5px; line-height: 1; }
|
|
1072
1772
|
.banner h1 .y { color: var(--text); }
|
|
1073
1773
|
.banner h1 .rest { color: var(--accent); }
|
|
1074
|
-
.banner p { color: var(--text-muted); margin-top:
|
|
1075
|
-
.banner-meta { display: flex; gap:
|
|
1076
|
-
.banner-meta span { color: var(--text-muted); font-size:
|
|
1774
|
+
.banner p { color: var(--text-muted); margin-top: 6px; font-size: 14px; }
|
|
1775
|
+
.banner-meta { display: flex; gap: 20px; margin-top: 10px; flex-wrap: wrap; }
|
|
1776
|
+
.banner-meta span { color: var(--text-muted); font-size: 12px; }
|
|
1077
1777
|
.banner-meta span strong { color: var(--text); font-family: monospace; }
|
|
1778
|
+
.api-links { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
|
1779
|
+
.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; }
|
|
1780
|
+
.btn-api:hover { border-color:var(--border-hi); color:var(--text); }
|
|
1781
|
+
.btn-swagger { border-color:var(--accent); color:var(--accent); }
|
|
1782
|
+
.btn-swagger:hover { background:#58a6ff18; }
|
|
1078
1783
|
|
|
1079
1784
|
/* \u2500\u2500 Layout \u2500\u2500 */
|
|
1080
|
-
.wrap {
|
|
1785
|
+
.wrap { padding: 24px 32px 48px; }
|
|
1081
1786
|
h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
|
|
1787
|
+
.about-grid { display:grid; grid-template-columns:5fr 7fr; gap:28px; align-items:start; }
|
|
1788
|
+
@media(max-width:960px) { .about-grid { grid-template-columns:1fr; } }
|
|
1789
|
+
.col-left, .col-right { min-width:0; }
|
|
1790
|
+
.col-left > h2:first-child, .col-right > h2:first-child { margin-top:0; }
|
|
1082
1791
|
|
|
1083
1792
|
/* \u2500\u2500 Cards \u2500\u2500 */
|
|
1084
1793
|
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
|
|
@@ -1094,10 +1803,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1094
1803
|
.modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
|
|
1095
1804
|
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
|
1096
1805
|
|
|
1097
|
-
/* \u2500\u2500 Endpoints
|
|
1098
|
-
.endpoints-grid { display:
|
|
1099
|
-
@media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
|
|
1100
|
-
.nested-card { grid-column: 1 / -1; }
|
|
1806
|
+
/* \u2500\u2500 Endpoints list \u2500\u2500 */
|
|
1807
|
+
.endpoints-grid { display: flex; flex-direction: column; gap: 10px; }
|
|
1101
1808
|
|
|
1102
1809
|
/* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
|
|
1103
1810
|
.resource-card {
|
|
@@ -1162,64 +1869,80 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
|
|
|
1162
1869
|
|
|
1163
1870
|
<div class="banner">
|
|
1164
1871
|
<div class="banner-inner">
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
<
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1872
|
+
<div class="banner-left">
|
|
1873
|
+
${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>`}
|
|
1874
|
+
<p>Zero-config REST API mock server</p>
|
|
1875
|
+
<div class="banner-meta">
|
|
1876
|
+
<span>URL <strong>${host}</strong></span>
|
|
1877
|
+
<span>Base <strong>${base || "/"}</strong></span>
|
|
1878
|
+
<span>File <strong>${options.file}</strong></span>
|
|
1879
|
+
<span>Collections <strong>${collections.length}</strong></span>
|
|
1880
|
+
</div>
|
|
1881
|
+
</div>
|
|
1882
|
+
<div class="api-links">
|
|
1883
|
+
<a href="/_openapi" target="_blank" class="btn-api">OpenAPI YAML</a>
|
|
1884
|
+
<a href="/_openapi.json" target="_blank" class="btn-api">OpenAPI JSON</a>
|
|
1885
|
+
<a href="${swaggerUrl}" target="_blank" class="btn-api btn-swagger">Swagger UI \u2197</a>
|
|
1172
1886
|
</div>
|
|
1173
1887
|
</div>
|
|
1174
1888
|
</div>
|
|
1175
1889
|
|
|
1176
1890
|
<div class="wrap">
|
|
1891
|
+
<div class="about-grid">
|
|
1892
|
+
|
|
1893
|
+
<div class="col-left">
|
|
1894
|
+
<h2>Active Modes</h2>
|
|
1895
|
+
<div class="card">
|
|
1896
|
+
<div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
|
|
1897
|
+
${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>` : ""}
|
|
1898
|
+
</div>
|
|
1899
|
+
|
|
1900
|
+
<h2>Endpoints</h2>
|
|
1901
|
+
<div class="endpoints-grid">
|
|
1902
|
+
${accordions}
|
|
1903
|
+
${nestedRoutesAccordion(relations, base)}
|
|
1904
|
+
${options.snapshot ? snapshotAccordion() : ""}
|
|
1905
|
+
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1906
|
+
${handlersAccordion(handlers, customRoutes, base)}
|
|
1907
|
+
</div>
|
|
1908
|
+
|
|
1909
|
+
<h2>Query Parameters</h2>
|
|
1910
|
+
<div class="card">
|
|
1911
|
+
<table class="param-table">
|
|
1912
|
+
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
1913
|
+
<tbody>
|
|
1914
|
+
<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>
|
|
1915
|
+
<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>
|
|
1916
|
+
<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>
|
|
1917
|
+
<tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
|
|
1918
|
+
<tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
|
|
1919
|
+
<tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
|
|
1920
|
+
<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>
|
|
1921
|
+
<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>
|
|
1922
|
+
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
1923
|
+
<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>
|
|
1924
|
+
<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>
|
|
1925
|
+
<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>
|
|
1926
|
+
</tbody>
|
|
1927
|
+
</table>
|
|
1928
|
+
</div>
|
|
1929
|
+
</div>
|
|
1177
1930
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
</div>
|
|
1183
|
-
|
|
1184
|
-
<h2>Endpoints</h2>
|
|
1185
|
-
<div class="endpoints-grid">
|
|
1186
|
-
${accordions}
|
|
1187
|
-
${nestedRoutesAccordion(relations, base)}
|
|
1188
|
-
${options.snapshot ? snapshotAccordion() : ""}
|
|
1189
|
-
${customRoutesAccordion(customRoutes, base, handlers)}
|
|
1190
|
-
${handlersAccordion(handlers, customRoutes, base)}
|
|
1191
|
-
</div>
|
|
1931
|
+
<div class="col-right">
|
|
1932
|
+
${erDiagram}
|
|
1933
|
+
${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
|
|
1934
|
+
</div>
|
|
1192
1935
|
|
|
1193
|
-
<h2>Query Parameters</h2>
|
|
1194
|
-
<div class="card">
|
|
1195
|
-
<table class="param-table">
|
|
1196
|
-
<thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
|
|
1197
|
-
<tbody>
|
|
1198
|
-
<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>
|
|
1199
|
-
<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>
|
|
1200
|
-
<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>
|
|
1201
|
-
<tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
|
|
1202
|
-
<tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
|
|
1203
|
-
<tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
|
|
1204
|
-
<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>
|
|
1205
|
-
<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>
|
|
1206
|
-
<tr><td><code>?_page & ?_limit</code></td><td><code>?_page=2&_limit=10</code></td><td>${paginationDesc}</td></tr>
|
|
1207
|
-
<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>
|
|
1208
|
-
<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>
|
|
1209
|
-
<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>
|
|
1210
|
-
</tbody>
|
|
1211
|
-
</table>
|
|
1212
1936
|
</div>
|
|
1213
1937
|
|
|
1214
|
-
${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
|
|
1215
|
-
|
|
1216
1938
|
<footer>
|
|
1217
1939
|
Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> \xB7 <a href="/_about">/_about</a>
|
|
1218
1940
|
</footer>
|
|
1219
1941
|
|
|
1220
1942
|
</div>
|
|
1221
1943
|
</body>
|
|
1222
|
-
</html
|
|
1944
|
+
</html>`
|
|
1945
|
+
);
|
|
1223
1946
|
}
|
|
1224
1947
|
|
|
1225
1948
|
// src/router/routes/about.routes.ts
|
|
@@ -1683,7 +2406,7 @@ var CustomRouteCommand = class {
|
|
|
1683
2406
|
url,
|
|
1684
2407
|
handler: async (req, reply) => {
|
|
1685
2408
|
if (route.delay && route.delay > 0) {
|
|
1686
|
-
await new Promise((
|
|
2409
|
+
await new Promise((resolve6) => setTimeout(resolve6, route.delay));
|
|
1687
2410
|
}
|
|
1688
2411
|
if (route.error) {
|
|
1689
2412
|
const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
|
|
@@ -1807,12 +2530,23 @@ var NestedRouteCommand = class {
|
|
|
1807
2530
|
relations;
|
|
1808
2531
|
base;
|
|
1809
2532
|
register(server) {
|
|
2533
|
+
const registered = /* @__PURE__ */ new Set();
|
|
1810
2534
|
for (const [source, fields] of Object.entries(this.relations)) {
|
|
1811
2535
|
for (const [key, def] of Object.entries(fields)) {
|
|
1812
2536
|
if (def.type === "many2many") {
|
|
1813
|
-
this.
|
|
2537
|
+
const fwd = `${this.base}/${source}/:id/${key}`;
|
|
2538
|
+
const inv = `${this.base}/${def.target}/:id/${source}`;
|
|
2539
|
+
if (!registered.has(fwd) && !registered.has(inv)) {
|
|
2540
|
+
registered.add(fwd);
|
|
2541
|
+
registered.add(inv);
|
|
2542
|
+
this.registerMany2Many(server, source, key, def);
|
|
2543
|
+
}
|
|
1814
2544
|
} else {
|
|
1815
|
-
|
|
2545
|
+
const collPath = `${this.base}/${def.target}/:id/${source}`;
|
|
2546
|
+
if (!registered.has(collPath)) {
|
|
2547
|
+
registered.add(collPath);
|
|
2548
|
+
this.registerFkRelation(server, source, key, def.target, def.type);
|
|
2549
|
+
}
|
|
1816
2550
|
}
|
|
1817
2551
|
}
|
|
1818
2552
|
}
|
|
@@ -1871,6 +2605,371 @@ var NestedRouteCommand = class {
|
|
|
1871
2605
|
}
|
|
1872
2606
|
};
|
|
1873
2607
|
|
|
2608
|
+
// src/openapi/inferSchema.ts
|
|
2609
|
+
function buildCollectionSchema(items, fieldDefs = {}) {
|
|
2610
|
+
const sample = items.slice(0, 10);
|
|
2611
|
+
const inferredTypes = /* @__PURE__ */ new Map();
|
|
2612
|
+
for (const item of sample) {
|
|
2613
|
+
for (const [key, value] of Object.entries(item)) {
|
|
2614
|
+
if (!inferredTypes.has(key)) {
|
|
2615
|
+
inferredTypes.set(key, jsToOpenApiType(value));
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
|
|
2620
|
+
const properties = {};
|
|
2621
|
+
const required = [];
|
|
2622
|
+
for (const field of allFields) {
|
|
2623
|
+
const def = fieldDefs[field];
|
|
2624
|
+
const inferred = inferredTypes.get(field) ?? "string";
|
|
2625
|
+
const prop = {
|
|
2626
|
+
type: def?.type ?? inferred
|
|
2627
|
+
};
|
|
2628
|
+
if (def?.format) prop.format = def.format;
|
|
2629
|
+
if (def?.description) prop.description = def.description;
|
|
2630
|
+
if (def?.enum) prop.enum = def.enum;
|
|
2631
|
+
if (def?.default !== void 0) prop.default = def.default;
|
|
2632
|
+
properties[field] = prop;
|
|
2633
|
+
if (def?.required === true) required.push(field);
|
|
2634
|
+
}
|
|
2635
|
+
const schema = { type: "object", properties };
|
|
2636
|
+
if (required.length > 0) schema.required = required;
|
|
2637
|
+
return schema;
|
|
2638
|
+
}
|
|
2639
|
+
function jsToOpenApiType(value) {
|
|
2640
|
+
if (value === null || value === void 0) return "string";
|
|
2641
|
+
if (typeof value === "boolean") return "boolean";
|
|
2642
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
2643
|
+
if (Array.isArray(value)) return "array";
|
|
2644
|
+
if (typeof value === "object") return "object";
|
|
2645
|
+
return "string";
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
// src/openapi/buildPaths.ts
|
|
2649
|
+
var COLLECTION_QUERY_PARAMS = [
|
|
2650
|
+
{ name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
|
|
2651
|
+
{ name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
2652
|
+
{ name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
|
|
2653
|
+
{
|
|
2654
|
+
name: "_order",
|
|
2655
|
+
in: "query",
|
|
2656
|
+
schema: { type: "string", enum: ["asc", "desc"] },
|
|
2657
|
+
description: "Sort direction"
|
|
2658
|
+
},
|
|
2659
|
+
{
|
|
2660
|
+
name: "_q",
|
|
2661
|
+
in: "query",
|
|
2662
|
+
schema: { type: "string" },
|
|
2663
|
+
description: "Full-text search across all scalar fields (case-insensitive)"
|
|
2664
|
+
},
|
|
2665
|
+
{
|
|
2666
|
+
name: "_expand",
|
|
2667
|
+
in: "query",
|
|
2668
|
+
schema: { type: "string" },
|
|
2669
|
+
description: "Embed related parent object inline (e.g. ?_expand=user)"
|
|
2670
|
+
},
|
|
2671
|
+
{
|
|
2672
|
+
name: "_embed",
|
|
2673
|
+
in: "query",
|
|
2674
|
+
schema: { type: "string" },
|
|
2675
|
+
description: "Embed child collection into each item (e.g. ?_embed=posts)"
|
|
2676
|
+
},
|
|
2677
|
+
{
|
|
2678
|
+
name: "_fields",
|
|
2679
|
+
in: "query",
|
|
2680
|
+
schema: { type: "string" },
|
|
2681
|
+
description: "Comma-separated field projection (e.g. ?_fields=id,name)"
|
|
2682
|
+
}
|
|
2683
|
+
];
|
|
2684
|
+
var ID_PATH_PARAM = {
|
|
2685
|
+
name: "id",
|
|
2686
|
+
in: "path",
|
|
2687
|
+
required: true,
|
|
2688
|
+
schema: { type: "string" },
|
|
2689
|
+
description: "Item id"
|
|
2690
|
+
};
|
|
2691
|
+
function toOpenApiPath(fastifyPath) {
|
|
2692
|
+
return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
2693
|
+
}
|
|
2694
|
+
function extractPathParams(fastifyPath) {
|
|
2695
|
+
const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
|
|
2696
|
+
return matches.map((m) => ({
|
|
2697
|
+
name: m.slice(1),
|
|
2698
|
+
in: "path",
|
|
2699
|
+
required: true,
|
|
2700
|
+
schema: { type: "string" }
|
|
2701
|
+
}));
|
|
2702
|
+
}
|
|
2703
|
+
function singular(name) {
|
|
2704
|
+
return name.endsWith("s") ? name.slice(0, -1) : name;
|
|
2705
|
+
}
|
|
2706
|
+
function schemaRef(name) {
|
|
2707
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
2708
|
+
}
|
|
2709
|
+
function jsonContent(schema) {
|
|
2710
|
+
return { "application/json": { schema } };
|
|
2711
|
+
}
|
|
2712
|
+
function ok(schema, description = "OK") {
|
|
2713
|
+
return { description, content: jsonContent(schema) };
|
|
2714
|
+
}
|
|
2715
|
+
function buildCrudPaths(collection, base, schemaName) {
|
|
2716
|
+
const ref = schemaRef(schemaName);
|
|
2717
|
+
const tag = collection;
|
|
2718
|
+
const sing = singular(collection);
|
|
2719
|
+
const collPath = `${base}/${collection}`;
|
|
2720
|
+
const itemPath = `${base}/${collection}/{id}`;
|
|
2721
|
+
return {
|
|
2722
|
+
[collPath]: {
|
|
2723
|
+
get: {
|
|
2724
|
+
summary: `List ${collection}`,
|
|
2725
|
+
tags: [tag],
|
|
2726
|
+
parameters: COLLECTION_QUERY_PARAMS,
|
|
2727
|
+
responses: {
|
|
2728
|
+
"200": {
|
|
2729
|
+
description: "OK",
|
|
2730
|
+
content: jsonContent({ type: "array", items: ref }),
|
|
2731
|
+
headers: {
|
|
2732
|
+
"X-Total-Count": {
|
|
2733
|
+
description: "Total items (when using ?_page / ?_limit)",
|
|
2734
|
+
schema: { type: "integer" }
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
},
|
|
2740
|
+
post: {
|
|
2741
|
+
summary: `Create ${sing}`,
|
|
2742
|
+
tags: [tag],
|
|
2743
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2744
|
+
responses: { "201": ok(ref, "Created") }
|
|
2745
|
+
}
|
|
2746
|
+
},
|
|
2747
|
+
[itemPath]: {
|
|
2748
|
+
get: {
|
|
2749
|
+
summary: `Get ${sing}`,
|
|
2750
|
+
tags: [tag],
|
|
2751
|
+
parameters: [
|
|
2752
|
+
ID_PATH_PARAM,
|
|
2753
|
+
...COLLECTION_QUERY_PARAMS.filter(
|
|
2754
|
+
(p) => ["_expand", "_embed", "_fields"].includes(p.name)
|
|
2755
|
+
)
|
|
2756
|
+
],
|
|
2757
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2758
|
+
},
|
|
2759
|
+
put: {
|
|
2760
|
+
summary: `Replace ${sing}`,
|
|
2761
|
+
tags: [tag],
|
|
2762
|
+
parameters: [ID_PATH_PARAM],
|
|
2763
|
+
requestBody: { required: true, content: jsonContent(ref) },
|
|
2764
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2765
|
+
},
|
|
2766
|
+
patch: {
|
|
2767
|
+
summary: `Update ${sing}`,
|
|
2768
|
+
tags: [tag],
|
|
2769
|
+
parameters: [ID_PATH_PARAM],
|
|
2770
|
+
requestBody: { required: false, content: jsonContent(ref) },
|
|
2771
|
+
responses: { "200": ok(ref), "404": { description: "Not found" } }
|
|
2772
|
+
},
|
|
2773
|
+
delete: {
|
|
2774
|
+
summary: `Delete ${sing}`,
|
|
2775
|
+
tags: [tag],
|
|
2776
|
+
parameters: [ID_PATH_PARAM],
|
|
2777
|
+
responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
function buildRelationPaths(relations, base) {
|
|
2783
|
+
const paths = {};
|
|
2784
|
+
for (const [source, fields] of Object.entries(relations)) {
|
|
2785
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
2786
|
+
if (def.type === "many2many") {
|
|
2787
|
+
const forwardPath = `${base}/${source}/{id}/${key}`;
|
|
2788
|
+
const inversePath = `${base}/${def.target}/{id}/${source}`;
|
|
2789
|
+
paths[forwardPath] = {
|
|
2790
|
+
get: {
|
|
2791
|
+
summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
|
|
2792
|
+
tags: [source],
|
|
2793
|
+
parameters: [ID_PATH_PARAM],
|
|
2794
|
+
responses: {
|
|
2795
|
+
"200": {
|
|
2796
|
+
description: "OK",
|
|
2797
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2798
|
+
},
|
|
2799
|
+
"404": { description: `${singular(source)} not found` }
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
};
|
|
2803
|
+
paths[inversePath] = {
|
|
2804
|
+
get: {
|
|
2805
|
+
summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
|
|
2806
|
+
tags: [def.target],
|
|
2807
|
+
parameters: [ID_PATH_PARAM],
|
|
2808
|
+
responses: {
|
|
2809
|
+
"200": {
|
|
2810
|
+
description: "OK",
|
|
2811
|
+
content: jsonContent({ type: "array", items: { type: "object" } })
|
|
2812
|
+
},
|
|
2813
|
+
"404": { description: `${singular(def.target)} not found` }
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
};
|
|
2817
|
+
} else {
|
|
2818
|
+
const parentSing = singular(def.target);
|
|
2819
|
+
const collPath = `${base}/${def.target}/{id}/${source}`;
|
|
2820
|
+
const isOne2One = def.type === "one2one";
|
|
2821
|
+
const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
|
|
2822
|
+
paths[collPath] = {
|
|
2823
|
+
get: {
|
|
2824
|
+
summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
|
|
2825
|
+
tags: [def.target],
|
|
2826
|
+
parameters: [ID_PATH_PARAM],
|
|
2827
|
+
responses: {
|
|
2828
|
+
"200": { description: "OK", content: jsonContent(responseSchema) },
|
|
2829
|
+
"404": { description: `${parentSing} not found` }
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
};
|
|
2833
|
+
if (!isOne2One) {
|
|
2834
|
+
const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
|
|
2835
|
+
paths[itemPath] = {
|
|
2836
|
+
get: {
|
|
2837
|
+
summary: `Get single ${singular(source)} scoped to ${parentSing}`,
|
|
2838
|
+
tags: [def.target],
|
|
2839
|
+
parameters: [
|
|
2840
|
+
ID_PATH_PARAM,
|
|
2841
|
+
{ name: "childId", in: "path", required: true, schema: { type: "string" } }
|
|
2842
|
+
],
|
|
2843
|
+
responses: {
|
|
2844
|
+
"200": { description: "OK", content: jsonContent({ type: "object" }) },
|
|
2845
|
+
"404": { description: "Not found" }
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
return paths;
|
|
2854
|
+
}
|
|
2855
|
+
function buildCustomRoutePaths(routes, base) {
|
|
2856
|
+
const paths = {};
|
|
2857
|
+
for (const route of routes) {
|
|
2858
|
+
const openApiPath = toOpenApiPath(`${base}${route.path}`);
|
|
2859
|
+
const method = route.method.toLowerCase();
|
|
2860
|
+
const pathParams = extractPathParams(route.path);
|
|
2861
|
+
const responses = {};
|
|
2862
|
+
if (route.error) {
|
|
2863
|
+
responses[String(route.error)] = { description: `Forced error ${route.error}` };
|
|
2864
|
+
} else {
|
|
2865
|
+
const statuses = /* @__PURE__ */ new Set();
|
|
2866
|
+
for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
|
|
2867
|
+
if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
|
|
2868
|
+
if (route.response) statuses.add(route.response.status ?? 200);
|
|
2869
|
+
if (statuses.size === 0) statuses.add(200);
|
|
2870
|
+
for (const status of statuses) {
|
|
2871
|
+
const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
|
|
2872
|
+
responses[String(status)] = {
|
|
2873
|
+
description: status < 400 ? "OK" : "Error",
|
|
2874
|
+
...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
|
|
2879
|
+
const operation = {
|
|
2880
|
+
summary: `${route.method.toUpperCase()} ${route.path}`,
|
|
2881
|
+
description: desc,
|
|
2882
|
+
tags: ["custom"],
|
|
2883
|
+
...pathParams.length > 0 ? { parameters: pathParams } : {},
|
|
2884
|
+
responses
|
|
2885
|
+
};
|
|
2886
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
2887
|
+
paths[openApiPath][method] = operation;
|
|
2888
|
+
}
|
|
2889
|
+
return paths;
|
|
2890
|
+
}
|
|
2891
|
+
function inferResponseSchema(body) {
|
|
2892
|
+
if (body === null || body === void 0) return {};
|
|
2893
|
+
if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
|
|
2894
|
+
const properties = {};
|
|
2895
|
+
for (const [key, value] of Object.entries(body)) {
|
|
2896
|
+
properties[key] = { type: jsToOpenApiType2(value) };
|
|
2897
|
+
}
|
|
2898
|
+
return { type: "object", properties };
|
|
2899
|
+
}
|
|
2900
|
+
function jsToOpenApiType2(value) {
|
|
2901
|
+
if (value === null || value === void 0) return "string";
|
|
2902
|
+
if (typeof value === "boolean") return "boolean";
|
|
2903
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
2904
|
+
if (Array.isArray(value)) return "array";
|
|
2905
|
+
if (typeof value === "object") return "object";
|
|
2906
|
+
return "string";
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
// src/openapi/generateOpenApi.ts
|
|
2910
|
+
function generateOpenApi(storage, options, title = "yRest API") {
|
|
2911
|
+
const collections = Object.keys(storage.getData());
|
|
2912
|
+
const relations = storage.getRelations();
|
|
2913
|
+
const schemaBlock = storage.getSchema();
|
|
2914
|
+
const customRoutes = storage.getRoutes();
|
|
2915
|
+
const base = options.base ?? "";
|
|
2916
|
+
const schemas = {};
|
|
2917
|
+
for (const collection of collections) {
|
|
2918
|
+
const items = storage.getCollection(collection) ?? [];
|
|
2919
|
+
const fieldDefs = schemaBlock[collection] ?? {};
|
|
2920
|
+
const schemaName = toSchemaName(collection);
|
|
2921
|
+
schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
|
|
2922
|
+
}
|
|
2923
|
+
const paths = {};
|
|
2924
|
+
for (const collection of collections) {
|
|
2925
|
+
const schemaName = toSchemaName(collection);
|
|
2926
|
+
Object.assign(paths, buildCrudPaths(collection, base, schemaName));
|
|
2927
|
+
}
|
|
2928
|
+
Object.assign(paths, buildRelationPaths(relations, base));
|
|
2929
|
+
Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
|
|
2930
|
+
return {
|
|
2931
|
+
openapi: "3.0.3",
|
|
2932
|
+
info: {
|
|
2933
|
+
title,
|
|
2934
|
+
version: "1.0.0",
|
|
2935
|
+
description: "Generated by yRest from db.yml"
|
|
2936
|
+
},
|
|
2937
|
+
servers: [
|
|
2938
|
+
{
|
|
2939
|
+
url: `http://${options.host}:${options.port}${base}`,
|
|
2940
|
+
description: "yRest mock server"
|
|
2941
|
+
}
|
|
2942
|
+
],
|
|
2943
|
+
paths,
|
|
2944
|
+
components: { schemas }
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
function toSchemaName(collection) {
|
|
2948
|
+
const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
|
|
2949
|
+
return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// src/router/routes/openapi.routes.ts
|
|
2953
|
+
var import_yaml2 = require("yaml");
|
|
2954
|
+
var OpenApiRouteCommand = class {
|
|
2955
|
+
constructor(storage, options) {
|
|
2956
|
+
this.storage = storage;
|
|
2957
|
+
this.options = options;
|
|
2958
|
+
}
|
|
2959
|
+
storage;
|
|
2960
|
+
options;
|
|
2961
|
+
register(server) {
|
|
2962
|
+
server.get("/_openapi", (_req, reply) => {
|
|
2963
|
+
const doc = generateOpenApi(this.storage, this.options);
|
|
2964
|
+
reply.header("Content-Type", "text/yaml; charset=utf-8");
|
|
2965
|
+
return reply.send((0, import_yaml2.stringify)(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
|
|
2966
|
+
});
|
|
2967
|
+
server.get("/_openapi.json", (_req, reply) => {
|
|
2968
|
+
return reply.send(generateOpenApi(this.storage, this.options));
|
|
2969
|
+
});
|
|
2970
|
+
}
|
|
2971
|
+
};
|
|
2972
|
+
|
|
1874
2973
|
// src/router/routes/snapshot.routes.ts
|
|
1875
2974
|
var SnapshotRouteCommand = class {
|
|
1876
2975
|
constructor(storage) {
|
|
@@ -1951,6 +3050,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
1951
3050
|
}
|
|
1952
3051
|
const commands = [
|
|
1953
3052
|
new AboutRouteCommand(storage, options, handlers),
|
|
3053
|
+
new OpenApiRouteCommand(storage, options),
|
|
1954
3054
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
1955
3055
|
new CustomRouteCommand(storage, options.base, handlers),
|
|
1956
3056
|
...buildResourceRouteCommands(storage, options)
|
|
@@ -2038,11 +3138,11 @@ var yrestOptionsSchema = import_zod.z.object({
|
|
|
2038
3138
|
|
|
2039
3139
|
// src/config/loadConfigFile.ts
|
|
2040
3140
|
var import_node_fs4 = require("fs");
|
|
2041
|
-
var
|
|
3141
|
+
var import_yaml3 = require("yaml");
|
|
2042
3142
|
function loadConfigFile(configPath) {
|
|
2043
3143
|
if (!(0, import_node_fs4.existsSync)(configPath)) return {};
|
|
2044
3144
|
const raw = (0, import_node_fs4.readFileSync)(configPath, "utf8");
|
|
2045
|
-
return (0,
|
|
3145
|
+
return (0, import_yaml3.parse)(raw) ?? {};
|
|
2046
3146
|
}
|
|
2047
3147
|
|
|
2048
3148
|
// src/utils/handlers.ts
|
|
@@ -2195,7 +3295,7 @@ function registerServe(program2) {
|
|
|
2195
3295
|
// src/cli/commands/handler.ts
|
|
2196
3296
|
var import_node_fs7 = require("fs");
|
|
2197
3297
|
var import_node_path5 = require("path");
|
|
2198
|
-
var
|
|
3298
|
+
var import_yaml4 = require("yaml");
|
|
2199
3299
|
var HANDLERS_FILE_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
|
|
2200
3300
|
// Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
|
|
2201
3301
|
// See https://github.com/aggiovato/yaml-rest for full documentation
|
|
@@ -2251,13 +3351,13 @@ function registerHandler(program2) {
|
|
|
2251
3351
|
console.error(` Error: database file not found at ${dbPath}`);
|
|
2252
3352
|
process.exit(1);
|
|
2253
3353
|
}
|
|
2254
|
-
const raw = (0,
|
|
3354
|
+
const raw = (0, import_yaml4.parse)((0, import_node_fs7.readFileSync)(dbPath, "utf8")) ?? {};
|
|
2255
3355
|
if (!Array.isArray(raw["_routes"])) raw["_routes"] = [];
|
|
2256
3356
|
const routes = raw["_routes"];
|
|
2257
3357
|
const alreadyRegistered = routes.some((r) => r["handler"] === name);
|
|
2258
3358
|
if (!alreadyRegistered) {
|
|
2259
3359
|
routes.push({ method: flags.method.toUpperCase(), path: flags.path, handler: name });
|
|
2260
|
-
(0, import_node_fs7.writeFileSync)(dbPath, (0,
|
|
3360
|
+
(0, import_node_fs7.writeFileSync)(dbPath, (0, import_yaml4.stringify)(raw), "utf8");
|
|
2261
3361
|
console.log(` Added _routes entry to ${(0, import_node_path5.basename)(dbPath)}`);
|
|
2262
3362
|
} else {
|
|
2263
3363
|
console.log(` Handler "${name}" already in _routes \u2014 skipped`);
|
|
@@ -2280,6 +3380,33 @@ function registerHandler(program2) {
|
|
|
2280
3380
|
});
|
|
2281
3381
|
}
|
|
2282
3382
|
|
|
3383
|
+
// src/cli/commands/openapi.ts
|
|
3384
|
+
var import_node_fs8 = require("fs");
|
|
3385
|
+
var import_node_path6 = require("path");
|
|
3386
|
+
var import_yaml5 = require("yaml");
|
|
3387
|
+
function registerOpenApi(program2) {
|
|
3388
|
+
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) => {
|
|
3389
|
+
const storage = createYrestStorage((0, import_node_path6.resolve)(file));
|
|
3390
|
+
const options = yrestOptionsSchema.parse({
|
|
3391
|
+
file,
|
|
3392
|
+
base: opts["base"] || void 0,
|
|
3393
|
+
port: Number(opts["port"]) || 3070,
|
|
3394
|
+
host: opts["host"] || "localhost"
|
|
3395
|
+
});
|
|
3396
|
+
const doc = generateOpenApi(storage, options, opts["title"]);
|
|
3397
|
+
const isJson = opts["format"] === "json";
|
|
3398
|
+
const output = isJson ? JSON.stringify(doc, null, 2) : (0, import_yaml5.stringify)(doc, { lineWidth: 0, aliasDuplicateObjects: false });
|
|
3399
|
+
if (opts["stdout"]) {
|
|
3400
|
+
process.stdout.write(output);
|
|
3401
|
+
return;
|
|
3402
|
+
}
|
|
3403
|
+
const defaultFile = isJson ? "openapi.json" : "openapi.yaml";
|
|
3404
|
+
const outFile = (0, import_node_path6.resolve)(opts["output"] ?? defaultFile);
|
|
3405
|
+
(0, import_node_fs8.writeFileSync)(outFile, output, "utf8");
|
|
3406
|
+
console.log(`\u2713 OpenAPI spec written to ${outFile}`);
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3409
|
+
|
|
2283
3410
|
// src/cli/index.ts
|
|
2284
3411
|
var require2 = (0, import_module.createRequire)(importMetaUrl);
|
|
2285
3412
|
var { version } = require2("../../package.json");
|
|
@@ -2287,4 +3414,5 @@ import_commander.program.name("yrest").description("Zero-config REST API mock se
|
|
|
2287
3414
|
registerInit(import_commander.program);
|
|
2288
3415
|
registerServe(import_commander.program);
|
|
2289
3416
|
registerHandler(import_commander.program);
|
|
3417
|
+
registerOpenApi(import_commander.program);
|
|
2290
3418
|
import_commander.program.parse();
|