postgresdk 0.6.5 → 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 +413 -13
- package/dist/emit-api-contract.d.ts +60 -0
- package/dist/index.js +413 -13
- 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,11 +2368,14 @@ 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
|
@@ -2364,15 +2391,21 @@ import type { Insert${Type}, Update${Type}, Select${Type} } from '${clientPath}/
|
|
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
|
}
|
@@ -2432,6 +2465,13 @@ export default defineConfig({
|
|
2432
2465
|
testTimeout: 30000,
|
2433
2466
|
hookTimeout: 30000,
|
2434
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
|
+
}
|
2435
2475
|
},
|
2436
2476
|
});
|
2437
2477
|
`;
|
@@ -2512,6 +2552,10 @@ docker-compose up -d --wait
|
|
2512
2552
|
export TEST_DATABASE_URL="postgres://testuser:testpass@localhost:5432/testdb"
|
2513
2553
|
export TEST_API_URL="http://localhost:3000"
|
2514
2554
|
|
2555
|
+
# Force color output for better terminal experience
|
2556
|
+
export FORCE_COLOR=1
|
2557
|
+
export CI=""
|
2558
|
+
|
2515
2559
|
# Wait for database to be ready
|
2516
2560
|
echo "⏳ Waiting for database..."
|
2517
2561
|
sleep 3
|
@@ -2570,16 +2614,67 @@ echo " cd $SCRIPT_DIR && docker-compose down"
|
|
2570
2614
|
exit $TEST_EXIT_CODE
|
2571
2615
|
`;
|
2572
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
|
+
}
|
2573
2668
|
function getTestCommand(framework, baseCommand) {
|
2574
2669
|
switch (framework) {
|
2575
2670
|
case "vitest":
|
2576
|
-
return
|
2671
|
+
return `FORCE_COLOR=1 ${baseCommand} --reporter=default --reporter=json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
|
2577
2672
|
case "jest":
|
2578
|
-
return
|
2673
|
+
return `FORCE_COLOR=1 ${baseCommand} --colors --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
|
2579
2674
|
case "bun":
|
2580
|
-
return
|
2675
|
+
return `FORCE_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
|
2581
2676
|
default:
|
2582
|
-
return
|
2677
|
+
return `FORCE_COLOR=1 ${baseCommand} "$@"`;
|
2583
2678
|
}
|
2584
2679
|
}
|
2585
2680
|
function getFrameworkImports(framework) {
|
@@ -2594,8 +2689,16 @@ function getFrameworkImports(framework) {
|
|
2594
2689
|
return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
|
2595
2690
|
}
|
2596
2691
|
}
|
2597
|
-
function generateSampleData(table) {
|
2692
|
+
function generateSampleData(table, hasForeignKeys = false) {
|
2598
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
|
+
}
|
2599
2702
|
for (const col of table.columns) {
|
2600
2703
|
if (col.name === "id" && col.hasDefault) {
|
2601
2704
|
continue;
|
@@ -2606,10 +2709,17 @@ function generateSampleData(table) {
|
|
2606
2709
|
if (col.name === "deleted_at") {
|
2607
2710
|
continue;
|
2608
2711
|
}
|
2609
|
-
|
2610
|
-
|
2611
|
-
const value = getSampleValue(col.pgType, col.name);
|
2712
|
+
if (!col.nullable) {
|
2713
|
+
const foreignTable = foreignKeyColumns.get(col.name);
|
2714
|
+
const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
|
2612
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
|
+
}
|
2613
2723
|
}
|
2614
2724
|
}
|
2615
2725
|
return fields.length > 0 ? `{
|
@@ -2681,6 +2791,18 @@ function getSampleValue(type, name, isUpdate = false) {
|
|
2681
2791
|
if (name.includes("tier")) {
|
2682
2792
|
return `'Standard'`;
|
2683
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
|
+
}
|
2684
2806
|
switch (type) {
|
2685
2807
|
case "text":
|
2686
2808
|
case "varchar":
|
@@ -2704,7 +2826,7 @@ function getSampleValue(type, name, isUpdate = false) {
|
|
2704
2826
|
return `'2024-01-01'`;
|
2705
2827
|
case "timestamp":
|
2706
2828
|
case "timestamptz":
|
2707
|
-
return `new Date()
|
2829
|
+
return `new Date()`;
|
2708
2830
|
case "json":
|
2709
2831
|
case "jsonb":
|
2710
2832
|
return `{ key: 'value' }`;
|
@@ -2717,7 +2839,7 @@ function getSampleValue(type, name, isUpdate = false) {
|
|
2717
2839
|
return `'test'`;
|
2718
2840
|
}
|
2719
2841
|
}
|
2720
|
-
function generateTestCases(table, sampleData, updateData) {
|
2842
|
+
function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
|
2721
2843
|
const Type = pascal(table.name);
|
2722
2844
|
const hasData = sampleData !== "{}";
|
2723
2845
|
return `it('should create a ${table.name}', async () => {
|
@@ -2776,6 +2898,280 @@ function generateTestCases(table, sampleData, updateData) {
|
|
2776
2898
|
});` : ""}`;
|
2777
2899
|
}
|
2778
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
|
+
|
2779
3175
|
// src/types.ts
|
2780
3176
|
function normalizeAuthConfig(input) {
|
2781
3177
|
if (!input)
|
@@ -2919,6 +3315,10 @@ async function generate(configPath) {
|
|
2919
3315
|
path: join(serverDir, "sdk-bundle.ts"),
|
2920
3316
|
content: emitSdkBundle(clientFiles, clientDir)
|
2921
3317
|
});
|
3318
|
+
files.push({
|
3319
|
+
path: join(serverDir, "api-contract.ts"),
|
3320
|
+
content: emitApiContract(model, cfg)
|
3321
|
+
});
|
2922
3322
|
if (generateTests) {
|
2923
3323
|
console.log("\uD83E\uDDEA Generating tests...");
|
2924
3324
|
const relativeClientPath = relative(testDir, clientDir);
|
@@ -0,0 +1,60 @@
|
|
1
|
+
import type { Model } from "./introspect";
|
2
|
+
import type { Config, AuthConfig } from "./types";
|
3
|
+
export interface ApiContract {
|
4
|
+
version: string;
|
5
|
+
generatedAt: string;
|
6
|
+
description: string;
|
7
|
+
authentication?: {
|
8
|
+
type: string;
|
9
|
+
description: string;
|
10
|
+
};
|
11
|
+
resources: ResourceContract[];
|
12
|
+
relationships: RelationshipContract[];
|
13
|
+
}
|
14
|
+
export interface ResourceContract {
|
15
|
+
name: string;
|
16
|
+
tableName: string;
|
17
|
+
description: string;
|
18
|
+
endpoints: EndpointContract[];
|
19
|
+
fields: FieldContract[];
|
20
|
+
}
|
21
|
+
export interface EndpointContract {
|
22
|
+
method: string;
|
23
|
+
path: string;
|
24
|
+
description: string;
|
25
|
+
requestBody?: any;
|
26
|
+
responseBody?: any;
|
27
|
+
queryParameters?: any;
|
28
|
+
}
|
29
|
+
export interface FieldContract {
|
30
|
+
name: string;
|
31
|
+
type: string;
|
32
|
+
required: boolean;
|
33
|
+
description: string;
|
34
|
+
foreignKey?: {
|
35
|
+
table: string;
|
36
|
+
field: string;
|
37
|
+
};
|
38
|
+
}
|
39
|
+
export interface RelationshipContract {
|
40
|
+
from: string;
|
41
|
+
to: string;
|
42
|
+
type: "one-to-many" | "many-to-one" | "many-to-many";
|
43
|
+
description: string;
|
44
|
+
}
|
45
|
+
/**
|
46
|
+
* Generate a comprehensive API contract in JSON format
|
47
|
+
*/
|
48
|
+
export declare function generateApiContract(model: Model, config: Config & {
|
49
|
+
auth?: AuthConfig;
|
50
|
+
}): ApiContract;
|
51
|
+
/**
|
52
|
+
* Generate a human-readable markdown version of the contract
|
53
|
+
*/
|
54
|
+
export declare function generateApiContractMarkdown(contract: ApiContract): string;
|
55
|
+
/**
|
56
|
+
* Emit the API contract as TypeScript code that can be served as an endpoint
|
57
|
+
*/
|
58
|
+
export declare function emitApiContract(model: Model, config: Config & {
|
59
|
+
auth?: AuthConfig;
|
60
|
+
}): string;
|
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,11 +2098,14 @@ 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
|
2101
|
+
const hasForeignKeys = table.fks.length > 0;
|
2102
|
+
const foreignKeySetup = hasForeignKeys ? generateForeignKeySetup(table, clientPath) : null;
|
2103
|
+
const sampleData = generateSampleData(table, hasForeignKeys);
|
2078
2104
|
const updateData = generateUpdateData(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
|
@@ -2094,15 +2121,21 @@ import type { Insert${Type}, Update${Type}, Select${Type} } from '${clientPath}/
|
|
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
|
}
|
@@ -2162,6 +2195,13 @@ export default defineConfig({
|
|
2162
2195
|
testTimeout: 30000,
|
2163
2196
|
hookTimeout: 30000,
|
2164
2197
|
// The reporters are configured via CLI in the test script
|
2198
|
+
// Force color output in terminal
|
2199
|
+
pool: 'forks',
|
2200
|
+
poolOptions: {
|
2201
|
+
forks: {
|
2202
|
+
singleFork: true,
|
2203
|
+
}
|
2204
|
+
}
|
2165
2205
|
},
|
2166
2206
|
});
|
2167
2207
|
`;
|
@@ -2242,6 +2282,10 @@ docker-compose up -d --wait
|
|
2242
2282
|
export TEST_DATABASE_URL="postgres://testuser:testpass@localhost:5432/testdb"
|
2243
2283
|
export TEST_API_URL="http://localhost:3000"
|
2244
2284
|
|
2285
|
+
# Force color output for better terminal experience
|
2286
|
+
export FORCE_COLOR=1
|
2287
|
+
export CI=""
|
2288
|
+
|
2245
2289
|
# Wait for database to be ready
|
2246
2290
|
echo "⏳ Waiting for database..."
|
2247
2291
|
sleep 3
|
@@ -2300,16 +2344,67 @@ echo " cd $SCRIPT_DIR && docker-compose down"
|
|
2300
2344
|
exit $TEST_EXIT_CODE
|
2301
2345
|
`;
|
2302
2346
|
}
|
2347
|
+
function generateForeignKeySetup(table, clientPath) {
|
2348
|
+
const imports = [];
|
2349
|
+
const variables = [];
|
2350
|
+
const setupStatements = [];
|
2351
|
+
const cleanupStatements = [];
|
2352
|
+
const foreignTables = new Set;
|
2353
|
+
for (const fk of table.fks) {
|
2354
|
+
const foreignTable = fk.toTable;
|
2355
|
+
if (!foreignTables.has(foreignTable)) {
|
2356
|
+
foreignTables.add(foreignTable);
|
2357
|
+
const ForeignType = pascal(foreignTable);
|
2358
|
+
imports.push(`import type { Insert${ForeignType} } from '${clientPath}/types/${foreignTable}';`);
|
2359
|
+
variables.push(`let ${foreignTable}Id: string;`);
|
2360
|
+
setupStatements.push(`
|
2361
|
+
// Create parent ${foreignTable} record for foreign key reference
|
2362
|
+
const ${foreignTable}Data = ${generateMinimalSampleData(foreignTable)};
|
2363
|
+
const created${ForeignType} = await sdk.${foreignTable}.create(${foreignTable}Data);
|
2364
|
+
${foreignTable}Id = created${ForeignType}.id;`);
|
2365
|
+
cleanupStatements.push(`
|
2366
|
+
// Clean up parent ${foreignTable} record
|
2367
|
+
if (${foreignTable}Id) {
|
2368
|
+
try {
|
2369
|
+
await sdk.${foreignTable}.delete(${foreignTable}Id);
|
2370
|
+
} catch (e) {
|
2371
|
+
// Parent might already be deleted due to cascading
|
2372
|
+
}
|
2373
|
+
}`);
|
2374
|
+
}
|
2375
|
+
}
|
2376
|
+
return {
|
2377
|
+
imports: imports.join(`
|
2378
|
+
`),
|
2379
|
+
variables: variables.join(`
|
2380
|
+
`),
|
2381
|
+
setup: setupStatements.join(""),
|
2382
|
+
cleanup: cleanupStatements.join("")
|
2383
|
+
};
|
2384
|
+
}
|
2385
|
+
function generateMinimalSampleData(tableName) {
|
2386
|
+
const commonPatterns = {
|
2387
|
+
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' }`,
|
2388
|
+
tags: `{ name: 'Test Tag' }`,
|
2389
|
+
authors: `{ name: 'Test Author' }`,
|
2390
|
+
books: `{ title: 'Test Book' }`,
|
2391
|
+
users: `{ name: 'Test User', email: 'test@example.com' }`,
|
2392
|
+
categories: `{ name: 'Test Category' }`,
|
2393
|
+
products: `{ name: 'Test Product', price: 10.99 }`,
|
2394
|
+
orders: `{ total: 100.00 }`
|
2395
|
+
};
|
2396
|
+
return commonPatterns[tableName] || `{ name: 'Test ${pascal(tableName)}' }`;
|
2397
|
+
}
|
2303
2398
|
function getTestCommand(framework, baseCommand) {
|
2304
2399
|
switch (framework) {
|
2305
2400
|
case "vitest":
|
2306
|
-
return
|
2401
|
+
return `FORCE_COLOR=1 ${baseCommand} --reporter=default --reporter=json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
|
2307
2402
|
case "jest":
|
2308
|
-
return
|
2403
|
+
return `FORCE_COLOR=1 ${baseCommand} --colors --json --outputFile="$TEST_RESULTS_DIR/results-\${TIMESTAMP}.json" "$@"`;
|
2309
2404
|
case "bun":
|
2310
|
-
return
|
2405
|
+
return `FORCE_COLOR=1 ${baseCommand} "$@" 2>&1 | tee "$TEST_RESULTS_DIR/results-\${TIMESTAMP}.txt"`;
|
2311
2406
|
default:
|
2312
|
-
return
|
2407
|
+
return `FORCE_COLOR=1 ${baseCommand} "$@"`;
|
2313
2408
|
}
|
2314
2409
|
}
|
2315
2410
|
function getFrameworkImports(framework) {
|
@@ -2324,8 +2419,16 @@ function getFrameworkImports(framework) {
|
|
2324
2419
|
return "import { describe, it, expect, beforeAll, afterAll } from 'vitest';";
|
2325
2420
|
}
|
2326
2421
|
}
|
2327
|
-
function generateSampleData(table) {
|
2422
|
+
function generateSampleData(table, hasForeignKeys = false) {
|
2328
2423
|
const fields = [];
|
2424
|
+
const foreignKeyColumns = new Map;
|
2425
|
+
if (hasForeignKeys) {
|
2426
|
+
for (const fk of table.fks) {
|
2427
|
+
if (fk.from.length === 1 && fk.from[0]) {
|
2428
|
+
foreignKeyColumns.set(fk.from[0], fk.toTable);
|
2429
|
+
}
|
2430
|
+
}
|
2431
|
+
}
|
2329
2432
|
for (const col of table.columns) {
|
2330
2433
|
if (col.name === "id" && col.hasDefault) {
|
2331
2434
|
continue;
|
@@ -2336,10 +2439,17 @@ function generateSampleData(table) {
|
|
2336
2439
|
if (col.name === "deleted_at") {
|
2337
2440
|
continue;
|
2338
2441
|
}
|
2339
|
-
|
2340
|
-
|
2341
|
-
const value = getSampleValue(col.pgType, col.name);
|
2442
|
+
if (!col.nullable) {
|
2443
|
+
const foreignTable = foreignKeyColumns.get(col.name);
|
2444
|
+
const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
|
2342
2445
|
fields.push(` ${col.name}: ${value}`);
|
2446
|
+
} else {
|
2447
|
+
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");
|
2448
|
+
if (isImportant) {
|
2449
|
+
const foreignTable = foreignKeyColumns.get(col.name);
|
2450
|
+
const value = foreignTable ? `${foreignTable}Id` : getSampleValue(col.pgType, col.name);
|
2451
|
+
fields.push(` ${col.name}: ${value}`);
|
2452
|
+
}
|
2343
2453
|
}
|
2344
2454
|
}
|
2345
2455
|
return fields.length > 0 ? `{
|
@@ -2411,6 +2521,18 @@ function getSampleValue(type, name, isUpdate = false) {
|
|
2411
2521
|
if (name.includes("tier")) {
|
2412
2522
|
return `'Standard'`;
|
2413
2523
|
}
|
2524
|
+
if (name.includes("emergency")) {
|
2525
|
+
return `'Emergency Contact ${isUpdate ? "Updated" : "Name"}'`;
|
2526
|
+
}
|
2527
|
+
if (name === "date_of_birth" || name.includes("birth")) {
|
2528
|
+
return `new Date('1990-01-01')`;
|
2529
|
+
}
|
2530
|
+
if (name.includes("accommodations")) {
|
2531
|
+
return `'No special accommodations'`;
|
2532
|
+
}
|
2533
|
+
if (name.includes("flight")) {
|
2534
|
+
return `'Economy'`;
|
2535
|
+
}
|
2414
2536
|
switch (type) {
|
2415
2537
|
case "text":
|
2416
2538
|
case "varchar":
|
@@ -2434,7 +2556,7 @@ function getSampleValue(type, name, isUpdate = false) {
|
|
2434
2556
|
return `'2024-01-01'`;
|
2435
2557
|
case "timestamp":
|
2436
2558
|
case "timestamptz":
|
2437
|
-
return `new Date()
|
2559
|
+
return `new Date()`;
|
2438
2560
|
case "json":
|
2439
2561
|
case "jsonb":
|
2440
2562
|
return `{ key: 'value' }`;
|
@@ -2447,7 +2569,7 @@ function getSampleValue(type, name, isUpdate = false) {
|
|
2447
2569
|
return `'test'`;
|
2448
2570
|
}
|
2449
2571
|
}
|
2450
|
-
function generateTestCases(table, sampleData, updateData) {
|
2572
|
+
function generateTestCases(table, sampleData, updateData, hasForeignKeys = false) {
|
2451
2573
|
const Type = pascal(table.name);
|
2452
2574
|
const hasData = sampleData !== "{}";
|
2453
2575
|
return `it('should create a ${table.name}', async () => {
|
@@ -2506,6 +2628,280 @@ function generateTestCases(table, sampleData, updateData) {
|
|
2506
2628
|
});` : ""}`;
|
2507
2629
|
}
|
2508
2630
|
|
2631
|
+
// src/emit-api-contract.ts
|
2632
|
+
function generateApiContract(model, config) {
|
2633
|
+
const resources = [];
|
2634
|
+
const relationships = [];
|
2635
|
+
for (const table of Object.values(model.tables)) {
|
2636
|
+
resources.push(generateResourceContract(table, model));
|
2637
|
+
for (const fk of table.fks) {
|
2638
|
+
relationships.push({
|
2639
|
+
from: table.name,
|
2640
|
+
to: fk.toTable,
|
2641
|
+
type: "many-to-one",
|
2642
|
+
description: `Each ${table.name} belongs to one ${fk.toTable}`
|
2643
|
+
});
|
2644
|
+
}
|
2645
|
+
}
|
2646
|
+
const contract = {
|
2647
|
+
version: "1.0.0",
|
2648
|
+
generatedAt: new Date().toISOString(),
|
2649
|
+
description: "Auto-generated API contract describing all available endpoints, resources, and their relationships",
|
2650
|
+
resources,
|
2651
|
+
relationships
|
2652
|
+
};
|
2653
|
+
if (config.auth?.strategy && config.auth.strategy !== "none") {
|
2654
|
+
contract.authentication = {
|
2655
|
+
type: config.auth.strategy,
|
2656
|
+
description: getAuthDescription(config.auth.strategy)
|
2657
|
+
};
|
2658
|
+
}
|
2659
|
+
return contract;
|
2660
|
+
}
|
2661
|
+
function generateResourceContract(table, model) {
|
2662
|
+
const Type = pascal(table.name);
|
2663
|
+
const basePath = `/v1/${table.name}`;
|
2664
|
+
const endpoints = [
|
2665
|
+
{
|
2666
|
+
method: "GET",
|
2667
|
+
path: basePath,
|
2668
|
+
description: `List all ${table.name} records with optional filtering, sorting, and pagination`,
|
2669
|
+
queryParameters: {
|
2670
|
+
limit: "number - Maximum number of records to return (default: 50)",
|
2671
|
+
offset: "number - Number of records to skip for pagination",
|
2672
|
+
order_by: "string - Field to sort by",
|
2673
|
+
order_dir: "string - Sort direction (asc or desc)",
|
2674
|
+
include: "string - Comma-separated list of related resources to include",
|
2675
|
+
...generateFilterParams(table)
|
2676
|
+
},
|
2677
|
+
responseBody: `Array<${Type}>`
|
2678
|
+
},
|
2679
|
+
{
|
2680
|
+
method: "GET",
|
2681
|
+
path: `${basePath}/:id`,
|
2682
|
+
description: `Get a single ${table.name} record by ID`,
|
2683
|
+
queryParameters: {
|
2684
|
+
include: "string - Comma-separated list of related resources to include"
|
2685
|
+
},
|
2686
|
+
responseBody: `${Type}`
|
2687
|
+
},
|
2688
|
+
{
|
2689
|
+
method: "POST",
|
2690
|
+
path: basePath,
|
2691
|
+
description: `Create a new ${table.name} record`,
|
2692
|
+
requestBody: `Insert${Type}`,
|
2693
|
+
responseBody: `${Type}`
|
2694
|
+
},
|
2695
|
+
{
|
2696
|
+
method: "PATCH",
|
2697
|
+
path: `${basePath}/:id`,
|
2698
|
+
description: `Update an existing ${table.name} record`,
|
2699
|
+
requestBody: `Update${Type}`,
|
2700
|
+
responseBody: `${Type}`
|
2701
|
+
},
|
2702
|
+
{
|
2703
|
+
method: "DELETE",
|
2704
|
+
path: `${basePath}/:id`,
|
2705
|
+
description: `Delete a ${table.name} record`,
|
2706
|
+
responseBody: `${Type}`
|
2707
|
+
}
|
2708
|
+
];
|
2709
|
+
const fields = table.columns.map((col) => generateFieldContract(col, table));
|
2710
|
+
return {
|
2711
|
+
name: Type,
|
2712
|
+
tableName: table.name,
|
2713
|
+
description: `Resource for managing ${table.name} records`,
|
2714
|
+
endpoints,
|
2715
|
+
fields
|
2716
|
+
};
|
2717
|
+
}
|
2718
|
+
function generateFieldContract(column, table) {
|
2719
|
+
const field = {
|
2720
|
+
name: column.name,
|
2721
|
+
type: postgresTypeToJsonType(column.pgType),
|
2722
|
+
required: !column.nullable && !column.hasDefault,
|
2723
|
+
description: generateFieldDescription(column, table)
|
2724
|
+
};
|
2725
|
+
const fk = table.fks.find((fk2) => fk2.from.length === 1 && fk2.from[0] === column.name);
|
2726
|
+
if (fk) {
|
2727
|
+
field.foreignKey = {
|
2728
|
+
table: fk.toTable,
|
2729
|
+
field: fk.to[0] || "id"
|
2730
|
+
};
|
2731
|
+
}
|
2732
|
+
return field;
|
2733
|
+
}
|
2734
|
+
function generateFieldDescription(column, table) {
|
2735
|
+
const descriptions = [];
|
2736
|
+
descriptions.push(`${postgresTypeToJsonType(column.pgType)} field`);
|
2737
|
+
if (column.name === "id") {
|
2738
|
+
descriptions.push("Unique identifier");
|
2739
|
+
} else if (column.name === "created_at") {
|
2740
|
+
descriptions.push("Timestamp when the record was created");
|
2741
|
+
} else if (column.name === "updated_at") {
|
2742
|
+
descriptions.push("Timestamp when the record was last updated");
|
2743
|
+
} else if (column.name === "deleted_at") {
|
2744
|
+
descriptions.push("Soft delete timestamp");
|
2745
|
+
} else if (column.name.endsWith("_id")) {
|
2746
|
+
const relatedTable = column.name.slice(0, -3);
|
2747
|
+
descriptions.push(`Reference to ${relatedTable}`);
|
2748
|
+
} else if (column.name.includes("email")) {
|
2749
|
+
descriptions.push("Email address");
|
2750
|
+
} else if (column.name.includes("phone")) {
|
2751
|
+
descriptions.push("Phone number");
|
2752
|
+
} else if (column.name.includes("name")) {
|
2753
|
+
descriptions.push("Name field");
|
2754
|
+
} else if (column.name.includes("description")) {
|
2755
|
+
descriptions.push("Description text");
|
2756
|
+
} else if (column.name.includes("status")) {
|
2757
|
+
descriptions.push("Status indicator");
|
2758
|
+
} else if (column.name.includes("price") || column.name.includes("amount") || column.name.includes("total")) {
|
2759
|
+
descriptions.push("Monetary value");
|
2760
|
+
}
|
2761
|
+
if (!column.nullable && !column.hasDefault) {
|
2762
|
+
descriptions.push("(required)");
|
2763
|
+
} else if (column.nullable) {
|
2764
|
+
descriptions.push("(optional)");
|
2765
|
+
}
|
2766
|
+
return descriptions.join(" - ");
|
2767
|
+
}
|
2768
|
+
function postgresTypeToJsonType(pgType) {
|
2769
|
+
switch (pgType) {
|
2770
|
+
case "int":
|
2771
|
+
case "integer":
|
2772
|
+
case "smallint":
|
2773
|
+
case "bigint":
|
2774
|
+
case "decimal":
|
2775
|
+
case "numeric":
|
2776
|
+
case "real":
|
2777
|
+
case "double precision":
|
2778
|
+
case "float":
|
2779
|
+
return "number";
|
2780
|
+
case "boolean":
|
2781
|
+
case "bool":
|
2782
|
+
return "boolean";
|
2783
|
+
case "date":
|
2784
|
+
case "timestamp":
|
2785
|
+
case "timestamptz":
|
2786
|
+
return "date/datetime";
|
2787
|
+
case "json":
|
2788
|
+
case "jsonb":
|
2789
|
+
return "object";
|
2790
|
+
case "uuid":
|
2791
|
+
return "uuid";
|
2792
|
+
case "text[]":
|
2793
|
+
case "varchar[]":
|
2794
|
+
return "array<string>";
|
2795
|
+
case "int[]":
|
2796
|
+
case "integer[]":
|
2797
|
+
return "array<number>";
|
2798
|
+
default:
|
2799
|
+
return "string";
|
2800
|
+
}
|
2801
|
+
}
|
2802
|
+
function generateFilterParams(table) {
|
2803
|
+
const filters = {};
|
2804
|
+
for (const col of table.columns) {
|
2805
|
+
const type = postgresTypeToJsonType(col.pgType);
|
2806
|
+
filters[col.name] = `${type} - Filter by exact ${col.name} value`;
|
2807
|
+
if (type === "number" || type === "date/datetime") {
|
2808
|
+
filters[`${col.name}_gt`] = `${type} - Filter where ${col.name} is greater than`;
|
2809
|
+
filters[`${col.name}_gte`] = `${type} - Filter where ${col.name} is greater than or equal`;
|
2810
|
+
filters[`${col.name}_lt`] = `${type} - Filter where ${col.name} is less than`;
|
2811
|
+
filters[`${col.name}_lte`] = `${type} - Filter where ${col.name} is less than or equal`;
|
2812
|
+
}
|
2813
|
+
if (type === "string") {
|
2814
|
+
filters[`${col.name}_like`] = `string - Filter where ${col.name} contains text (case-insensitive)`;
|
2815
|
+
}
|
2816
|
+
}
|
2817
|
+
return filters;
|
2818
|
+
}
|
2819
|
+
function getAuthDescription(strategy) {
|
2820
|
+
switch (strategy) {
|
2821
|
+
case "jwt":
|
2822
|
+
return "JWT Bearer token authentication. Include token in Authorization header: 'Bearer <token>'";
|
2823
|
+
case "apiKey":
|
2824
|
+
return "API Key authentication. Include key in the configured header (e.g., 'x-api-key')";
|
2825
|
+
default:
|
2826
|
+
return "Custom authentication strategy";
|
2827
|
+
}
|
2828
|
+
}
|
2829
|
+
function generateApiContractMarkdown(contract) {
|
2830
|
+
const lines = [];
|
2831
|
+
lines.push("# API Contract");
|
2832
|
+
lines.push("");
|
2833
|
+
lines.push(contract.description);
|
2834
|
+
lines.push("");
|
2835
|
+
lines.push(`**Version:** ${contract.version}`);
|
2836
|
+
lines.push(`**Generated:** ${new Date(contract.generatedAt).toLocaleString()}`);
|
2837
|
+
lines.push("");
|
2838
|
+
if (contract.authentication) {
|
2839
|
+
lines.push("## Authentication");
|
2840
|
+
lines.push("");
|
2841
|
+
lines.push(`**Type:** ${contract.authentication.type}`);
|
2842
|
+
lines.push("");
|
2843
|
+
lines.push(contract.authentication.description);
|
2844
|
+
lines.push("");
|
2845
|
+
}
|
2846
|
+
lines.push("## Resources");
|
2847
|
+
lines.push("");
|
2848
|
+
for (const resource of contract.resources) {
|
2849
|
+
lines.push(`### ${resource.name}`);
|
2850
|
+
lines.push("");
|
2851
|
+
lines.push(resource.description);
|
2852
|
+
lines.push("");
|
2853
|
+
lines.push("**Endpoints:**");
|
2854
|
+
lines.push("");
|
2855
|
+
for (const endpoint of resource.endpoints) {
|
2856
|
+
lines.push(`- \`${endpoint.method} ${endpoint.path}\` - ${endpoint.description}`);
|
2857
|
+
}
|
2858
|
+
lines.push("");
|
2859
|
+
lines.push("**Fields:**");
|
2860
|
+
lines.push("");
|
2861
|
+
for (const field of resource.fields) {
|
2862
|
+
const required = field.required ? " *(required)*" : "";
|
2863
|
+
const fk = field.foreignKey ? ` → ${field.foreignKey.table}` : "";
|
2864
|
+
lines.push(`- \`${field.name}\` (${field.type})${required}${fk} - ${field.description}`);
|
2865
|
+
}
|
2866
|
+
lines.push("");
|
2867
|
+
}
|
2868
|
+
if (contract.relationships.length > 0) {
|
2869
|
+
lines.push("## Relationships");
|
2870
|
+
lines.push("");
|
2871
|
+
for (const rel of contract.relationships) {
|
2872
|
+
lines.push(`- **${rel.from}** → **${rel.to}** (${rel.type}): ${rel.description}`);
|
2873
|
+
}
|
2874
|
+
lines.push("");
|
2875
|
+
}
|
2876
|
+
return lines.join(`
|
2877
|
+
`);
|
2878
|
+
}
|
2879
|
+
function emitApiContract(model, config) {
|
2880
|
+
const contract = generateApiContract(model, config);
|
2881
|
+
const contractJson = JSON.stringify(contract, null, 2);
|
2882
|
+
return `/**
|
2883
|
+
* API Contract
|
2884
|
+
*
|
2885
|
+
* This module exports the API contract that describes all available
|
2886
|
+
* endpoints, resources, and their relationships.
|
2887
|
+
*/
|
2888
|
+
|
2889
|
+
export const apiContract = ${contractJson};
|
2890
|
+
|
2891
|
+
export const apiContractMarkdown = \`${generateApiContractMarkdown(contract).replace(/`/g, "\\`")}\`;
|
2892
|
+
|
2893
|
+
/**
|
2894
|
+
* Helper to get the contract in different formats
|
2895
|
+
*/
|
2896
|
+
export function getApiContract(format: 'json' | 'markdown' = 'json') {
|
2897
|
+
if (format === 'markdown') {
|
2898
|
+
return apiContractMarkdown;
|
2899
|
+
}
|
2900
|
+
return apiContract;
|
2901
|
+
}
|
2902
|
+
`;
|
2903
|
+
}
|
2904
|
+
|
2509
2905
|
// src/types.ts
|
2510
2906
|
function normalizeAuthConfig(input) {
|
2511
2907
|
if (!input)
|
@@ -2649,6 +3045,10 @@ async function generate(configPath) {
|
|
2649
3045
|
path: join(serverDir, "sdk-bundle.ts"),
|
2650
3046
|
content: emitSdkBundle(clientFiles, clientDir)
|
2651
3047
|
});
|
3048
|
+
files.push({
|
3049
|
+
path: join(serverDir, "api-contract.ts"),
|
3050
|
+
content: emitApiContract(model, cfg)
|
3051
|
+
});
|
2652
3052
|
if (generateTests) {
|
2653
3053
|
console.log("\uD83E\uDDEA Generating tests...");
|
2654
3054
|
const relativeClientPath = relative(testDir, clientDir);
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "postgresdk",
|
3
|
-
"version": "0.6.
|
3
|
+
"version": "0.6.6",
|
4
4
|
"description": "Generate a typed server/client SDK from a Postgres schema (includes, Zod, Hono).",
|
5
5
|
"type": "module",
|
6
6
|
"bin": {
|
@@ -28,6 +28,7 @@
|
|
28
28
|
"test:gen-with-tests": "bun src/cli.ts generate -c test/test-with-tests.config.ts",
|
29
29
|
"test:pull": "bun test/test-pull.ts",
|
30
30
|
"test:typecheck": "bun test/test-typecheck.ts",
|
31
|
+
"typecheck": "tsc --noEmit",
|
31
32
|
"prepublishOnly": "npm run build",
|
32
33
|
"publish:patch": "./publish.sh",
|
33
34
|
"publish:minor": "./publish.sh",
|