postgresdk 0.6.5 → 0.6.7
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/dist/cli.js +585 -78
- package/dist/emit-api-contract.d.ts +60 -0
- package/dist/index.js +585 -78
- package/package.json +2 -1
package/dist/cli.js
CHANGED
@@ -2006,6 +2006,7 @@ function emitHonoRouter(tables, hasAuth, useJsExtensions) {
|
|
2006
2006
|
return `/* Generated. Do not edit. */
|
2007
2007
|
import { Hono } from "hono";
|
2008
2008
|
import { SDK_MANIFEST } from "./sdk-bundle${ext}";
|
2009
|
+
import { getApiContract } from "./api-contract${ext}";
|
2009
2010
|
${imports}
|
2010
2011
|
${hasAuth ? `export { authMiddleware } from "./auth${ext}";` : ""}
|
2011
2012
|
|
@@ -2067,6 +2068,29 @@ ${registrations}
|
|
2067
2068
|
});
|
2068
2069
|
});
|
2069
2070
|
|
2071
|
+
// API Contract endpoints - describes the entire API
|
2072
|
+
router.get("/api/contract", (c) => {
|
2073
|
+
const format = c.req.query("format") || "json";
|
2074
|
+
|
2075
|
+
if (format === "markdown") {
|
2076
|
+
return c.text(getApiContract("markdown") as string, 200, {
|
2077
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
2078
|
+
});
|
2079
|
+
}
|
2080
|
+
|
2081
|
+
return c.json(getApiContract("json"));
|
2082
|
+
});
|
2083
|
+
|
2084
|
+
router.get("/api/contract.json", (c) => {
|
2085
|
+
return c.json(getApiContract("json"));
|
2086
|
+
});
|
2087
|
+
|
2088
|
+
router.get("/api/contract.md", (c) => {
|
2089
|
+
return c.text(getApiContract("markdown") as string, 200, {
|
2090
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
2091
|
+
});
|
2092
|
+
});
|
2093
|
+
|
2070
2094
|
return router;
|
2071
2095
|
}
|
2072
2096
|
|
@@ -2344,35 +2368,44 @@ function emitTableTest(table, clientPath, framework = "vitest") {
|
|
2344
2368
|
const Type = pascal(table.name);
|
2345
2369
|
const tableName = table.name;
|
2346
2370
|
const imports = getFrameworkImports(framework);
|
2347
|
-
const
|
2348
|
-
const
|
2371
|
+
const hasForeignKeys = table.fks.length > 0;
|
2372
|
+
const foreignKeySetup = hasForeignKeys ? generateForeignKeySetup(table, clientPath) : null;
|
2373
|
+
const sampleData = generateSampleDataFromSchema(table, hasForeignKeys);
|
2374
|
+
const updateData = generateUpdateDataFromSchema(table);
|
2349
2375
|
return `${imports}
|
2350
2376
|
import { SDK } from '${clientPath}';
|
2351
2377
|
import type { Insert${Type}, Update${Type}, Select${Type} } from '${clientPath}/types/${tableName}';
|
2378
|
+
${foreignKeySetup?.imports || ""}
|
2352
2379
|
|
2353
2380
|
/**
|
2354
2381
|
* Basic tests for ${tableName} table operations
|
2355
2382
|
*
|
2356
2383
|
* These tests demonstrate basic CRUD operations.
|
2357
|
-
* The test data is auto-generated
|
2384
|
+
* The test data is auto-generated based on your schema.
|
2358
2385
|
*
|
2359
|
-
* If tests fail
|
2360
|
-
* 1. Check
|
2361
|
-
* 2. Update the test data below to match your
|
2362
|
-
* 3. Consider adding
|
2386
|
+
* If tests fail:
|
2387
|
+
* 1. Check the error messages for missing required fields
|
2388
|
+
* 2. Update the test data below to match your business requirements
|
2389
|
+
* 3. Consider adding custom tests for business logic in separate files
|
2363
2390
|
*/
|
2364
2391
|
describe('${Type} SDK Operations', () => {
|
2365
2392
|
let sdk: SDK;
|
2366
2393
|
let createdId: string;
|
2394
|
+
${foreignKeySetup?.variables || ""}
|
2367
2395
|
|
2368
|
-
beforeAll(() => {
|
2396
|
+
beforeAll(async () => {
|
2369
2397
|
sdk = new SDK({
|
2370
2398
|
baseUrl: process.env.API_URL || 'http://localhost:3000',
|
2371
2399
|
auth: process.env.API_KEY ? { apiKey: process.env.API_KEY } : undefined
|
2372
2400
|
});
|
2401
|
+
${foreignKeySetup?.setup || ""}
|
2402
|
+
});
|
2403
|
+
|
2404
|
+
${hasForeignKeys && foreignKeySetup?.cleanup ? `afterAll(async () => {
|
2405
|
+
${foreignKeySetup.cleanup}
|
2373
2406
|
});
|
2374
2407
|
|
2375
|
-
${generateTestCases(table, sampleData, updateData)}
|
2408
|
+
` : ""}${generateTestCases(table, sampleData, updateData, hasForeignKeys)}
|
2376
2409
|
});
|
2377
2410
|
`;
|
2378
2411
|
}
|
@@ -2466,6 +2499,7 @@ version: '3.8'
|
|
2466
2499
|
services:
|
2467
2500
|
postgres:
|
2468
2501
|
image: postgres:17-alpine
|
2502
|
+
container_name: postgresdk-test-database
|
2469
2503
|
environment:
|
2470
2504
|
POSTGRES_USER: testuser
|
2471
2505
|
POSTGRES_PASSWORD: testpass
|
@@ -2504,7 +2538,39 @@ set -e
|
|
2504
2538
|
|
2505
2539
|
SCRIPT_DIR="$( cd "$( dirname "\${BASH_SOURCE[0]}" )" && pwd )"
|
2506
2540
|
|
2507
|
-
|
2541
|
+
# Cleanup function to ensure database is stopped
|
2542
|
+
cleanup() {
|
2543
|
+
echo ""
|
2544
|
+
echo "\uD83E\uDDF9 Cleaning up..."
|
2545
|
+
if [ ! -z "\${SERVER_PID}" ]; then
|
2546
|
+
echo " Stopping API server..."
|
2547
|
+
kill $SERVER_PID 2>/dev/null || true
|
2548
|
+
fi
|
2549
|
+
echo " Stopping test database..."
|
2550
|
+
docker-compose -f "$SCRIPT_DIR/docker-compose.yml" stop 2>/dev/null || true
|
2551
|
+
echo " Done!"
|
2552
|
+
}
|
2553
|
+
|
2554
|
+
# Set up cleanup trap
|
2555
|
+
trap cleanup EXIT INT TERM
|
2556
|
+
|
2557
|
+
# Check for existing PostgreSQL container or connection
|
2558
|
+
echo "\uD83D\uDD0D Checking for existing database connections..."
|
2559
|
+
if docker ps | grep -q "5432->5432"; then
|
2560
|
+
echo "⚠️ Found existing PostgreSQL container on port 5432"
|
2561
|
+
echo " Stopping existing container..."
|
2562
|
+
docker ps --filter "publish=5432" --format "{{.ID}}" | xargs -r docker stop
|
2563
|
+
sleep 2
|
2564
|
+
fi
|
2565
|
+
|
2566
|
+
# Clean up any existing test database container
|
2567
|
+
if docker ps -a | grep -q "postgresdk-test-database"; then
|
2568
|
+
echo "\uD83E\uDDF9 Cleaning up existing test database container..."
|
2569
|
+
docker-compose -f "$SCRIPT_DIR/docker-compose.yml" down -v
|
2570
|
+
sleep 2
|
2571
|
+
fi
|
2572
|
+
|
2573
|
+
echo "\uD83D\uDC33 Starting fresh test database..."
|
2508
2574
|
cd "$SCRIPT_DIR"
|
2509
2575
|
docker-compose up -d --wait
|
2510
2576
|
|
@@ -2526,11 +2592,11 @@ echo "⚠️ TODO: Uncomment and customize the API server startup command below
|
|
2526
2592
|
echo ""
|
2527
2593
|
echo " # Example for Node.js/Bun:"
|
2528
2594
|
echo " # cd ../.. && npm run dev &"
|
2529
|
-
echo " # SERVER_PID
|
2595
|
+
echo " # SERVER_PID=\\$!"
|
2530
2596
|
echo ""
|
2531
2597
|
echo " # Example for custom server file:"
|
2532
2598
|
echo " # cd ../.. && node server.js &"
|
2533
|
-
echo " # SERVER_PID
|
2599
|
+
echo " # SERVER_PID=\\$!"
|
2534
2600
|
echo ""
|
2535
2601
|
echo " Please edit this script to start your API server."
|
2536
2602
|
echo ""
|
@@ -2548,12 +2614,6 @@ ${getTestCommand(framework, runCommand)}
|
|
2548
2614
|
|
2549
2615
|
TEST_EXIT_CODE=$?
|
2550
2616
|
|
2551
|
-
# Cleanup
|
2552
|
-
# if [ ! -z "\${SERVER_PID}" ]; then
|
2553
|
-
# echo "\uD83D\uDED1 Stopping API server..."
|
2554
|
-
# kill $SERVER_PID 2>/dev/null || true
|
2555
|
-
# fi
|
2556
|
-
|
2557
2617
|
if [ $TEST_EXIT_CODE -eq 0 ]; then
|
2558
2618
|
echo "✅ Tests completed successfully!"
|
2559
2619
|
else
|
@@ -2564,12 +2624,76 @@ echo ""
|
|
2564
2624
|
echo "\uD83D\uDCCA Test results saved to:"
|
2565
2625
|
echo " $TEST_RESULTS_DIR/"
|
2566
2626
|
echo ""
|
2567
|
-
echo "
|
2568
|
-
echo "
|
2627
|
+
echo "\uD83D\uDCA1 Tips:"
|
2628
|
+
echo " - Database will be stopped automatically on script exit"
|
2629
|
+
echo " - To manually stop the database: docker-compose -f $SCRIPT_DIR/docker-compose.yml down"
|
2630
|
+
echo " - To reset the database: docker-compose -f $SCRIPT_DIR/docker-compose.yml down -v"
|
2569
2631
|
|
2570
2632
|
exit $TEST_EXIT_CODE
|
2571
2633
|
`;
|
2572
2634
|
}
|
2635
|
+
function generateForeignKeySetup(table, clientPath) {
|
2636
|
+
const imports = [];
|
2637
|
+
const variables = [];
|
2638
|
+
const setupStatements = [];
|
2639
|
+
const cleanupStatements = [];
|
2640
|
+
const foreignTables = new Set;
|
2641
|
+
for (const fk of table.fks) {
|
2642
|
+
const foreignTable = fk.toTable;
|
2643
|
+
if (!foreignTables.has(foreignTable)) {
|
2644
|
+
foreignTables.add(foreignTable);
|
2645
|
+
const ForeignType = pascal(foreignTable);
|
2646
|
+
imports.push(`import type { Insert${ForeignType} } from '${clientPath}/types/${foreignTable}';`);
|
2647
|
+
variables.push(`let ${foreignTable}Id: string;`);
|
2648
|
+
setupStatements.push(`
|
2649
|
+
// Create parent ${foreignTable} record for foreign key reference
|
2650
|
+
const ${foreignTable}Data: Insert${ForeignType} = ${generateMinimalDataForTable(foreignTable)};
|
2651
|
+
const created${ForeignType} = await sdk.${foreignTable}.create(${foreignTable}Data);
|
2652
|
+
${foreignTable}Id = created${ForeignType}.id;`);
|
2653
|
+
cleanupStatements.push(`
|
2654
|
+
// Clean up parent ${foreignTable} record
|
2655
|
+
if (${foreignTable}Id) {
|
2656
|
+
try {
|
2657
|
+
await sdk.${foreignTable}.delete(${foreignTable}Id);
|
2658
|
+
} catch (e) {
|
2659
|
+
// Parent might already be deleted due to cascading
|
2660
|
+
}
|
2661
|
+
}`);
|
2662
|
+
}
|
2663
|
+
}
|
2664
|
+
return {
|
2665
|
+
imports: imports.join(`
|
2666
|
+
`),
|
2667
|
+
variables: variables.join(`
|
2668
|
+
`),
|
2669
|
+
setup: setupStatements.join(""),
|
2670
|
+
cleanup: cleanupStatements.join("")
|
2671
|
+
};
|
2672
|
+
}
|
2673
|
+
function generateMinimalDataForTable(tableName) {
|
2674
|
+
if (tableName.includes("author")) {
|
2675
|
+
return `{ name: 'Test Author' }`;
|
2676
|
+
}
|
2677
|
+
if (tableName.includes("book")) {
|
2678
|
+
return `{ title: 'Test Book' }`;
|
2679
|
+
}
|
2680
|
+
if (tableName.includes("tag")) {
|
2681
|
+
return `{ name: 'Test Tag' }`;
|
2682
|
+
}
|
2683
|
+
if (tableName.includes("user")) {
|
2684
|
+
return `{ name: 'Test User', email: 'test@example.com' }`;
|
2685
|
+
}
|
2686
|
+
if (tableName.includes("category") || tableName.includes("categories")) {
|
2687
|
+
return `{ name: 'Test Category' }`;
|
2688
|
+
}
|
2689
|
+
if (tableName.includes("product")) {
|
2690
|
+
return `{ name: 'Test Product', price: 10.99 }`;
|
2691
|
+
}
|
2692
|
+
if (tableName.includes("order")) {
|
2693
|
+
return `{ total: 100.00, status: 'pending' }`;
|
2694
|
+
}
|
2695
|
+
return `{ name: 'Test ${pascal(tableName)}' }`;
|
2696
|
+
}
|
2573
2697
|
function getTestCommand(framework, baseCommand) {
|
2574
2698
|
switch (framework) {
|
2575
2699
|
case "vitest":
|
@@ -2577,7 +2701,7 @@ function getTestCommand(framework, baseCommand) {
|
|
2577
2701
|
case "jest":
|
2578
2702
|
return `${baseCommand} --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
|
2579
2703
|
case "bun":
|
2580
|
-
return
|
2704
|
+
return `NO_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
|
2581
2705
|
default:
|
2582
2706
|
return `${baseCommand} "$@"`;
|
2583
2707
|
}
|
@@ -2594,22 +2718,45 @@ function getFrameworkImports(framework) {
|
|
2594
2718
|
return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
|
2595
2719
|
}
|
2596
2720
|
}
|
2597
|
-
function
|
2721
|
+
function generateSampleDataFromSchema(table, hasForeignKeys = false) {
|
2598
2722
|
const fields = [];
|
2599
|
-
|
2600
|
-
|
2601
|
-
|
2723
|
+
const foreignKeyColumns = new Map;
|
2724
|
+
if (hasForeignKeys) {
|
2725
|
+
for (const fk of table.fks) {
|
2726
|
+
for (let i = 0;i < fk.from.length; i++) {
|
2727
|
+
const fromCol = fk.from[i];
|
2728
|
+
if (fromCol) {
|
2729
|
+
foreignKeyColumns.set(fromCol, fk.toTable);
|
2730
|
+
}
|
2731
|
+
}
|
2602
2732
|
}
|
2603
|
-
|
2604
|
-
|
2733
|
+
}
|
2734
|
+
for (const col of table.columns) {
|
2735
|
+
if (col.hasDefault) {
|
2736
|
+
const autoGenerated = ["id", "created_at", "updated_at", "created", "updated", "modified_at"];
|
2737
|
+
if (autoGenerated.includes(col.name.toLowerCase())) {
|
2738
|
+
continue;
|
2739
|
+
}
|
2605
2740
|
}
|
2606
|
-
if (col.name === "deleted_at") {
|
2741
|
+
if (col.name === "deleted_at" || col.name === "deleted") {
|
2607
2742
|
continue;
|
2608
2743
|
}
|
2609
|
-
|
2610
|
-
|
2611
|
-
|
2612
|
-
|
2744
|
+
if (!col.nullable) {
|
2745
|
+
const foreignTable = foreignKeyColumns.get(col.name);
|
2746
|
+
if (foreignTable) {
|
2747
|
+
fields.push(` ${col.name}: ${foreignTable}Id`);
|
2748
|
+
} else {
|
2749
|
+
const value = generateValueForColumn(col);
|
2750
|
+
fields.push(` ${col.name}: ${value}`);
|
2751
|
+
}
|
2752
|
+
} else {
|
2753
|
+
const foreignTable = foreignKeyColumns.get(col.name);
|
2754
|
+
if (foreignTable) {
|
2755
|
+
fields.push(` ${col.name}: ${foreignTable}Id`);
|
2756
|
+
} else if (shouldIncludeNullableColumn(col)) {
|
2757
|
+
const value = generateValueForColumn(col);
|
2758
|
+
fields.push(` ${col.name}: ${value}`);
|
2759
|
+
}
|
2613
2760
|
}
|
2614
2761
|
}
|
2615
2762
|
return fields.length > 0 ? `{
|
@@ -2617,14 +2764,23 @@ ${fields.join(`,
|
|
2617
2764
|
`)}
|
2618
2765
|
}` : "{}";
|
2619
2766
|
}
|
2620
|
-
function
|
2767
|
+
function generateUpdateDataFromSchema(table) {
|
2621
2768
|
const fields = [];
|
2622
2769
|
for (const col of table.columns) {
|
2623
|
-
if (
|
2770
|
+
if (table.pk.includes(col.name) || col.hasDefault) {
|
2771
|
+
const autoGenerated = ["id", "created_at", "updated_at", "created", "updated", "modified_at"];
|
2772
|
+
if (autoGenerated.includes(col.name.toLowerCase())) {
|
2773
|
+
continue;
|
2774
|
+
}
|
2775
|
+
}
|
2776
|
+
if (col.name.endsWith("_id")) {
|
2624
2777
|
continue;
|
2625
2778
|
}
|
2626
|
-
if (
|
2627
|
-
|
2779
|
+
if (col.name === "deleted_at" || col.name === "deleted") {
|
2780
|
+
continue;
|
2781
|
+
}
|
2782
|
+
if (!col.nullable || shouldIncludeNullableColumn(col)) {
|
2783
|
+
const value = generateValueForColumn(col, true);
|
2628
2784
|
fields.push(` ${col.name}: ${value}`);
|
2629
2785
|
break;
|
2630
2786
|
}
|
@@ -2634,59 +2790,107 @@ ${fields.join(`,
|
|
2634
2790
|
`)}
|
2635
2791
|
}` : "{}";
|
2636
2792
|
}
|
2637
|
-
function
|
2638
|
-
const
|
2639
|
-
|
2640
|
-
|
2641
|
-
|
2793
|
+
function shouldIncludeNullableColumn(col) {
|
2794
|
+
const importantPatterns = [
|
2795
|
+
"_id",
|
2796
|
+
"_by",
|
2797
|
+
"email",
|
2798
|
+
"name",
|
2799
|
+
"title",
|
2800
|
+
"description",
|
2801
|
+
"phone",
|
2802
|
+
"address",
|
2803
|
+
"status",
|
2804
|
+
"type",
|
2805
|
+
"category",
|
2806
|
+
"price",
|
2807
|
+
"amount",
|
2808
|
+
"quantity",
|
2809
|
+
"url",
|
2810
|
+
"slug"
|
2811
|
+
];
|
2812
|
+
const name = col.name.toLowerCase();
|
2813
|
+
return importantPatterns.some((pattern) => name.includes(pattern));
|
2814
|
+
}
|
2815
|
+
function generateValueForColumn(col, isUpdate = false) {
|
2816
|
+
const name = col.name.toLowerCase();
|
2817
|
+
const type = col.pgType.toLowerCase();
|
2642
2818
|
if (name.includes("email")) {
|
2643
2819
|
return `'test${isUpdate ? ".updated" : ""}@example.com'`;
|
2644
2820
|
}
|
2645
|
-
if (name
|
2646
|
-
return `'
|
2821
|
+
if (name.includes("phone")) {
|
2822
|
+
return `'555-${isUpdate ? "0200" : "0100"}'`;
|
2647
2823
|
}
|
2648
|
-
if (name
|
2649
|
-
return `'${isUpdate ? "
|
2824
|
+
if (name.includes("url") || name.includes("website")) {
|
2825
|
+
return `'https://example.com${isUpdate ? "/updated" : ""}'`;
|
2650
2826
|
}
|
2651
|
-
if (name.includes("
|
2652
|
-
return `'
|
2827
|
+
if (name.includes("password")) {
|
2828
|
+
return `'hashedPassword123'`;
|
2829
|
+
}
|
2830
|
+
if (name === "name" || name.includes("_name") || name.includes("name_")) {
|
2831
|
+
return `'Test ${pascal(col.name)}${isUpdate ? " Updated" : ""}'`;
|
2832
|
+
}
|
2833
|
+
if (name === "title" || name.includes("title")) {
|
2834
|
+
return `'Test Title${isUpdate ? " Updated" : ""}'`;
|
2835
|
+
}
|
2836
|
+
if (name.includes("description") || name === "bio" || name === "about") {
|
2837
|
+
return `'Test description${isUpdate ? " updated" : ""}'`;
|
2653
2838
|
}
|
2654
|
-
if (name
|
2655
|
-
return `'
|
2839
|
+
if (name === "slug") {
|
2840
|
+
return `'test-slug${isUpdate ? "-updated" : ""}'`;
|
2656
2841
|
}
|
2657
|
-
if (name === "
|
2842
|
+
if (name === "status") {
|
2658
2843
|
return `'${isUpdate ? "updated" : "active"}'`;
|
2659
2844
|
}
|
2660
|
-
if (name === "
|
2661
|
-
return `'
|
2845
|
+
if (name === "type" || name === "kind" || name === "category") {
|
2846
|
+
return `'${isUpdate ? "type2" : "type1"}'`;
|
2847
|
+
}
|
2848
|
+
if (name === "color" || name === "colour") {
|
2849
|
+
return `'${isUpdate ? "#FF0000" : "#0000FF"}'`;
|
2850
|
+
}
|
2851
|
+
if (name === "gender") {
|
2852
|
+
return `'${isUpdate ? "F" : "M"}'`;
|
2662
2853
|
}
|
2663
|
-
if (name.includes("
|
2664
|
-
return
|
2854
|
+
if (name.includes("price") || name === "cost" || name === "amount") {
|
2855
|
+
return isUpdate ? "99.99" : "10.50";
|
2665
2856
|
}
|
2666
|
-
if (name
|
2667
|
-
return
|
2857
|
+
if (name === "quantity" || name === "count" || name.includes("qty")) {
|
2858
|
+
return isUpdate ? "5" : "1";
|
2668
2859
|
}
|
2669
|
-
if (name
|
2670
|
-
return
|
2860
|
+
if (name === "age") {
|
2861
|
+
return isUpdate ? "30" : "25";
|
2671
2862
|
}
|
2672
|
-
if (name.includes("
|
2673
|
-
return
|
2863
|
+
if (name.includes("percent") || name === "rate" || name === "ratio") {
|
2864
|
+
return isUpdate ? "0.75" : "0.5";
|
2674
2865
|
}
|
2675
|
-
if (name.includes("
|
2676
|
-
return
|
2866
|
+
if (name.includes("latitude") || name === "lat") {
|
2867
|
+
return "40.7128";
|
2677
2868
|
}
|
2678
|
-
if (name.includes("
|
2679
|
-
return
|
2869
|
+
if (name.includes("longitude") || name === "lng" || name === "lon") {
|
2870
|
+
return "-74.0060";
|
2680
2871
|
}
|
2681
|
-
if (
|
2682
|
-
|
2872
|
+
if (type.includes("date") || type.includes("timestamp")) {
|
2873
|
+
if (name.includes("birth") || name === "dob") {
|
2874
|
+
return `new Date('1990-01-01')`;
|
2875
|
+
}
|
2876
|
+
if (name.includes("end") || name.includes("expire")) {
|
2877
|
+
return `new Date('2025-12-31')`;
|
2878
|
+
}
|
2879
|
+
if (name.includes("start") || name.includes("begin")) {
|
2880
|
+
return `new Date('2024-01-01')`;
|
2881
|
+
}
|
2882
|
+
return `new Date()`;
|
2683
2883
|
}
|
2684
2884
|
switch (type) {
|
2685
2885
|
case "text":
|
2686
2886
|
case "varchar":
|
2687
2887
|
case "char":
|
2688
|
-
|
2888
|
+
case "character varying":
|
2889
|
+
return `'test_value${isUpdate ? "_updated" : ""}'`;
|
2689
2890
|
case "int":
|
2891
|
+
case "int2":
|
2892
|
+
case "int4":
|
2893
|
+
case "int8":
|
2690
2894
|
case "integer":
|
2691
2895
|
case "smallint":
|
2692
2896
|
case "bigint":
|
@@ -2696,30 +2900,55 @@ function getSampleValue(type, name, isUpdate = false) {
|
|
2696
2900
|
case "real":
|
2697
2901
|
case "double precision":
|
2698
2902
|
case "float":
|
2903
|
+
case "float4":
|
2904
|
+
case "float8":
|
2699
2905
|
return isUpdate ? "99.99" : "10.50";
|
2700
2906
|
case "boolean":
|
2701
2907
|
case "bool":
|
2702
2908
|
return isUpdate ? "false" : "true";
|
2703
|
-
case "date":
|
2704
|
-
return `'2024-01-01'`;
|
2705
|
-
case "timestamp":
|
2706
|
-
case "timestamptz":
|
2707
|
-
return `new Date().toISOString()`;
|
2708
2909
|
case "json":
|
2709
2910
|
case "jsonb":
|
2710
|
-
return `{ key: 'value' }`;
|
2911
|
+
return `{ key: '${isUpdate ? "updated" : "value"}' }`;
|
2711
2912
|
case "uuid":
|
2712
2913
|
return `'${isUpdate ? "550e8400-e29b-41d4-a716-446655440001" : "550e8400-e29b-41d4-a716-446655440000"}'`;
|
2713
|
-
case "
|
2714
|
-
|
2715
|
-
|
2914
|
+
case "inet":
|
2915
|
+
return `'${isUpdate ? "192.168.1.2" : "192.168.1.1"}'`;
|
2916
|
+
case "cidr":
|
2917
|
+
return `'192.168.1.0/24'`;
|
2918
|
+
case "macaddr":
|
2919
|
+
return `'08:00:2b:01:02:0${isUpdate ? "4" : "3"}'`;
|
2920
|
+
case "xml":
|
2921
|
+
return `'<root>${isUpdate ? "updated" : "value"}</root>'`;
|
2716
2922
|
default:
|
2717
|
-
|
2923
|
+
if (type.endsWith("[]")) {
|
2924
|
+
const baseType = type.slice(0, -2);
|
2925
|
+
if (baseType === "text" || baseType === "varchar") {
|
2926
|
+
return `['item1', 'item2${isUpdate ? "_updated" : ""}']`;
|
2927
|
+
}
|
2928
|
+
if (baseType === "int" || baseType === "integer") {
|
2929
|
+
return `[1, 2, ${isUpdate ? "3" : ""}]`;
|
2930
|
+
}
|
2931
|
+
return `[]`;
|
2932
|
+
}
|
2933
|
+
return `'test${isUpdate ? "_updated" : ""}'`;
|
2718
2934
|
}
|
2719
2935
|
}
|
2720
|
-
function generateTestCases(table, sampleData, updateData) {
|
2936
|
+
function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
|
2721
2937
|
const Type = pascal(table.name);
|
2722
2938
|
const hasData = sampleData !== "{}";
|
2939
|
+
const isJunctionTable = table.pk.length > 1 && table.columns.every((col) => table.pk.includes(col.name) || col.name.endsWith("_id"));
|
2940
|
+
if (isJunctionTable) {
|
2941
|
+
return `it('should create a ${table.name} relationship', async () => {
|
2942
|
+
// This is a junction table for M:N relationships
|
2943
|
+
// Test data depends on parent records created in other tests
|
2944
|
+
expect(true).toBe(true);
|
2945
|
+
});
|
2946
|
+
|
2947
|
+
it('should list ${table.name} relationships', async () => {
|
2948
|
+
const list = await sdk.${table.name}.list({ limit: 10 });
|
2949
|
+
expect(Array.isArray(list)).toBe(true);
|
2950
|
+
});`;
|
2951
|
+
}
|
2723
2952
|
return `it('should create a ${table.name}', async () => {
|
2724
2953
|
const data: Insert${Type} = ${sampleData};
|
2725
2954
|
${hasData ? `
|
@@ -2776,6 +3005,280 @@ function generateTestCases(table, sampleData, updateData) {
|
|
2776
3005
|
});` : ""}`;
|
2777
3006
|
}
|
2778
3007
|
|
3008
|
+
// src/emit-api-contract.ts
|
3009
|
+
function generateApiContract(model, config) {
|
3010
|
+
const resources = [];
|
3011
|
+
const relationships = [];
|
3012
|
+
for (const table of Object.values(model.tables)) {
|
3013
|
+
resources.push(generateResourceContract(table, model));
|
3014
|
+
for (const fk of table.fks) {
|
3015
|
+
relationships.push({
|
3016
|
+
from: table.name,
|
3017
|
+
to: fk.toTable,
|
3018
|
+
type: "many-to-one",
|
3019
|
+
description: `Each ${table.name} belongs to one ${fk.toTable}`
|
3020
|
+
});
|
3021
|
+
}
|
3022
|
+
}
|
3023
|
+
const contract = {
|
3024
|
+
version: "1.0.0",
|
3025
|
+
generatedAt: new Date().toISOString(),
|
3026
|
+
description: "Auto-generated API contract describing all available endpoints, resources, and their relationships",
|
3027
|
+
resources,
|
3028
|
+
relationships
|
3029
|
+
};
|
3030
|
+
if (config.auth?.strategy && config.auth.strategy !== "none") {
|
3031
|
+
contract.authentication = {
|
3032
|
+
type: config.auth.strategy,
|
3033
|
+
description: getAuthDescription(config.auth.strategy)
|
3034
|
+
};
|
3035
|
+
}
|
3036
|
+
return contract;
|
3037
|
+
}
|
3038
|
+
function generateResourceContract(table, model) {
|
3039
|
+
const Type = pascal(table.name);
|
3040
|
+
const basePath = `/v1/${table.name}`;
|
3041
|
+
const endpoints = [
|
3042
|
+
{
|
3043
|
+
method: "GET",
|
3044
|
+
path: basePath,
|
3045
|
+
description: `List all ${table.name} records with optional filtering, sorting, and pagination`,
|
3046
|
+
queryParameters: {
|
3047
|
+
limit: "number - Maximum number of records to return (default: 50)",
|
3048
|
+
offset: "number - Number of records to skip for pagination",
|
3049
|
+
order_by: "string - Field to sort by",
|
3050
|
+
order_dir: "string - Sort direction (asc or desc)",
|
3051
|
+
include: "string - Comma-separated list of related resources to include",
|
3052
|
+
...generateFilterParams(table)
|
3053
|
+
},
|
3054
|
+
responseBody: `Array<${Type}>`
|
3055
|
+
},
|
3056
|
+
{
|
3057
|
+
method: "GET",
|
3058
|
+
path: `${basePath}/:id`,
|
3059
|
+
description: `Get a single ${table.name} record by ID`,
|
3060
|
+
queryParameters: {
|
3061
|
+
include: "string - Comma-separated list of related resources to include"
|
3062
|
+
},
|
3063
|
+
responseBody: `${Type}`
|
3064
|
+
},
|
3065
|
+
{
|
3066
|
+
method: "POST",
|
3067
|
+
path: basePath,
|
3068
|
+
description: `Create a new ${table.name} record`,
|
3069
|
+
requestBody: `Insert${Type}`,
|
3070
|
+
responseBody: `${Type}`
|
3071
|
+
},
|
3072
|
+
{
|
3073
|
+
method: "PATCH",
|
3074
|
+
path: `${basePath}/:id`,
|
3075
|
+
description: `Update an existing ${table.name} record`,
|
3076
|
+
requestBody: `Update${Type}`,
|
3077
|
+
responseBody: `${Type}`
|
3078
|
+
},
|
3079
|
+
{
|
3080
|
+
method: "DELETE",
|
3081
|
+
path: `${basePath}/:id`,
|
3082
|
+
description: `Delete a ${table.name} record`,
|
3083
|
+
responseBody: `${Type}`
|
3084
|
+
}
|
3085
|
+
];
|
3086
|
+
const fields = table.columns.map((col) => generateFieldContract(col, table));
|
3087
|
+
return {
|
3088
|
+
name: Type,
|
3089
|
+
tableName: table.name,
|
3090
|
+
description: `Resource for managing ${table.name} records`,
|
3091
|
+
endpoints,
|
3092
|
+
fields
|
3093
|
+
};
|
3094
|
+
}
|
3095
|
+
function generateFieldContract(column, table) {
|
3096
|
+
const field = {
|
3097
|
+
name: column.name,
|
3098
|
+
type: postgresTypeToJsonType(column.pgType),
|
3099
|
+
required: !column.nullable && !column.hasDefault,
|
3100
|
+
description: generateFieldDescription(column, table)
|
3101
|
+
};
|
3102
|
+
const fk = table.fks.find((fk2) => fk2.from.length === 1 && fk2.from[0] === column.name);
|
3103
|
+
if (fk) {
|
3104
|
+
field.foreignKey = {
|
3105
|
+
table: fk.toTable,
|
3106
|
+
field: fk.to[0] || "id"
|
3107
|
+
};
|
3108
|
+
}
|
3109
|
+
return field;
|
3110
|
+
}
|
3111
|
+
function generateFieldDescription(column, table) {
|
3112
|
+
const descriptions = [];
|
3113
|
+
descriptions.push(`${postgresTypeToJsonType(column.pgType)} field`);
|
3114
|
+
if (column.name === "id") {
|
3115
|
+
descriptions.push("Unique identifier");
|
3116
|
+
} else if (column.name === "created_at") {
|
3117
|
+
descriptions.push("Timestamp when the record was created");
|
3118
|
+
} else if (column.name === "updated_at") {
|
3119
|
+
descriptions.push("Timestamp when the record was last updated");
|
3120
|
+
} else if (column.name === "deleted_at") {
|
3121
|
+
descriptions.push("Soft delete timestamp");
|
3122
|
+
} else if (column.name.endsWith("_id")) {
|
3123
|
+
const relatedTable = column.name.slice(0, -3);
|
3124
|
+
descriptions.push(`Reference to ${relatedTable}`);
|
3125
|
+
} else if (column.name.includes("email")) {
|
3126
|
+
descriptions.push("Email address");
|
3127
|
+
} else if (column.name.includes("phone")) {
|
3128
|
+
descriptions.push("Phone number");
|
3129
|
+
} else if (column.name.includes("name")) {
|
3130
|
+
descriptions.push("Name field");
|
3131
|
+
} else if (column.name.includes("description")) {
|
3132
|
+
descriptions.push("Description text");
|
3133
|
+
} else if (column.name.includes("status")) {
|
3134
|
+
descriptions.push("Status indicator");
|
3135
|
+
} else if (column.name.includes("price") || column.name.includes("amount") || column.name.includes("total")) {
|
3136
|
+
descriptions.push("Monetary value");
|
3137
|
+
}
|
3138
|
+
if (!column.nullable && !column.hasDefault) {
|
3139
|
+
descriptions.push("(required)");
|
3140
|
+
} else if (column.nullable) {
|
3141
|
+
descriptions.push("(optional)");
|
3142
|
+
}
|
3143
|
+
return descriptions.join(" - ");
|
3144
|
+
}
|
3145
|
+
function postgresTypeToJsonType(pgType) {
|
3146
|
+
switch (pgType) {
|
3147
|
+
case "int":
|
3148
|
+
case "integer":
|
3149
|
+
case "smallint":
|
3150
|
+
case "bigint":
|
3151
|
+
case "decimal":
|
3152
|
+
case "numeric":
|
3153
|
+
case "real":
|
3154
|
+
case "double precision":
|
3155
|
+
case "float":
|
3156
|
+
return "number";
|
3157
|
+
case "boolean":
|
3158
|
+
case "bool":
|
3159
|
+
return "boolean";
|
3160
|
+
case "date":
|
3161
|
+
case "timestamp":
|
3162
|
+
case "timestamptz":
|
3163
|
+
return "date/datetime";
|
3164
|
+
case "json":
|
3165
|
+
case "jsonb":
|
3166
|
+
return "object";
|
3167
|
+
case "uuid":
|
3168
|
+
return "uuid";
|
3169
|
+
case "text[]":
|
3170
|
+
case "varchar[]":
|
3171
|
+
return "array<string>";
|
3172
|
+
case "int[]":
|
3173
|
+
case "integer[]":
|
3174
|
+
return "array<number>";
|
3175
|
+
default:
|
3176
|
+
return "string";
|
3177
|
+
}
|
3178
|
+
}
|
3179
|
+
function generateFilterParams(table) {
|
3180
|
+
const filters = {};
|
3181
|
+
for (const col of table.columns) {
|
3182
|
+
const type = postgresTypeToJsonType(col.pgType);
|
3183
|
+
filters[col.name] = `${type} - Filter by exact ${col.name} value`;
|
3184
|
+
if (type === "number" || type === "date/datetime") {
|
3185
|
+
filters[`${col.name}_gt`] = `${type} - Filter where ${col.name} is greater than`;
|
3186
|
+
filters[`${col.name}_gte`] = `${type} - Filter where ${col.name} is greater than or equal`;
|
3187
|
+
filters[`${col.name}_lt`] = `${type} - Filter where ${col.name} is less than`;
|
3188
|
+
filters[`${col.name}_lte`] = `${type} - Filter where ${col.name} is less than or equal`;
|
3189
|
+
}
|
3190
|
+
if (type === "string") {
|
3191
|
+
filters[`${col.name}_like`] = `string - Filter where ${col.name} contains text (case-insensitive)`;
|
3192
|
+
}
|
3193
|
+
}
|
3194
|
+
return filters;
|
3195
|
+
}
|
3196
|
+
function getAuthDescription(strategy) {
|
3197
|
+
switch (strategy) {
|
3198
|
+
case "jwt":
|
3199
|
+
return "JWT Bearer token authentication. Include token in Authorization header: 'Bearer <token>'";
|
3200
|
+
case "apiKey":
|
3201
|
+
return "API Key authentication. Include key in the configured header (e.g., 'x-api-key')";
|
3202
|
+
default:
|
3203
|
+
return "Custom authentication strategy";
|
3204
|
+
}
|
3205
|
+
}
|
3206
|
+
function generateApiContractMarkdown(contract) {
|
3207
|
+
const lines = [];
|
3208
|
+
lines.push("# API Contract");
|
3209
|
+
lines.push("");
|
3210
|
+
lines.push(contract.description);
|
3211
|
+
lines.push("");
|
3212
|
+
lines.push(`**Version:** ${contract.version}`);
|
3213
|
+
lines.push(`**Generated:** ${new Date(contract.generatedAt).toLocaleString()}`);
|
3214
|
+
lines.push("");
|
3215
|
+
if (contract.authentication) {
|
3216
|
+
lines.push("## Authentication");
|
3217
|
+
lines.push("");
|
3218
|
+
lines.push(`**Type:** ${contract.authentication.type}`);
|
3219
|
+
lines.push("");
|
3220
|
+
lines.push(contract.authentication.description);
|
3221
|
+
lines.push("");
|
3222
|
+
}
|
3223
|
+
lines.push("## Resources");
|
3224
|
+
lines.push("");
|
3225
|
+
for (const resource of contract.resources) {
|
3226
|
+
lines.push(`### ${resource.name}`);
|
3227
|
+
lines.push("");
|
3228
|
+
lines.push(resource.description);
|
3229
|
+
lines.push("");
|
3230
|
+
lines.push("**Endpoints:**");
|
3231
|
+
lines.push("");
|
3232
|
+
for (const endpoint of resource.endpoints) {
|
3233
|
+
lines.push(`- \`${endpoint.method} ${endpoint.path}\` - ${endpoint.description}`);
|
3234
|
+
}
|
3235
|
+
lines.push("");
|
3236
|
+
lines.push("**Fields:**");
|
3237
|
+
lines.push("");
|
3238
|
+
for (const field of resource.fields) {
|
3239
|
+
const required = field.required ? " *(required)*" : "";
|
3240
|
+
const fk = field.foreignKey ? ` → ${field.foreignKey.table}` : "";
|
3241
|
+
lines.push(`- \`${field.name}\` (${field.type})${required}${fk} - ${field.description}`);
|
3242
|
+
}
|
3243
|
+
lines.push("");
|
3244
|
+
}
|
3245
|
+
if (contract.relationships.length > 0) {
|
3246
|
+
lines.push("## Relationships");
|
3247
|
+
lines.push("");
|
3248
|
+
for (const rel of contract.relationships) {
|
3249
|
+
lines.push(`- **${rel.from}** → **${rel.to}** (${rel.type}): ${rel.description}`);
|
3250
|
+
}
|
3251
|
+
lines.push("");
|
3252
|
+
}
|
3253
|
+
return lines.join(`
|
3254
|
+
`);
|
3255
|
+
}
|
3256
|
+
function emitApiContract(model, config) {
|
3257
|
+
const contract = generateApiContract(model, config);
|
3258
|
+
const contractJson = JSON.stringify(contract, null, 2);
|
3259
|
+
return `/**
|
3260
|
+
* API Contract
|
3261
|
+
*
|
3262
|
+
* This module exports the API contract that describes all available
|
3263
|
+
* endpoints, resources, and their relationships.
|
3264
|
+
*/
|
3265
|
+
|
3266
|
+
export const apiContract = ${contractJson};
|
3267
|
+
|
3268
|
+
export const apiContractMarkdown = \`${generateApiContractMarkdown(contract).replace(/`/g, "\\`")}\`;
|
3269
|
+
|
3270
|
+
/**
|
3271
|
+
* Helper to get the contract in different formats
|
3272
|
+
*/
|
3273
|
+
export function getApiContract(format: 'json' | 'markdown' = 'json') {
|
3274
|
+
if (format === 'markdown') {
|
3275
|
+
return apiContractMarkdown;
|
3276
|
+
}
|
3277
|
+
return apiContract;
|
3278
|
+
}
|
3279
|
+
`;
|
3280
|
+
}
|
3281
|
+
|
2779
3282
|
// src/types.ts
|
2780
3283
|
function normalizeAuthConfig(input) {
|
2781
3284
|
if (!input)
|
@@ -2919,6 +3422,10 @@ async function generate(configPath) {
|
|
2919
3422
|
path: join(serverDir, "sdk-bundle.ts"),
|
2920
3423
|
content: emitSdkBundle(clientFiles, clientDir)
|
2921
3424
|
});
|
3425
|
+
files.push({
|
3426
|
+
path: join(serverDir, "api-contract.ts"),
|
3427
|
+
content: emitApiContract(model, cfg)
|
3428
|
+
});
|
2922
3429
|
if (generateTests) {
|
2923
3430
|
console.log("\uD83E\uDDEA Generating tests...");
|
2924
3431
|
const relativeClientPath = relative(testDir, clientDir);
|