postgresdk 0.6.4 → 0.6.6
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 +535 -14
- package/dist/emit-api-contract.d.ts +60 -0
- package/dist/emit-tests.d.ts +8 -0
- package/dist/index.js +535 -14
- 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,30 +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
|
2371
|
+
const hasForeignKeys = table.fks.length > 0;
|
2372
|
+
const foreignKeySetup = hasForeignKeys ? generateForeignKeySetup(table, clientPath) : null;
|
2373
|
+
const sampleData = generateSampleData(table, hasForeignKeys);
|
2348
2374
|
const updateData = generateUpdateData(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
|
-
*
|
2384
|
+
* The test data is auto-generated and may need adjustment for your specific schema.
|
2385
|
+
*
|
2386
|
+
* If tests fail due to validation errors:
|
2387
|
+
* 1. Check which fields are required by your API
|
2388
|
+
* 2. Update the test data below to match your schema requirements
|
2389
|
+
* 3. Consider adding your own business logic tests in separate files
|
2358
2390
|
*/
|
2359
2391
|
describe('${Type} SDK Operations', () => {
|
2360
2392
|
let sdk: SDK;
|
2361
2393
|
let createdId: string;
|
2394
|
+
${foreignKeySetup?.variables || ""}
|
2362
2395
|
|
2363
|
-
beforeAll(() => {
|
2396
|
+
beforeAll(async () => {
|
2364
2397
|
sdk = new SDK({
|
2365
2398
|
baseUrl: process.env.API_URL || 'http://localhost:3000',
|
2366
2399
|
auth: process.env.API_KEY ? { apiKey: process.env.API_KEY } : undefined
|
2367
2400
|
});
|
2401
|
+
${foreignKeySetup?.setup || ""}
|
2402
|
+
});
|
2403
|
+
|
2404
|
+
${hasForeignKeys && foreignKeySetup?.cleanup ? `afterAll(async () => {
|
2405
|
+
${foreignKeySetup.cleanup}
|
2368
2406
|
});
|
2369
2407
|
|
2370
|
-
${generateTestCases(table, sampleData, updateData)}
|
2408
|
+
` : ""}${generateTestCases(table, sampleData, updateData, hasForeignKeys)}
|
2371
2409
|
});
|
2372
2410
|
`;
|
2373
2411
|
}
|
@@ -2417,6 +2455,45 @@ export function randomDate(): Date {
|
|
2417
2455
|
}
|
2418
2456
|
`;
|
2419
2457
|
}
|
2458
|
+
function emitVitestConfig() {
|
2459
|
+
return `import { defineConfig } from 'vitest/config';
|
2460
|
+
|
2461
|
+
export default defineConfig({
|
2462
|
+
test: {
|
2463
|
+
globals: true,
|
2464
|
+
environment: 'node',
|
2465
|
+
testTimeout: 30000,
|
2466
|
+
hookTimeout: 30000,
|
2467
|
+
// The reporters are configured via CLI in the test script
|
2468
|
+
// Force color output in terminal
|
2469
|
+
pool: 'forks',
|
2470
|
+
poolOptions: {
|
2471
|
+
forks: {
|
2472
|
+
singleFork: true,
|
2473
|
+
}
|
2474
|
+
}
|
2475
|
+
},
|
2476
|
+
});
|
2477
|
+
`;
|
2478
|
+
}
|
2479
|
+
function emitTestGitignore() {
|
2480
|
+
return `# Test results
|
2481
|
+
test-results/
|
2482
|
+
*.log
|
2483
|
+
|
2484
|
+
# Node modules (if tests have their own dependencies)
|
2485
|
+
node_modules/
|
2486
|
+
|
2487
|
+
# Environment files
|
2488
|
+
.env
|
2489
|
+
.env.local
|
2490
|
+
.env.test
|
2491
|
+
|
2492
|
+
# Coverage reports
|
2493
|
+
coverage/
|
2494
|
+
*.lcov
|
2495
|
+
`;
|
2496
|
+
}
|
2420
2497
|
function emitDockerCompose() {
|
2421
2498
|
return `# Docker Compose for Test Database
|
2422
2499
|
#
|
@@ -2475,6 +2552,10 @@ docker-compose up -d --wait
|
|
2475
2552
|
export TEST_DATABASE_URL="postgres://testuser:testpass@localhost:5432/testdb"
|
2476
2553
|
export TEST_API_URL="http://localhost:3000"
|
2477
2554
|
|
2555
|
+
# Force color output for better terminal experience
|
2556
|
+
export FORCE_COLOR=1
|
2557
|
+
export CI=""
|
2558
|
+
|
2478
2559
|
# Wait for database to be ready
|
2479
2560
|
echo "⏳ Waiting for database..."
|
2480
2561
|
sleep 3
|
@@ -2502,7 +2583,14 @@ echo ""
|
|
2502
2583
|
# sleep 3
|
2503
2584
|
|
2504
2585
|
echo "\uD83E\uDDEA Running tests..."
|
2505
|
-
|
2586
|
+
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
2587
|
+
TEST_RESULTS_DIR="$SCRIPT_DIR/test-results"
|
2588
|
+
mkdir -p "$TEST_RESULTS_DIR"
|
2589
|
+
|
2590
|
+
# Run tests with appropriate reporter based on framework
|
2591
|
+
${getTestCommand(framework, runCommand)}
|
2592
|
+
|
2593
|
+
TEST_EXIT_CODE=$?
|
2506
2594
|
|
2507
2595
|
# Cleanup
|
2508
2596
|
# if [ ! -z "\${SERVER_PID}" ]; then
|
@@ -2510,12 +2598,85 @@ ${runCommand} "$@"
|
|
2510
2598
|
# kill $SERVER_PID 2>/dev/null || true
|
2511
2599
|
# fi
|
2512
2600
|
|
2513
|
-
|
2601
|
+
if [ $TEST_EXIT_CODE -eq 0 ]; then
|
2602
|
+
echo "✅ Tests completed successfully!"
|
2603
|
+
else
|
2604
|
+
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
|
2605
|
+
fi
|
2606
|
+
|
2607
|
+
echo ""
|
2608
|
+
echo "\uD83D\uDCCA Test results saved to:"
|
2609
|
+
echo " $TEST_RESULTS_DIR/"
|
2514
2610
|
echo ""
|
2515
2611
|
echo "To stop the test database, run:"
|
2516
2612
|
echo " cd $SCRIPT_DIR && docker-compose down"
|
2613
|
+
|
2614
|
+
exit $TEST_EXIT_CODE
|
2517
2615
|
`;
|
2518
2616
|
}
|
2617
|
+
function generateForeignKeySetup(table, clientPath) {
|
2618
|
+
const imports = [];
|
2619
|
+
const variables = [];
|
2620
|
+
const setupStatements = [];
|
2621
|
+
const cleanupStatements = [];
|
2622
|
+
const foreignTables = new Set;
|
2623
|
+
for (const fk of table.fks) {
|
2624
|
+
const foreignTable = fk.toTable;
|
2625
|
+
if (!foreignTables.has(foreignTable)) {
|
2626
|
+
foreignTables.add(foreignTable);
|
2627
|
+
const ForeignType = pascal(foreignTable);
|
2628
|
+
imports.push(`import type { Insert${ForeignType} } from '${clientPath}/types/${foreignTable}';`);
|
2629
|
+
variables.push(`let ${foreignTable}Id: string;`);
|
2630
|
+
setupStatements.push(`
|
2631
|
+
// Create parent ${foreignTable} record for foreign key reference
|
2632
|
+
const ${foreignTable}Data = ${generateMinimalSampleData(foreignTable)};
|
2633
|
+
const created${ForeignType} = await sdk.${foreignTable}.create(${foreignTable}Data);
|
2634
|
+
${foreignTable}Id = created${ForeignType}.id;`);
|
2635
|
+
cleanupStatements.push(`
|
2636
|
+
// Clean up parent ${foreignTable} record
|
2637
|
+
if (${foreignTable}Id) {
|
2638
|
+
try {
|
2639
|
+
await sdk.${foreignTable}.delete(${foreignTable}Id);
|
2640
|
+
} catch (e) {
|
2641
|
+
// Parent might already be deleted due to cascading
|
2642
|
+
}
|
2643
|
+
}`);
|
2644
|
+
}
|
2645
|
+
}
|
2646
|
+
return {
|
2647
|
+
imports: imports.join(`
|
2648
|
+
`),
|
2649
|
+
variables: variables.join(`
|
2650
|
+
`),
|
2651
|
+
setup: setupStatements.join(""),
|
2652
|
+
cleanup: cleanupStatements.join("")
|
2653
|
+
};
|
2654
|
+
}
|
2655
|
+
function generateMinimalSampleData(tableName) {
|
2656
|
+
const commonPatterns = {
|
2657
|
+
contacts: `{ name: 'Test Contact', email: 'test@example.com', gender: 'M', date_of_birth: new Date('1990-01-01'), emergency_contact: 'Emergency Contact', clinic_location: 'Main Clinic', specialty: 'General', fmv_tiering: 'Standard', flight_preferences: 'Economy', dietary_restrictions: [], special_accommodations: 'None' }`,
|
2658
|
+
tags: `{ name: 'Test Tag' }`,
|
2659
|
+
authors: `{ name: 'Test Author' }`,
|
2660
|
+
books: `{ title: 'Test Book' }`,
|
2661
|
+
users: `{ name: 'Test User', email: 'test@example.com' }`,
|
2662
|
+
categories: `{ name: 'Test Category' }`,
|
2663
|
+
products: `{ name: 'Test Product', price: 10.99 }`,
|
2664
|
+
orders: `{ total: 100.00 }`
|
2665
|
+
};
|
2666
|
+
return commonPatterns[tableName] || `{ name: 'Test ${pascal(tableName)}' }`;
|
2667
|
+
}
|
2668
|
+
function getTestCommand(framework, baseCommand) {
|
2669
|
+
switch (framework) {
|
2670
|
+
case "vitest":
|
2671
|
+
return `FORCE_COLOR=1 ${baseCommand} --reporter=default --reporter=json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
|
2672
|
+
case "jest":
|
2673
|
+
return `FORCE_COLOR=1 ${baseCommand} --colors --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
|
2674
|
+
case "bun":
|
2675
|
+
return `FORCE_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
|
2676
|
+
default:
|
2677
|
+
return `FORCE_COLOR=1 ${baseCommand} "$@"`;
|
2678
|
+
}
|
2679
|
+
}
|
2519
2680
|
function getFrameworkImports(framework) {
|
2520
2681
|
switch (framework) {
|
2521
2682
|
case "vitest":
|
@@ -2528,17 +2689,38 @@ function getFrameworkImports(framework) {
|
|
2528
2689
|
return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
|
2529
2690
|
}
|
2530
2691
|
}
|
2531
|
-
function generateSampleData(table) {
|
2692
|
+
function generateSampleData(table, hasForeignKeys = false) {
|
2532
2693
|
const fields = [];
|
2694
|
+
const foreignKeyColumns = new Map;
|
2695
|
+
if (hasForeignKeys) {
|
2696
|
+
for (const fk of table.fks) {
|
2697
|
+
if (fk.from.length === 1 && fk.from[0]) {
|
2698
|
+
foreignKeyColumns.set(fk.from[0], fk.toTable);
|
2699
|
+
}
|
2700
|
+
}
|
2701
|
+
}
|
2533
2702
|
for (const col of table.columns) {
|
2534
|
-
if (col.
|
2703
|
+
if (col.name === "id" && col.hasDefault) {
|
2535
2704
|
continue;
|
2536
2705
|
}
|
2537
|
-
if (col.
|
2706
|
+
if ((col.name === "created_at" || col.name === "updated_at") && col.hasDefault) {
|
2538
2707
|
continue;
|
2539
2708
|
}
|
2540
|
-
|
2541
|
-
|
2709
|
+
if (col.name === "deleted_at") {
|
2710
|
+
continue;
|
2711
|
+
}
|
2712
|
+
if (!col.nullable) {
|
2713
|
+
const foreignTable = foreignKeyColumns.get(col.name);
|
2714
|
+
const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
|
2715
|
+
fields.push(` ${col.name}: ${value}`);
|
2716
|
+
} else {
|
2717
|
+
const isImportant = col.name.endsWith("_id") || col.name.endsWith("_by") || col.name.includes("email") || col.name.includes("name") || col.name.includes("phone") || col.name.includes("address") || col.name.includes("description") || col.name.includes("color") || col.name.includes("type") || col.name.includes("status") || col.name.includes("subject");
|
2718
|
+
if (isImportant) {
|
2719
|
+
const foreignTable = foreignKeyColumns.get(col.name);
|
2720
|
+
const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
|
2721
|
+
fields.push(` ${col.name}: ${value}`);
|
2722
|
+
}
|
2723
|
+
}
|
2542
2724
|
}
|
2543
2725
|
return fields.length > 0 ? `{
|
2544
2726
|
${fields.join(`,
|
@@ -2564,15 +2746,63 @@ ${fields.join(`,
|
|
2564
2746
|
}
|
2565
2747
|
function getSampleValue(type, name, isUpdate = false) {
|
2566
2748
|
const suffix = isUpdate ? ' + " (updated)"' : "";
|
2749
|
+
if (name.endsWith("_id") || name.endsWith("_by")) {
|
2750
|
+
return `'550e8400-e29b-41d4-a716-446655440000'`;
|
2751
|
+
}
|
2567
2752
|
if (name.includes("email")) {
|
2568
2753
|
return `'test${isUpdate ? ".updated" : ""}@example.com'`;
|
2569
2754
|
}
|
2755
|
+
if (name === "color") {
|
2756
|
+
return `'#${isUpdate ? "FF0000" : "0000FF"}'`;
|
2757
|
+
}
|
2758
|
+
if (name === "gender") {
|
2759
|
+
return `'${isUpdate ? "F" : "M"}'`;
|
2760
|
+
}
|
2761
|
+
if (name.includes("phone")) {
|
2762
|
+
return `'${isUpdate ? "555-0200" : "555-0100"}'`;
|
2763
|
+
}
|
2764
|
+
if (name.includes("address")) {
|
2765
|
+
return `'123 ${isUpdate ? "Updated" : "Test"} Street'`;
|
2766
|
+
}
|
2767
|
+
if (name === "type" || name === "status") {
|
2768
|
+
return `'${isUpdate ? "updated" : "active"}'`;
|
2769
|
+
}
|
2770
|
+
if (name === "subject") {
|
2771
|
+
return `'Test Subject${isUpdate ? " Updated" : ""}'`;
|
2772
|
+
}
|
2570
2773
|
if (name.includes("name") || name.includes("title")) {
|
2571
2774
|
return `'Test ${pascal(name)}'${suffix}`;
|
2572
2775
|
}
|
2573
2776
|
if (name.includes("description") || name.includes("bio") || name.includes("content")) {
|
2574
2777
|
return `'Test description'${suffix}`;
|
2575
2778
|
}
|
2779
|
+
if (name.includes("preferences") || name.includes("settings")) {
|
2780
|
+
return `'Test preferences'${suffix}`;
|
2781
|
+
}
|
2782
|
+
if (name.includes("restrictions") || name.includes("dietary")) {
|
2783
|
+
return `['vegetarian']`;
|
2784
|
+
}
|
2785
|
+
if (name.includes("location") || name.includes("clinic")) {
|
2786
|
+
return `'Test Location'${suffix}`;
|
2787
|
+
}
|
2788
|
+
if (name.includes("specialty")) {
|
2789
|
+
return `'General'`;
|
2790
|
+
}
|
2791
|
+
if (name.includes("tier")) {
|
2792
|
+
return `'Standard'`;
|
2793
|
+
}
|
2794
|
+
if (name.includes("emergency")) {
|
2795
|
+
return `'Emergency Contact ${isUpdate ? "Updated" : "Name"}'`;
|
2796
|
+
}
|
2797
|
+
if (name === "date_of_birth" || name.includes("birth")) {
|
2798
|
+
return `new Date('1990-01-01')`;
|
2799
|
+
}
|
2800
|
+
if (name.includes("accommodations")) {
|
2801
|
+
return `'No special accommodations'`;
|
2802
|
+
}
|
2803
|
+
if (name.includes("flight")) {
|
2804
|
+
return `'Economy'`;
|
2805
|
+
}
|
2576
2806
|
switch (type) {
|
2577
2807
|
case "text":
|
2578
2808
|
case "varchar":
|
@@ -2596,17 +2826,20 @@ function getSampleValue(type, name, isUpdate = false) {
|
|
2596
2826
|
return `'2024-01-01'`;
|
2597
2827
|
case "timestamp":
|
2598
2828
|
case "timestamptz":
|
2599
|
-
return `new Date()
|
2829
|
+
return `new Date()`;
|
2600
2830
|
case "json":
|
2601
2831
|
case "jsonb":
|
2602
2832
|
return `{ key: 'value' }`;
|
2603
2833
|
case "uuid":
|
2604
|
-
return `'${isUpdate ? "
|
2834
|
+
return `'${isUpdate ? "550e8400-e29b-41d4-a716-446655440001" : "550e8400-e29b-41d4-a716-446655440000"}'`;
|
2835
|
+
case "text[]":
|
2836
|
+
case "varchar[]":
|
2837
|
+
return `['item1', 'item2']`;
|
2605
2838
|
default:
|
2606
2839
|
return `'test'`;
|
2607
2840
|
}
|
2608
2841
|
}
|
2609
|
-
function generateTestCases(table, sampleData, updateData) {
|
2842
|
+
function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
|
2610
2843
|
const Type = pascal(table.name);
|
2611
2844
|
const hasData = sampleData !== "{}";
|
2612
2845
|
return `it('should create a ${table.name}', async () => {
|
@@ -2665,6 +2898,280 @@ function generateTestCases(table, sampleData, updateData) {
|
|
2665
2898
|
});` : ""}`;
|
2666
2899
|
}
|
2667
2900
|
|
2901
|
+
// src/emit-api-contract.ts
|
2902
|
+
function generateApiContract(model, config) {
|
2903
|
+
const resources = [];
|
2904
|
+
const relationships = [];
|
2905
|
+
for (const table of Object.values(model.tables)) {
|
2906
|
+
resources.push(generateResourceContract(table, model));
|
2907
|
+
for (const fk of table.fks) {
|
2908
|
+
relationships.push({
|
2909
|
+
from: table.name,
|
2910
|
+
to: fk.toTable,
|
2911
|
+
type: "many-to-one",
|
2912
|
+
description: `Each ${table.name} belongs to one ${fk.toTable}`
|
2913
|
+
});
|
2914
|
+
}
|
2915
|
+
}
|
2916
|
+
const contract = {
|
2917
|
+
version: "1.0.0",
|
2918
|
+
generatedAt: new Date().toISOString(),
|
2919
|
+
description: "Auto-generated API contract describing all available endpoints, resources, and their relationships",
|
2920
|
+
resources,
|
2921
|
+
relationships
|
2922
|
+
};
|
2923
|
+
if (config.auth?.strategy && config.auth.strategy !== "none") {
|
2924
|
+
contract.authentication = {
|
2925
|
+
type: config.auth.strategy,
|
2926
|
+
description: getAuthDescription(config.auth.strategy)
|
2927
|
+
};
|
2928
|
+
}
|
2929
|
+
return contract;
|
2930
|
+
}
|
2931
|
+
function generateResourceContract(table, model) {
|
2932
|
+
const Type = pascal(table.name);
|
2933
|
+
const basePath = `/v1/${table.name}`;
|
2934
|
+
const endpoints = [
|
2935
|
+
{
|
2936
|
+
method: "GET",
|
2937
|
+
path: basePath,
|
2938
|
+
description: `List all ${table.name} records with optional filtering, sorting, and pagination`,
|
2939
|
+
queryParameters: {
|
2940
|
+
limit: "number - Maximum number of records to return (default: 50)",
|
2941
|
+
offset: "number - Number of records to skip for pagination",
|
2942
|
+
order_by: "string - Field to sort by",
|
2943
|
+
order_dir: "string - Sort direction (asc or desc)",
|
2944
|
+
include: "string - Comma-separated list of related resources to include",
|
2945
|
+
...generateFilterParams(table)
|
2946
|
+
},
|
2947
|
+
responseBody: `Array<${Type}>`
|
2948
|
+
},
|
2949
|
+
{
|
2950
|
+
method: "GET",
|
2951
|
+
path: `${basePath}/:id`,
|
2952
|
+
description: `Get a single ${table.name} record by ID`,
|
2953
|
+
queryParameters: {
|
2954
|
+
include: "string - Comma-separated list of related resources to include"
|
2955
|
+
},
|
2956
|
+
responseBody: `${Type}`
|
2957
|
+
},
|
2958
|
+
{
|
2959
|
+
method: "POST",
|
2960
|
+
path: basePath,
|
2961
|
+
description: `Create a new ${table.name} record`,
|
2962
|
+
requestBody: `Insert${Type}`,
|
2963
|
+
responseBody: `${Type}`
|
2964
|
+
},
|
2965
|
+
{
|
2966
|
+
method: "PATCH",
|
2967
|
+
path: `${basePath}/:id`,
|
2968
|
+
description: `Update an existing ${table.name} record`,
|
2969
|
+
requestBody: `Update${Type}`,
|
2970
|
+
responseBody: `${Type}`
|
2971
|
+
},
|
2972
|
+
{
|
2973
|
+
method: "DELETE",
|
2974
|
+
path: `${basePath}/:id`,
|
2975
|
+
description: `Delete a ${table.name} record`,
|
2976
|
+
responseBody: `${Type}`
|
2977
|
+
}
|
2978
|
+
];
|
2979
|
+
const fields = table.columns.map((col) => generateFieldContract(col, table));
|
2980
|
+
return {
|
2981
|
+
name: Type,
|
2982
|
+
tableName: table.name,
|
2983
|
+
description: `Resource for managing ${table.name} records`,
|
2984
|
+
endpoints,
|
2985
|
+
fields
|
2986
|
+
};
|
2987
|
+
}
|
2988
|
+
function generateFieldContract(column, table) {
|
2989
|
+
const field = {
|
2990
|
+
name: column.name,
|
2991
|
+
type: postgresTypeToJsonType(column.pgType),
|
2992
|
+
required: !column.nullable && !column.hasDefault,
|
2993
|
+
description: generateFieldDescription(column, table)
|
2994
|
+
};
|
2995
|
+
const fk = table.fks.find((fk2) => fk2.from.length === 1 && fk2.from[0] === column.name);
|
2996
|
+
if (fk) {
|
2997
|
+
field.foreignKey = {
|
2998
|
+
table: fk.toTable,
|
2999
|
+
field: fk.to[0] || "id"
|
3000
|
+
};
|
3001
|
+
}
|
3002
|
+
return field;
|
3003
|
+
}
|
3004
|
+
function generateFieldDescription(column, table) {
|
3005
|
+
const descriptions = [];
|
3006
|
+
descriptions.push(`${postgresTypeToJsonType(column.pgType)} field`);
|
3007
|
+
if (column.name === "id") {
|
3008
|
+
descriptions.push("Unique identifier");
|
3009
|
+
} else if (column.name === "created_at") {
|
3010
|
+
descriptions.push("Timestamp when the record was created");
|
3011
|
+
} else if (column.name === "updated_at") {
|
3012
|
+
descriptions.push("Timestamp when the record was last updated");
|
3013
|
+
} else if (column.name === "deleted_at") {
|
3014
|
+
descriptions.push("Soft delete timestamp");
|
3015
|
+
} else if (column.name.endsWith("_id")) {
|
3016
|
+
const relatedTable = column.name.slice(0, -3);
|
3017
|
+
descriptions.push(`Reference to ${relatedTable}`);
|
3018
|
+
} else if (column.name.includes("email")) {
|
3019
|
+
descriptions.push("Email address");
|
3020
|
+
} else if (column.name.includes("phone")) {
|
3021
|
+
descriptions.push("Phone number");
|
3022
|
+
} else if (column.name.includes("name")) {
|
3023
|
+
descriptions.push("Name field");
|
3024
|
+
} else if (column.name.includes("description")) {
|
3025
|
+
descriptions.push("Description text");
|
3026
|
+
} else if (column.name.includes("status")) {
|
3027
|
+
descriptions.push("Status indicator");
|
3028
|
+
} else if (column.name.includes("price") || column.name.includes("amount") || column.name.includes("total")) {
|
3029
|
+
descriptions.push("Monetary value");
|
3030
|
+
}
|
3031
|
+
if (!column.nullable && !column.hasDefault) {
|
3032
|
+
descriptions.push("(required)");
|
3033
|
+
} else if (column.nullable) {
|
3034
|
+
descriptions.push("(optional)");
|
3035
|
+
}
|
3036
|
+
return descriptions.join(" - ");
|
3037
|
+
}
|
3038
|
+
function postgresTypeToJsonType(pgType) {
|
3039
|
+
switch (pgType) {
|
3040
|
+
case "int":
|
3041
|
+
case "integer":
|
3042
|
+
case "smallint":
|
3043
|
+
case "bigint":
|
3044
|
+
case "decimal":
|
3045
|
+
case "numeric":
|
3046
|
+
case "real":
|
3047
|
+
case "double precision":
|
3048
|
+
case "float":
|
3049
|
+
return "number";
|
3050
|
+
case "boolean":
|
3051
|
+
case "bool":
|
3052
|
+
return "boolean";
|
3053
|
+
case "date":
|
3054
|
+
case "timestamp":
|
3055
|
+
case "timestamptz":
|
3056
|
+
return "date/datetime";
|
3057
|
+
case "json":
|
3058
|
+
case "jsonb":
|
3059
|
+
return "object";
|
3060
|
+
case "uuid":
|
3061
|
+
return "uuid";
|
3062
|
+
case "text[]":
|
3063
|
+
case "varchar[]":
|
3064
|
+
return "array<string>";
|
3065
|
+
case "int[]":
|
3066
|
+
case "integer[]":
|
3067
|
+
return "array<number>";
|
3068
|
+
default:
|
3069
|
+
return "string";
|
3070
|
+
}
|
3071
|
+
}
|
3072
|
+
function generateFilterParams(table) {
|
3073
|
+
const filters = {};
|
3074
|
+
for (const col of table.columns) {
|
3075
|
+
const type = postgresTypeToJsonType(col.pgType);
|
3076
|
+
filters[col.name] = `${type} - Filter by exact ${col.name} value`;
|
3077
|
+
if (type === "number" || type === "date/datetime") {
|
3078
|
+
filters[`${col.name}_gt`] = `${type} - Filter where ${col.name} is greater than`;
|
3079
|
+
filters[`${col.name}_gte`] = `${type} - Filter where ${col.name} is greater than or equal`;
|
3080
|
+
filters[`${col.name}_lt`] = `${type} - Filter where ${col.name} is less than`;
|
3081
|
+
filters[`${col.name}_lte`] = `${type} - Filter where ${col.name} is less than or equal`;
|
3082
|
+
}
|
3083
|
+
if (type === "string") {
|
3084
|
+
filters[`${col.name}_like`] = `string - Filter where ${col.name} contains text (case-insensitive)`;
|
3085
|
+
}
|
3086
|
+
}
|
3087
|
+
return filters;
|
3088
|
+
}
|
3089
|
+
function getAuthDescription(strategy) {
|
3090
|
+
switch (strategy) {
|
3091
|
+
case "jwt":
|
3092
|
+
return "JWT Bearer token authentication. Include token in Authorization header: 'Bearer <token>'";
|
3093
|
+
case "apiKey":
|
3094
|
+
return "API Key authentication. Include key in the configured header (e.g., 'x-api-key')";
|
3095
|
+
default:
|
3096
|
+
return "Custom authentication strategy";
|
3097
|
+
}
|
3098
|
+
}
|
3099
|
+
function generateApiContractMarkdown(contract) {
|
3100
|
+
const lines = [];
|
3101
|
+
lines.push("# API Contract");
|
3102
|
+
lines.push("");
|
3103
|
+
lines.push(contract.description);
|
3104
|
+
lines.push("");
|
3105
|
+
lines.push(`**Version:** ${contract.version}`);
|
3106
|
+
lines.push(`**Generated:** ${new Date(contract.generatedAt).toLocaleString()}`);
|
3107
|
+
lines.push("");
|
3108
|
+
if (contract.authentication) {
|
3109
|
+
lines.push("## Authentication");
|
3110
|
+
lines.push("");
|
3111
|
+
lines.push(`**Type:** ${contract.authentication.type}`);
|
3112
|
+
lines.push("");
|
3113
|
+
lines.push(contract.authentication.description);
|
3114
|
+
lines.push("");
|
3115
|
+
}
|
3116
|
+
lines.push("## Resources");
|
3117
|
+
lines.push("");
|
3118
|
+
for (const resource of contract.resources) {
|
3119
|
+
lines.push(`### ${resource.name}`);
|
3120
|
+
lines.push("");
|
3121
|
+
lines.push(resource.description);
|
3122
|
+
lines.push("");
|
3123
|
+
lines.push("**Endpoints:**");
|
3124
|
+
lines.push("");
|
3125
|
+
for (const endpoint of resource.endpoints) {
|
3126
|
+
lines.push(`- \`${endpoint.method} ${endpoint.path}\` - ${endpoint.description}`);
|
3127
|
+
}
|
3128
|
+
lines.push("");
|
3129
|
+
lines.push("**Fields:**");
|
3130
|
+
lines.push("");
|
3131
|
+
for (const field of resource.fields) {
|
3132
|
+
const required = field.required ? " *(required)*" : "";
|
3133
|
+
const fk = field.foreignKey ? ` → ${field.foreignKey.table}` : "";
|
3134
|
+
lines.push(`- \`${field.name}\` (${field.type})${required}${fk} - ${field.description}`);
|
3135
|
+
}
|
3136
|
+
lines.push("");
|
3137
|
+
}
|
3138
|
+
if (contract.relationships.length > 0) {
|
3139
|
+
lines.push("## Relationships");
|
3140
|
+
lines.push("");
|
3141
|
+
for (const rel of contract.relationships) {
|
3142
|
+
lines.push(`- **${rel.from}** → **${rel.to}** (${rel.type}): ${rel.description}`);
|
3143
|
+
}
|
3144
|
+
lines.push("");
|
3145
|
+
}
|
3146
|
+
return lines.join(`
|
3147
|
+
`);
|
3148
|
+
}
|
3149
|
+
function emitApiContract(model, config) {
|
3150
|
+
const contract = generateApiContract(model, config);
|
3151
|
+
const contractJson = JSON.stringify(contract, null, 2);
|
3152
|
+
return `/**
|
3153
|
+
* API Contract
|
3154
|
+
*
|
3155
|
+
* This module exports the API contract that describes all available
|
3156
|
+
* endpoints, resources, and their relationships.
|
3157
|
+
*/
|
3158
|
+
|
3159
|
+
export const apiContract = ${contractJson};
|
3160
|
+
|
3161
|
+
export const apiContractMarkdown = \`${generateApiContractMarkdown(contract).replace(/`/g, "\\`")}\`;
|
3162
|
+
|
3163
|
+
/**
|
3164
|
+
* Helper to get the contract in different formats
|
3165
|
+
*/
|
3166
|
+
export function getApiContract(format: 'json' | 'markdown' = 'json') {
|
3167
|
+
if (format === 'markdown') {
|
3168
|
+
return apiContractMarkdown;
|
3169
|
+
}
|
3170
|
+
return apiContract;
|
3171
|
+
}
|
3172
|
+
`;
|
3173
|
+
}
|
3174
|
+
|
2668
3175
|
// src/types.ts
|
2669
3176
|
function normalizeAuthConfig(input) {
|
2670
3177
|
if (!input)
|
@@ -2808,6 +3315,10 @@ async function generate(configPath) {
|
|
2808
3315
|
path: join(serverDir, "sdk-bundle.ts"),
|
2809
3316
|
content: emitSdkBundle(clientFiles, clientDir)
|
2810
3317
|
});
|
3318
|
+
files.push({
|
3319
|
+
path: join(serverDir, "api-contract.ts"),
|
3320
|
+
content: emitApiContract(model, cfg)
|
3321
|
+
});
|
2811
3322
|
if (generateTests) {
|
2812
3323
|
console.log("\uD83E\uDDEA Generating tests...");
|
2813
3324
|
const relativeClientPath = relative(testDir, clientDir);
|
@@ -2823,6 +3334,16 @@ async function generate(configPath) {
|
|
2823
3334
|
path: join(testDir, "run-tests.sh"),
|
2824
3335
|
content: emitTestScript(testFramework)
|
2825
3336
|
});
|
3337
|
+
files.push({
|
3338
|
+
path: join(testDir, ".gitignore"),
|
3339
|
+
content: emitTestGitignore()
|
3340
|
+
});
|
3341
|
+
if (testFramework === "vitest") {
|
3342
|
+
files.push({
|
3343
|
+
path: join(testDir, "vitest.config.ts"),
|
3344
|
+
content: emitVitestConfig()
|
3345
|
+
});
|
3346
|
+
}
|
2826
3347
|
for (const table of Object.values(model.tables)) {
|
2827
3348
|
files.push({
|
2828
3349
|
path: join(testDir, `${table.name}.test.ts`),
|