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/index.js
CHANGED
@@ -1736,6 +1736,7 @@ function emitHonoRouter(tables, hasAuth, useJsExtensions) {
|
|
1736
1736
|
return `/* Generated. Do not edit. */
|
1737
1737
|
import { Hono } from "hono";
|
1738
1738
|
import { SDK_MANIFEST } from "./sdk-bundle${ext}";
|
1739
|
+
import { getApiContract } from "./api-contract${ext}";
|
1739
1740
|
${imports}
|
1740
1741
|
${hasAuth ? `export { authMiddleware } from "./auth${ext}";` : ""}
|
1741
1742
|
|
@@ -1797,6 +1798,29 @@ ${registrations}
|
|
1797
1798
|
});
|
1798
1799
|
});
|
1799
1800
|
|
1801
|
+
// API Contract endpoints - describes the entire API
|
1802
|
+
router.get("/api/contract", (c) => {
|
1803
|
+
const format = c.req.query("format") || "json";
|
1804
|
+
|
1805
|
+
if (format === "markdown") {
|
1806
|
+
return c.text(getApiContract("markdown") as string, 200, {
|
1807
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
1808
|
+
});
|
1809
|
+
}
|
1810
|
+
|
1811
|
+
return c.json(getApiContract("json"));
|
1812
|
+
});
|
1813
|
+
|
1814
|
+
router.get("/api/contract.json", (c) => {
|
1815
|
+
return c.json(getApiContract("json"));
|
1816
|
+
});
|
1817
|
+
|
1818
|
+
router.get("/api/contract.md", (c) => {
|
1819
|
+
return c.text(getApiContract("markdown") as string, 200, {
|
1820
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
1821
|
+
});
|
1822
|
+
});
|
1823
|
+
|
1800
1824
|
return router;
|
1801
1825
|
}
|
1802
1826
|
|
@@ -2074,35 +2098,44 @@ function emitTableTest(table, clientPath, framework = "vitest") {
|
|
2074
2098
|
const Type = pascal(table.name);
|
2075
2099
|
const tableName = table.name;
|
2076
2100
|
const imports = getFrameworkImports(framework);
|
2077
|
-
const
|
2078
|
-
const
|
2101
|
+
const hasForeignKeys = table.fks.length > 0;
|
2102
|
+
const foreignKeySetup = hasForeignKeys ? generateForeignKeySetup(table, clientPath) : null;
|
2103
|
+
const sampleData = generateSampleDataFromSchema(table, hasForeignKeys);
|
2104
|
+
const updateData = generateUpdateDataFromSchema(table);
|
2079
2105
|
return `${imports}
|
2080
2106
|
import { SDK } from '${clientPath}';
|
2081
2107
|
import type { Insert${Type}, Update${Type}, Select${Type} } from '${clientPath}/types/${tableName}';
|
2108
|
+
${foreignKeySetup?.imports || ""}
|
2082
2109
|
|
2083
2110
|
/**
|
2084
2111
|
* Basic tests for ${tableName} table operations
|
2085
2112
|
*
|
2086
2113
|
* These tests demonstrate basic CRUD operations.
|
2087
|
-
* The test data is auto-generated
|
2114
|
+
* The test data is auto-generated based on your schema.
|
2088
2115
|
*
|
2089
|
-
* If tests fail
|
2090
|
-
* 1. Check
|
2091
|
-
* 2. Update the test data below to match your
|
2092
|
-
* 3. Consider adding
|
2116
|
+
* If tests fail:
|
2117
|
+
* 1. Check the error messages for missing required fields
|
2118
|
+
* 2. Update the test data below to match your business requirements
|
2119
|
+
* 3. Consider adding custom tests for business logic in separate files
|
2093
2120
|
*/
|
2094
2121
|
describe('${Type} SDK Operations', () => {
|
2095
2122
|
let sdk: SDK;
|
2096
2123
|
let createdId: string;
|
2124
|
+
${foreignKeySetup?.variables || ""}
|
2097
2125
|
|
2098
|
-
beforeAll(() => {
|
2126
|
+
beforeAll(async () => {
|
2099
2127
|
sdk = new SDK({
|
2100
2128
|
baseUrl: process.env.API_URL || 'http://localhost:3000',
|
2101
2129
|
auth: process.env.API_KEY ? { apiKey: process.env.API_KEY } : undefined
|
2102
2130
|
});
|
2131
|
+
${foreignKeySetup?.setup || ""}
|
2132
|
+
});
|
2133
|
+
|
2134
|
+
${hasForeignKeys && foreignKeySetup?.cleanup ? `afterAll(async () => {
|
2135
|
+
${foreignKeySetup.cleanup}
|
2103
2136
|
});
|
2104
2137
|
|
2105
|
-
${generateTestCases(table, sampleData, updateData)}
|
2138
|
+
` : ""}${generateTestCases(table, sampleData, updateData, hasForeignKeys)}
|
2106
2139
|
});
|
2107
2140
|
`;
|
2108
2141
|
}
|
@@ -2196,6 +2229,7 @@ version: '3.8'
|
|
2196
2229
|
services:
|
2197
2230
|
postgres:
|
2198
2231
|
image: postgres:17-alpine
|
2232
|
+
container_name: postgresdk-test-database
|
2199
2233
|
environment:
|
2200
2234
|
POSTGRES_USER: testuser
|
2201
2235
|
POSTGRES_PASSWORD: testpass
|
@@ -2234,7 +2268,39 @@ set -e
|
|
2234
2268
|
|
2235
2269
|
SCRIPT_DIR="$( cd "$( dirname "\${BASH_SOURCE[0]}" )" && pwd )"
|
2236
2270
|
|
2237
|
-
|
2271
|
+
# Cleanup function to ensure database is stopped
|
2272
|
+
cleanup() {
|
2273
|
+
echo ""
|
2274
|
+
echo "\uD83E\uDDF9 Cleaning up..."
|
2275
|
+
if [ ! -z "\${SERVER_PID}" ]; then
|
2276
|
+
echo " Stopping API server..."
|
2277
|
+
kill $SERVER_PID 2>/dev/null || true
|
2278
|
+
fi
|
2279
|
+
echo " Stopping test database..."
|
2280
|
+
docker-compose -f "$SCRIPT_DIR/docker-compose.yml" stop 2>/dev/null || true
|
2281
|
+
echo " Done!"
|
2282
|
+
}
|
2283
|
+
|
2284
|
+
# Set up cleanup trap
|
2285
|
+
trap cleanup EXIT INT TERM
|
2286
|
+
|
2287
|
+
# Check for existing PostgreSQL container or connection
|
2288
|
+
echo "\uD83D\uDD0D Checking for existing database connections..."
|
2289
|
+
if docker ps | grep -q "5432->5432"; then
|
2290
|
+
echo "⚠️ Found existing PostgreSQL container on port 5432"
|
2291
|
+
echo " Stopping existing container..."
|
2292
|
+
docker ps --filter "publish=5432" --format "{{.ID}}" | xargs -r docker stop
|
2293
|
+
sleep 2
|
2294
|
+
fi
|
2295
|
+
|
2296
|
+
# Clean up any existing test database container
|
2297
|
+
if docker ps -a | grep -q "postgresdk-test-database"; then
|
2298
|
+
echo "\uD83E\uDDF9 Cleaning up existing test database container..."
|
2299
|
+
docker-compose -f "$SCRIPT_DIR/docker-compose.yml" down -v
|
2300
|
+
sleep 2
|
2301
|
+
fi
|
2302
|
+
|
2303
|
+
echo "\uD83D\uDC33 Starting fresh test database..."
|
2238
2304
|
cd "$SCRIPT_DIR"
|
2239
2305
|
docker-compose up -d --wait
|
2240
2306
|
|
@@ -2256,11 +2322,11 @@ echo "⚠️ TODO: Uncomment and customize the API server startup command below
|
|
2256
2322
|
echo ""
|
2257
2323
|
echo " # Example for Node.js/Bun:"
|
2258
2324
|
echo " # cd ../.. && npm run dev &"
|
2259
|
-
echo " # SERVER_PID
|
2325
|
+
echo " # SERVER_PID=\\$!"
|
2260
2326
|
echo ""
|
2261
2327
|
echo " # Example for custom server file:"
|
2262
2328
|
echo " # cd ../.. && node server.js &"
|
2263
|
-
echo " # SERVER_PID
|
2329
|
+
echo " # SERVER_PID=\\$!"
|
2264
2330
|
echo ""
|
2265
2331
|
echo " Please edit this script to start your API server."
|
2266
2332
|
echo ""
|
@@ -2278,12 +2344,6 @@ ${getTestCommand(framework, runCommand)}
|
|
2278
2344
|
|
2279
2345
|
TEST_EXIT_CODE=$?
|
2280
2346
|
|
2281
|
-
# Cleanup
|
2282
|
-
# if [ ! -z "\${SERVER_PID}" ]; then
|
2283
|
-
# echo "\uD83D\uDED1 Stopping API server..."
|
2284
|
-
# kill $SERVER_PID 2>/dev/null || true
|
2285
|
-
# fi
|
2286
|
-
|
2287
2347
|
if [ $TEST_EXIT_CODE -eq 0 ]; then
|
2288
2348
|
echo "✅ Tests completed successfully!"
|
2289
2349
|
else
|
@@ -2294,12 +2354,76 @@ echo ""
|
|
2294
2354
|
echo "\uD83D\uDCCA Test results saved to:"
|
2295
2355
|
echo " $TEST_RESULTS_DIR/"
|
2296
2356
|
echo ""
|
2297
|
-
echo "
|
2298
|
-
echo "
|
2357
|
+
echo "\uD83D\uDCA1 Tips:"
|
2358
|
+
echo " - Database will be stopped automatically on script exit"
|
2359
|
+
echo " - To manually stop the database: docker-compose -f $SCRIPT_DIR/docker-compose.yml down"
|
2360
|
+
echo " - To reset the database: docker-compose -f $SCRIPT_DIR/docker-compose.yml down -v"
|
2299
2361
|
|
2300
2362
|
exit $TEST_EXIT_CODE
|
2301
2363
|
`;
|
2302
2364
|
}
|
2365
|
+
function generateForeignKeySetup(table, clientPath) {
|
2366
|
+
const imports = [];
|
2367
|
+
const variables = [];
|
2368
|
+
const setupStatements = [];
|
2369
|
+
const cleanupStatements = [];
|
2370
|
+
const foreignTables = new Set;
|
2371
|
+
for (const fk of table.fks) {
|
2372
|
+
const foreignTable = fk.toTable;
|
2373
|
+
if (!foreignTables.has(foreignTable)) {
|
2374
|
+
foreignTables.add(foreignTable);
|
2375
|
+
const ForeignType = pascal(foreignTable);
|
2376
|
+
imports.push(`import type { Insert${ForeignType} } from '${clientPath}/types/${foreignTable}';`);
|
2377
|
+
variables.push(`let ${foreignTable}Id: string;`);
|
2378
|
+
setupStatements.push(`
|
2379
|
+
// Create parent ${foreignTable} record for foreign key reference
|
2380
|
+
const ${foreignTable}Data: Insert${ForeignType} = ${generateMinimalDataForTable(foreignTable)};
|
2381
|
+
const created${ForeignType} = await sdk.${foreignTable}.create(${foreignTable}Data);
|
2382
|
+
${foreignTable}Id = created${ForeignType}.id;`);
|
2383
|
+
cleanupStatements.push(`
|
2384
|
+
// Clean up parent ${foreignTable} record
|
2385
|
+
if (${foreignTable}Id) {
|
2386
|
+
try {
|
2387
|
+
await sdk.${foreignTable}.delete(${foreignTable}Id);
|
2388
|
+
} catch (e) {
|
2389
|
+
// Parent might already be deleted due to cascading
|
2390
|
+
}
|
2391
|
+
}`);
|
2392
|
+
}
|
2393
|
+
}
|
2394
|
+
return {
|
2395
|
+
imports: imports.join(`
|
2396
|
+
`),
|
2397
|
+
variables: variables.join(`
|
2398
|
+
`),
|
2399
|
+
setup: setupStatements.join(""),
|
2400
|
+
cleanup: cleanupStatements.join("")
|
2401
|
+
};
|
2402
|
+
}
|
2403
|
+
function generateMinimalDataForTable(tableName) {
|
2404
|
+
if (tableName.includes("author")) {
|
2405
|
+
return `{ name: 'Test Author' }`;
|
2406
|
+
}
|
2407
|
+
if (tableName.includes("book")) {
|
2408
|
+
return `{ title: 'Test Book' }`;
|
2409
|
+
}
|
2410
|
+
if (tableName.includes("tag")) {
|
2411
|
+
return `{ name: 'Test Tag' }`;
|
2412
|
+
}
|
2413
|
+
if (tableName.includes("user")) {
|
2414
|
+
return `{ name: 'Test User', email: 'test@example.com' }`;
|
2415
|
+
}
|
2416
|
+
if (tableName.includes("category") || tableName.includes("categories")) {
|
2417
|
+
return `{ name: 'Test Category' }`;
|
2418
|
+
}
|
2419
|
+
if (tableName.includes("product")) {
|
2420
|
+
return `{ name: 'Test Product', price: 10.99 }`;
|
2421
|
+
}
|
2422
|
+
if (tableName.includes("order")) {
|
2423
|
+
return `{ total: 100.00, status: 'pending' }`;
|
2424
|
+
}
|
2425
|
+
return `{ name: 'Test ${pascal(tableName)}' }`;
|
2426
|
+
}
|
2303
2427
|
function getTestCommand(framework, baseCommand) {
|
2304
2428
|
switch (framework) {
|
2305
2429
|
case "vitest":
|
@@ -2307,7 +2431,7 @@ function getTestCommand(framework, baseCommand) {
|
|
2307
2431
|
case "jest":
|
2308
2432
|
return `${baseCommand} --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
|
2309
2433
|
case "bun":
|
2310
|
-
return
|
2434
|
+
return `NO_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
|
2311
2435
|
default:
|
2312
2436
|
return `${baseCommand} "$@"`;
|
2313
2437
|
}
|
@@ -2324,22 +2448,45 @@ function getFrameworkImports(framework) {
|
|
2324
2448
|
return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
|
2325
2449
|
}
|
2326
2450
|
}
|
2327
|
-
function
|
2451
|
+
function generateSampleDataFromSchema(table, hasForeignKeys = false) {
|
2328
2452
|
const fields = [];
|
2329
|
-
|
2330
|
-
|
2331
|
-
|
2453
|
+
const foreignKeyColumns = new Map;
|
2454
|
+
if (hasForeignKeys) {
|
2455
|
+
for (const fk of table.fks) {
|
2456
|
+
for (let i = 0;i < fk.from.length; i++) {
|
2457
|
+
const fromCol = fk.from[i];
|
2458
|
+
if (fromCol) {
|
2459
|
+
foreignKeyColumns.set(fromCol, fk.toTable);
|
2460
|
+
}
|
2461
|
+
}
|
2332
2462
|
}
|
2333
|
-
|
2334
|
-
|
2463
|
+
}
|
2464
|
+
for (const col of table.columns) {
|
2465
|
+
if (col.hasDefault) {
|
2466
|
+
const autoGenerated = ["id", "created_at", "updated_at", "created", "updated", "modified_at"];
|
2467
|
+
if (autoGenerated.includes(col.name.toLowerCase())) {
|
2468
|
+
continue;
|
2469
|
+
}
|
2335
2470
|
}
|
2336
|
-
if (col.name === "deleted_at") {
|
2471
|
+
if (col.name === "deleted_at" || col.name === "deleted") {
|
2337
2472
|
continue;
|
2338
2473
|
}
|
2339
|
-
|
2340
|
-
|
2341
|
-
|
2342
|
-
|
2474
|
+
if (!col.nullable) {
|
2475
|
+
const foreignTable = foreignKeyColumns.get(col.name);
|
2476
|
+
if (foreignTable) {
|
2477
|
+
fields.push(` ${col.name}: ${foreignTable}Id`);
|
2478
|
+
} else {
|
2479
|
+
const value = generateValueForColumn(col);
|
2480
|
+
fields.push(` ${col.name}: ${value}`);
|
2481
|
+
}
|
2482
|
+
} else {
|
2483
|
+
const foreignTable = foreignKeyColumns.get(col.name);
|
2484
|
+
if (foreignTable) {
|
2485
|
+
fields.push(` ${col.name}: ${foreignTable}Id`);
|
2486
|
+
} else if (shouldIncludeNullableColumn(col)) {
|
2487
|
+
const value = generateValueForColumn(col);
|
2488
|
+
fields.push(` ${col.name}: ${value}`);
|
2489
|
+
}
|
2343
2490
|
}
|
2344
2491
|
}
|
2345
2492
|
return fields.length > 0 ? `{
|
@@ -2347,14 +2494,23 @@ ${fields.join(`,
|
|
2347
2494
|
`)}
|
2348
2495
|
}` : "{}";
|
2349
2496
|
}
|
2350
|
-
function
|
2497
|
+
function generateUpdateDataFromSchema(table) {
|
2351
2498
|
const fields = [];
|
2352
2499
|
for (const col of table.columns) {
|
2353
|
-
if (
|
2500
|
+
if (table.pk.includes(col.name) || col.hasDefault) {
|
2501
|
+
const autoGenerated = ["id", "created_at", "updated_at", "created", "updated", "modified_at"];
|
2502
|
+
if (autoGenerated.includes(col.name.toLowerCase())) {
|
2503
|
+
continue;
|
2504
|
+
}
|
2505
|
+
}
|
2506
|
+
if (col.name.endsWith("_id")) {
|
2354
2507
|
continue;
|
2355
2508
|
}
|
2356
|
-
if (
|
2357
|
-
|
2509
|
+
if (col.name === "deleted_at" || col.name === "deleted") {
|
2510
|
+
continue;
|
2511
|
+
}
|
2512
|
+
if (!col.nullable || shouldIncludeNullableColumn(col)) {
|
2513
|
+
const value = generateValueForColumn(col, true);
|
2358
2514
|
fields.push(` ${col.name}: ${value}`);
|
2359
2515
|
break;
|
2360
2516
|
}
|
@@ -2364,59 +2520,107 @@ ${fields.join(`,
|
|
2364
2520
|
`)}
|
2365
2521
|
}` : "{}";
|
2366
2522
|
}
|
2367
|
-
function
|
2368
|
-
const
|
2369
|
-
|
2370
|
-
|
2371
|
-
|
2523
|
+
function shouldIncludeNullableColumn(col) {
|
2524
|
+
const importantPatterns = [
|
2525
|
+
"_id",
|
2526
|
+
"_by",
|
2527
|
+
"email",
|
2528
|
+
"name",
|
2529
|
+
"title",
|
2530
|
+
"description",
|
2531
|
+
"phone",
|
2532
|
+
"address",
|
2533
|
+
"status",
|
2534
|
+
"type",
|
2535
|
+
"category",
|
2536
|
+
"price",
|
2537
|
+
"amount",
|
2538
|
+
"quantity",
|
2539
|
+
"url",
|
2540
|
+
"slug"
|
2541
|
+
];
|
2542
|
+
const name = col.name.toLowerCase();
|
2543
|
+
return importantPatterns.some((pattern) => name.includes(pattern));
|
2544
|
+
}
|
2545
|
+
function generateValueForColumn(col, isUpdate = false) {
|
2546
|
+
const name = col.name.toLowerCase();
|
2547
|
+
const type = col.pgType.toLowerCase();
|
2372
2548
|
if (name.includes("email")) {
|
2373
2549
|
return `'test${isUpdate ? ".updated" : ""}@example.com'`;
|
2374
2550
|
}
|
2375
|
-
if (name
|
2376
|
-
return `'
|
2551
|
+
if (name.includes("phone")) {
|
2552
|
+
return `'555-${isUpdate ? "0200" : "0100"}'`;
|
2377
2553
|
}
|
2378
|
-
if (name
|
2379
|
-
return `'${isUpdate ? "
|
2554
|
+
if (name.includes("url") || name.includes("website")) {
|
2555
|
+
return `'https://example.com${isUpdate ? "/updated" : ""}'`;
|
2380
2556
|
}
|
2381
|
-
if (name.includes("
|
2382
|
-
return `'
|
2557
|
+
if (name.includes("password")) {
|
2558
|
+
return `'hashedPassword123'`;
|
2559
|
+
}
|
2560
|
+
if (name === "name" || name.includes("_name") || name.includes("name_")) {
|
2561
|
+
return `'Test ${pascal(col.name)}${isUpdate ? " Updated" : ""}'`;
|
2562
|
+
}
|
2563
|
+
if (name === "title" || name.includes("title")) {
|
2564
|
+
return `'Test Title${isUpdate ? " Updated" : ""}'`;
|
2565
|
+
}
|
2566
|
+
if (name.includes("description") || name === "bio" || name === "about") {
|
2567
|
+
return `'Test description${isUpdate ? " updated" : ""}'`;
|
2383
2568
|
}
|
2384
|
-
if (name
|
2385
|
-
return `'
|
2569
|
+
if (name === "slug") {
|
2570
|
+
return `'test-slug${isUpdate ? "-updated" : ""}'`;
|
2386
2571
|
}
|
2387
|
-
if (name === "
|
2572
|
+
if (name === "status") {
|
2388
2573
|
return `'${isUpdate ? "updated" : "active"}'`;
|
2389
2574
|
}
|
2390
|
-
if (name === "
|
2391
|
-
return `'
|
2575
|
+
if (name === "type" || name === "kind" || name === "category") {
|
2576
|
+
return `'${isUpdate ? "type2" : "type1"}'`;
|
2577
|
+
}
|
2578
|
+
if (name === "color" || name === "colour") {
|
2579
|
+
return `'${isUpdate ? "#FF0000" : "#0000FF"}'`;
|
2580
|
+
}
|
2581
|
+
if (name === "gender") {
|
2582
|
+
return `'${isUpdate ? "F" : "M"}'`;
|
2392
2583
|
}
|
2393
|
-
if (name.includes("
|
2394
|
-
return
|
2584
|
+
if (name.includes("price") || name === "cost" || name === "amount") {
|
2585
|
+
return isUpdate ? "99.99" : "10.50";
|
2395
2586
|
}
|
2396
|
-
if (name
|
2397
|
-
return
|
2587
|
+
if (name === "quantity" || name === "count" || name.includes("qty")) {
|
2588
|
+
return isUpdate ? "5" : "1";
|
2398
2589
|
}
|
2399
|
-
if (name
|
2400
|
-
return
|
2590
|
+
if (name === "age") {
|
2591
|
+
return isUpdate ? "30" : "25";
|
2401
2592
|
}
|
2402
|
-
if (name.includes("
|
2403
|
-
return
|
2593
|
+
if (name.includes("percent") || name === "rate" || name === "ratio") {
|
2594
|
+
return isUpdate ? "0.75" : "0.5";
|
2404
2595
|
}
|
2405
|
-
if (name.includes("
|
2406
|
-
return
|
2596
|
+
if (name.includes("latitude") || name === "lat") {
|
2597
|
+
return "40.7128";
|
2407
2598
|
}
|
2408
|
-
if (name.includes("
|
2409
|
-
return
|
2599
|
+
if (name.includes("longitude") || name === "lng" || name === "lon") {
|
2600
|
+
return "-74.0060";
|
2410
2601
|
}
|
2411
|
-
if (
|
2412
|
-
|
2602
|
+
if (type.includes("date") || type.includes("timestamp")) {
|
2603
|
+
if (name.includes("birth") || name === "dob") {
|
2604
|
+
return `new Date('1990-01-01')`;
|
2605
|
+
}
|
2606
|
+
if (name.includes("end") || name.includes("expire")) {
|
2607
|
+
return `new Date('2025-12-31')`;
|
2608
|
+
}
|
2609
|
+
if (name.includes("start") || name.includes("begin")) {
|
2610
|
+
return `new Date('2024-01-01')`;
|
2611
|
+
}
|
2612
|
+
return `new Date()`;
|
2413
2613
|
}
|
2414
2614
|
switch (type) {
|
2415
2615
|
case "text":
|
2416
2616
|
case "varchar":
|
2417
2617
|
case "char":
|
2418
|
-
|
2618
|
+
case "character varying":
|
2619
|
+
return `'test_value${isUpdate ? "_updated" : ""}'`;
|
2419
2620
|
case "int":
|
2621
|
+
case "int2":
|
2622
|
+
case "int4":
|
2623
|
+
case "int8":
|
2420
2624
|
case "integer":
|
2421
2625
|
case "smallint":
|
2422
2626
|
case "bigint":
|
@@ -2426,30 +2630,55 @@ function getSampleValue(type, name, isUpdate = false) {
|
|
2426
2630
|
case "real":
|
2427
2631
|
case "double precision":
|
2428
2632
|
case "float":
|
2633
|
+
case "float4":
|
2634
|
+
case "float8":
|
2429
2635
|
return isUpdate ? "99.99" : "10.50";
|
2430
2636
|
case "boolean":
|
2431
2637
|
case "bool":
|
2432
2638
|
return isUpdate ? "false" : "true";
|
2433
|
-
case "date":
|
2434
|
-
return `'2024-01-01'`;
|
2435
|
-
case "timestamp":
|
2436
|
-
case "timestamptz":
|
2437
|
-
return `new Date().toISOString()`;
|
2438
2639
|
case "json":
|
2439
2640
|
case "jsonb":
|
2440
|
-
return `{ key: 'value' }`;
|
2641
|
+
return `{ key: '${isUpdate ? "updated" : "value"}' }`;
|
2441
2642
|
case "uuid":
|
2442
2643
|
return `'${isUpdate ? "550e8400-e29b-41d4-a716-446655440001" : "550e8400-e29b-41d4-a716-446655440000"}'`;
|
2443
|
-
case "
|
2444
|
-
|
2445
|
-
|
2644
|
+
case "inet":
|
2645
|
+
return `'${isUpdate ? "192.168.1.2" : "192.168.1.1"}'`;
|
2646
|
+
case "cidr":
|
2647
|
+
return `'192.168.1.0/24'`;
|
2648
|
+
case "macaddr":
|
2649
|
+
return `'08:00:2b:01:02:0${isUpdate ? "4" : "3"}'`;
|
2650
|
+
case "xml":
|
2651
|
+
return `'<root>${isUpdate ? "updated" : "value"}</root>'`;
|
2446
2652
|
default:
|
2447
|
-
|
2653
|
+
if (type.endsWith("[]")) {
|
2654
|
+
const baseType = type.slice(0, -2);
|
2655
|
+
if (baseType === "text" || baseType === "varchar") {
|
2656
|
+
return `['item1', 'item2${isUpdate ? "_updated" : ""}']`;
|
2657
|
+
}
|
2658
|
+
if (baseType === "int" || baseType === "integer") {
|
2659
|
+
return `[1, 2, ${isUpdate ? "3" : ""}]`;
|
2660
|
+
}
|
2661
|
+
return `[]`;
|
2662
|
+
}
|
2663
|
+
return `'test${isUpdate ? "_updated" : ""}'`;
|
2448
2664
|
}
|
2449
2665
|
}
|
2450
|
-
function generateTestCases(table, sampleData, updateData) {
|
2666
|
+
function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
|
2451
2667
|
const Type = pascal(table.name);
|
2452
2668
|
const hasData = sampleData !== "{}";
|
2669
|
+
const isJunctionTable = table.pk.length > 1 && table.columns.every((col) => table.pk.includes(col.name) || col.name.endsWith("_id"));
|
2670
|
+
if (isJunctionTable) {
|
2671
|
+
return `it('should create a ${table.name} relationship', async () => {
|
2672
|
+
// This is a junction table for M:N relationships
|
2673
|
+
// Test data depends on parent records created in other tests
|
2674
|
+
expect(true).toBe(true);
|
2675
|
+
});
|
2676
|
+
|
2677
|
+
it('should list ${table.name} relationships', async () => {
|
2678
|
+
const list = await sdk.${table.name}.list({ limit: 10 });
|
2679
|
+
expect(Array.isArray(list)).toBe(true);
|
2680
|
+
});`;
|
2681
|
+
}
|
2453
2682
|
return `it('should create a ${table.name}', async () => {
|
2454
2683
|
const data: Insert${Type} = ${sampleData};
|
2455
2684
|
${hasData ? `
|
@@ -2506,6 +2735,280 @@ function generateTestCases(table, sampleData, updateData) {
|
|
2506
2735
|
});` : ""}`;
|
2507
2736
|
}
|
2508
2737
|
|
2738
|
+
// src/emit-api-contract.ts
|
2739
|
+
function generateApiContract(model, config) {
|
2740
|
+
const resources = [];
|
2741
|
+
const relationships = [];
|
2742
|
+
for (const table of Object.values(model.tables)) {
|
2743
|
+
resources.push(generateResourceContract(table, model));
|
2744
|
+
for (const fk of table.fks) {
|
2745
|
+
relationships.push({
|
2746
|
+
from: table.name,
|
2747
|
+
to: fk.toTable,
|
2748
|
+
type: "many-to-one",
|
2749
|
+
description: `Each ${table.name} belongs to one ${fk.toTable}`
|
2750
|
+
});
|
2751
|
+
}
|
2752
|
+
}
|
2753
|
+
const contract = {
|
2754
|
+
version: "1.0.0",
|
2755
|
+
generatedAt: new Date().toISOString(),
|
2756
|
+
description: "Auto-generated API contract describing all available endpoints, resources, and their relationships",
|
2757
|
+
resources,
|
2758
|
+
relationships
|
2759
|
+
};
|
2760
|
+
if (config.auth?.strategy && config.auth.strategy !== "none") {
|
2761
|
+
contract.authentication = {
|
2762
|
+
type: config.auth.strategy,
|
2763
|
+
description: getAuthDescription(config.auth.strategy)
|
2764
|
+
};
|
2765
|
+
}
|
2766
|
+
return contract;
|
2767
|
+
}
|
2768
|
+
function generateResourceContract(table, model) {
|
2769
|
+
const Type = pascal(table.name);
|
2770
|
+
const basePath = `/v1/${table.name}`;
|
2771
|
+
const endpoints = [
|
2772
|
+
{
|
2773
|
+
method: "GET",
|
2774
|
+
path: basePath,
|
2775
|
+
description: `List all ${table.name} records with optional filtering, sorting, and pagination`,
|
2776
|
+
queryParameters: {
|
2777
|
+
limit: "number - Maximum number of records to return (default: 50)",
|
2778
|
+
offset: "number - Number of records to skip for pagination",
|
2779
|
+
order_by: "string - Field to sort by",
|
2780
|
+
order_dir: "string - Sort direction (asc or desc)",
|
2781
|
+
include: "string - Comma-separated list of related resources to include",
|
2782
|
+
...generateFilterParams(table)
|
2783
|
+
},
|
2784
|
+
responseBody: `Array<${Type}>`
|
2785
|
+
},
|
2786
|
+
{
|
2787
|
+
method: "GET",
|
2788
|
+
path: `${basePath}/:id`,
|
2789
|
+
description: `Get a single ${table.name} record by ID`,
|
2790
|
+
queryParameters: {
|
2791
|
+
include: "string - Comma-separated list of related resources to include"
|
2792
|
+
},
|
2793
|
+
responseBody: `${Type}`
|
2794
|
+
},
|
2795
|
+
{
|
2796
|
+
method: "POST",
|
2797
|
+
path: basePath,
|
2798
|
+
description: `Create a new ${table.name} record`,
|
2799
|
+
requestBody: `Insert${Type}`,
|
2800
|
+
responseBody: `${Type}`
|
2801
|
+
},
|
2802
|
+
{
|
2803
|
+
method: "PATCH",
|
2804
|
+
path: `${basePath}/:id`,
|
2805
|
+
description: `Update an existing ${table.name} record`,
|
2806
|
+
requestBody: `Update${Type}`,
|
2807
|
+
responseBody: `${Type}`
|
2808
|
+
},
|
2809
|
+
{
|
2810
|
+
method: "DELETE",
|
2811
|
+
path: `${basePath}/:id`,
|
2812
|
+
description: `Delete a ${table.name} record`,
|
2813
|
+
responseBody: `${Type}`
|
2814
|
+
}
|
2815
|
+
];
|
2816
|
+
const fields = table.columns.map((col) => generateFieldContract(col, table));
|
2817
|
+
return {
|
2818
|
+
name: Type,
|
2819
|
+
tableName: table.name,
|
2820
|
+
description: `Resource for managing ${table.name} records`,
|
2821
|
+
endpoints,
|
2822
|
+
fields
|
2823
|
+
};
|
2824
|
+
}
|
2825
|
+
function generateFieldContract(column, table) {
|
2826
|
+
const field = {
|
2827
|
+
name: column.name,
|
2828
|
+
type: postgresTypeToJsonType(column.pgType),
|
2829
|
+
required: !column.nullable && !column.hasDefault,
|
2830
|
+
description: generateFieldDescription(column, table)
|
2831
|
+
};
|
2832
|
+
const fk = table.fks.find((fk2) => fk2.from.length === 1 && fk2.from[0] === column.name);
|
2833
|
+
if (fk) {
|
2834
|
+
field.foreignKey = {
|
2835
|
+
table: fk.toTable,
|
2836
|
+
field: fk.to[0] || "id"
|
2837
|
+
};
|
2838
|
+
}
|
2839
|
+
return field;
|
2840
|
+
}
|
2841
|
+
function generateFieldDescription(column, table) {
|
2842
|
+
const descriptions = [];
|
2843
|
+
descriptions.push(`${postgresTypeToJsonType(column.pgType)} field`);
|
2844
|
+
if (column.name === "id") {
|
2845
|
+
descriptions.push("Unique identifier");
|
2846
|
+
} else if (column.name === "created_at") {
|
2847
|
+
descriptions.push("Timestamp when the record was created");
|
2848
|
+
} else if (column.name === "updated_at") {
|
2849
|
+
descriptions.push("Timestamp when the record was last updated");
|
2850
|
+
} else if (column.name === "deleted_at") {
|
2851
|
+
descriptions.push("Soft delete timestamp");
|
2852
|
+
} else if (column.name.endsWith("_id")) {
|
2853
|
+
const relatedTable = column.name.slice(0, -3);
|
2854
|
+
descriptions.push(`Reference to ${relatedTable}`);
|
2855
|
+
} else if (column.name.includes("email")) {
|
2856
|
+
descriptions.push("Email address");
|
2857
|
+
} else if (column.name.includes("phone")) {
|
2858
|
+
descriptions.push("Phone number");
|
2859
|
+
} else if (column.name.includes("name")) {
|
2860
|
+
descriptions.push("Name field");
|
2861
|
+
} else if (column.name.includes("description")) {
|
2862
|
+
descriptions.push("Description text");
|
2863
|
+
} else if (column.name.includes("status")) {
|
2864
|
+
descriptions.push("Status indicator");
|
2865
|
+
} else if (column.name.includes("price") || column.name.includes("amount") || column.name.includes("total")) {
|
2866
|
+
descriptions.push("Monetary value");
|
2867
|
+
}
|
2868
|
+
if (!column.nullable && !column.hasDefault) {
|
2869
|
+
descriptions.push("(required)");
|
2870
|
+
} else if (column.nullable) {
|
2871
|
+
descriptions.push("(optional)");
|
2872
|
+
}
|
2873
|
+
return descriptions.join(" - ");
|
2874
|
+
}
|
2875
|
+
function postgresTypeToJsonType(pgType) {
|
2876
|
+
switch (pgType) {
|
2877
|
+
case "int":
|
2878
|
+
case "integer":
|
2879
|
+
case "smallint":
|
2880
|
+
case "bigint":
|
2881
|
+
case "decimal":
|
2882
|
+
case "numeric":
|
2883
|
+
case "real":
|
2884
|
+
case "double precision":
|
2885
|
+
case "float":
|
2886
|
+
return "number";
|
2887
|
+
case "boolean":
|
2888
|
+
case "bool":
|
2889
|
+
return "boolean";
|
2890
|
+
case "date":
|
2891
|
+
case "timestamp":
|
2892
|
+
case "timestamptz":
|
2893
|
+
return "date/datetime";
|
2894
|
+
case "json":
|
2895
|
+
case "jsonb":
|
2896
|
+
return "object";
|
2897
|
+
case "uuid":
|
2898
|
+
return "uuid";
|
2899
|
+
case "text[]":
|
2900
|
+
case "varchar[]":
|
2901
|
+
return "array<string>";
|
2902
|
+
case "int[]":
|
2903
|
+
case "integer[]":
|
2904
|
+
return "array<number>";
|
2905
|
+
default:
|
2906
|
+
return "string";
|
2907
|
+
}
|
2908
|
+
}
|
2909
|
+
function generateFilterParams(table) {
|
2910
|
+
const filters = {};
|
2911
|
+
for (const col of table.columns) {
|
2912
|
+
const type = postgresTypeToJsonType(col.pgType);
|
2913
|
+
filters[col.name] = `${type} - Filter by exact ${col.name} value`;
|
2914
|
+
if (type === "number" || type === "date/datetime") {
|
2915
|
+
filters[`${col.name}_gt`] = `${type} - Filter where ${col.name} is greater than`;
|
2916
|
+
filters[`${col.name}_gte`] = `${type} - Filter where ${col.name} is greater than or equal`;
|
2917
|
+
filters[`${col.name}_lt`] = `${type} - Filter where ${col.name} is less than`;
|
2918
|
+
filters[`${col.name}_lte`] = `${type} - Filter where ${col.name} is less than or equal`;
|
2919
|
+
}
|
2920
|
+
if (type === "string") {
|
2921
|
+
filters[`${col.name}_like`] = `string - Filter where ${col.name} contains text (case-insensitive)`;
|
2922
|
+
}
|
2923
|
+
}
|
2924
|
+
return filters;
|
2925
|
+
}
|
2926
|
+
function getAuthDescription(strategy) {
|
2927
|
+
switch (strategy) {
|
2928
|
+
case "jwt":
|
2929
|
+
return "JWT Bearer token authentication. Include token in Authorization header: 'Bearer <token>'";
|
2930
|
+
case "apiKey":
|
2931
|
+
return "API Key authentication. Include key in the configured header (e.g., 'x-api-key')";
|
2932
|
+
default:
|
2933
|
+
return "Custom authentication strategy";
|
2934
|
+
}
|
2935
|
+
}
|
2936
|
+
function generateApiContractMarkdown(contract) {
|
2937
|
+
const lines = [];
|
2938
|
+
lines.push("# API Contract");
|
2939
|
+
lines.push("");
|
2940
|
+
lines.push(contract.description);
|
2941
|
+
lines.push("");
|
2942
|
+
lines.push(`**Version:** ${contract.version}`);
|
2943
|
+
lines.push(`**Generated:** ${new Date(contract.generatedAt).toLocaleString()}`);
|
2944
|
+
lines.push("");
|
2945
|
+
if (contract.authentication) {
|
2946
|
+
lines.push("## Authentication");
|
2947
|
+
lines.push("");
|
2948
|
+
lines.push(`**Type:** ${contract.authentication.type}`);
|
2949
|
+
lines.push("");
|
2950
|
+
lines.push(contract.authentication.description);
|
2951
|
+
lines.push("");
|
2952
|
+
}
|
2953
|
+
lines.push("## Resources");
|
2954
|
+
lines.push("");
|
2955
|
+
for (const resource of contract.resources) {
|
2956
|
+
lines.push(`### ${resource.name}`);
|
2957
|
+
lines.push("");
|
2958
|
+
lines.push(resource.description);
|
2959
|
+
lines.push("");
|
2960
|
+
lines.push("**Endpoints:**");
|
2961
|
+
lines.push("");
|
2962
|
+
for (const endpoint of resource.endpoints) {
|
2963
|
+
lines.push(`- \`${endpoint.method} ${endpoint.path}\` - ${endpoint.description}`);
|
2964
|
+
}
|
2965
|
+
lines.push("");
|
2966
|
+
lines.push("**Fields:**");
|
2967
|
+
lines.push("");
|
2968
|
+
for (const field of resource.fields) {
|
2969
|
+
const required = field.required ? " *(required)*" : "";
|
2970
|
+
const fk = field.foreignKey ? ` → ${field.foreignKey.table}` : "";
|
2971
|
+
lines.push(`- \`${field.name}\` (${field.type})${required}${fk} - ${field.description}`);
|
2972
|
+
}
|
2973
|
+
lines.push("");
|
2974
|
+
}
|
2975
|
+
if (contract.relationships.length > 0) {
|
2976
|
+
lines.push("## Relationships");
|
2977
|
+
lines.push("");
|
2978
|
+
for (const rel of contract.relationships) {
|
2979
|
+
lines.push(`- **${rel.from}** → **${rel.to}** (${rel.type}): ${rel.description}`);
|
2980
|
+
}
|
2981
|
+
lines.push("");
|
2982
|
+
}
|
2983
|
+
return lines.join(`
|
2984
|
+
`);
|
2985
|
+
}
|
2986
|
+
function emitApiContract(model, config) {
|
2987
|
+
const contract = generateApiContract(model, config);
|
2988
|
+
const contractJson = JSON.stringify(contract, null, 2);
|
2989
|
+
return `/**
|
2990
|
+
* API Contract
|
2991
|
+
*
|
2992
|
+
* This module exports the API contract that describes all available
|
2993
|
+
* endpoints, resources, and their relationships.
|
2994
|
+
*/
|
2995
|
+
|
2996
|
+
export const apiContract = ${contractJson};
|
2997
|
+
|
2998
|
+
export const apiContractMarkdown = \`${generateApiContractMarkdown(contract).replace(/`/g, "\\`")}\`;
|
2999
|
+
|
3000
|
+
/**
|
3001
|
+
* Helper to get the contract in different formats
|
3002
|
+
*/
|
3003
|
+
export function getApiContract(format: 'json' | 'markdown' = 'json') {
|
3004
|
+
if (format === 'markdown') {
|
3005
|
+
return apiContractMarkdown;
|
3006
|
+
}
|
3007
|
+
return apiContract;
|
3008
|
+
}
|
3009
|
+
`;
|
3010
|
+
}
|
3011
|
+
|
2509
3012
|
// src/types.ts
|
2510
3013
|
function normalizeAuthConfig(input) {
|
2511
3014
|
if (!input)
|
@@ -2649,6 +3152,10 @@ async function generate(configPath) {
|
|
2649
3152
|
path: join(serverDir, "sdk-bundle.ts"),
|
2650
3153
|
content: emitSdkBundle(clientFiles, clientDir)
|
2651
3154
|
});
|
3155
|
+
files.push({
|
3156
|
+
path: join(serverDir, "api-contract.ts"),
|
3157
|
+
content: emitApiContract(model, cfg)
|
3158
|
+
});
|
2652
3159
|
if (generateTests) {
|
2653
3160
|
console.log("\uD83E\uDDEA Generating tests...");
|
2654
3161
|
const relativeClientPath = relative(testDir, clientDir);
|