tina4-nodejs 3.13.132 → 3.13.134
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/CLAUDE.md +4 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +3415 -3251
- package/packages/cli/src/commands/generate.ts +33 -22
- package/packages/cli/src/commands/lint.ts +77 -111
- package/packages/core/dist/index.js +3241 -3069
- package/packages/core/src/.tina4-metrics.json +15004 -0
- package/packages/core/src/aiClient.ts +199 -161
- package/packages/core/src/dispatchPipeline.ts +65 -67
- package/packages/core/src/docs.ts +52 -544
- package/packages/core/src/docsParser.ts +270 -0
- package/packages/core/src/docsScanner.ts +121 -0
- package/packages/core/src/docsSignatures.ts +165 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/logger.ts +68 -82
- package/packages/core/src/mcp.ts +32 -60
- package/packages/core/src/messenger.ts +136 -157
- package/packages/core/src/middleware.ts +56 -60
- package/packages/core/src/plan.ts +78 -70
- package/packages/core/src/projectIndex.ts +15 -288
- package/packages/core/src/projectIndexExtractors.ts +126 -0
- package/packages/core/src/projectIndexStorage.ts +122 -0
- package/packages/core/src/push.ts +281 -0
- package/packages/core/src/server.ts +182 -183
- package/packages/frond/dist/index.js +607 -770
- package/packages/frond/src/engine.ts +670 -818
- package/packages/orm/dist/index.js +3334 -3165
- package/packages/orm/src/adapters/mongodb.ts +99 -144
- package/packages/orm/src/baseModel.ts +429 -515
- package/packages/orm/src/fakeData.ts +73 -61
- package/packages/orm/src/migration.ts +96 -126
- package/packages/orm/src/seeder.ts +6 -238
- package/packages/orm/src/seederTable.ts +101 -0
- package/packages/orm/src/seederTypes.ts +14 -0
- package/packages/orm/src/validation.ts +97 -80
- package/packages/swagger/dist/index.js +150 -116
- package/packages/swagger/src/generator.ts +261 -154
- package/types/core/src/aiClient.d.ts +5 -0
- package/types/core/src/docsParser.d.ts +28 -0
- package/types/core/src/docsScanner.d.ts +1 -0
- package/types/core/src/docsSignatures.d.ts +11 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/messenger.d.ts +8 -0
- package/types/core/src/projectIndexExtractors.d.ts +3 -0
- package/types/core/src/projectIndexStorage.d.ts +13 -0
- package/types/core/src/push.d.ts +45 -0
- package/types/frond/src/engine.d.ts +25 -0
- package/types/orm/src/fakeData.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +3 -89
- package/types/orm/src/seederTable.d.ts +9 -0
- package/types/orm/src/seederTypes.d.ts +16 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { FakeData } from "./fakeData.js";
|
|
2
|
+
import { adapterExecute, adapterColumns, adapterInsert } from "./database.js";
|
|
3
|
+
import { Log } from "../../core/src/index.js";
|
|
4
|
+
import type { DatabaseAdapter, FieldDefinition, FieldType } from "./types.js";
|
|
5
|
+
import type { SeedOptions, SeedSummary } from "./seederTypes.js";
|
|
6
|
+
|
|
7
|
+
function normaliseOptions(overrides?: Record<string, unknown>, opts?: SeedOptions):
|
|
8
|
+
Required<Pick<SeedOptions, "clear" | "strict">> & { overrides?: Record<string, unknown> } {
|
|
9
|
+
const merged = { ...(opts ?? {}) };
|
|
10
|
+
return {
|
|
11
|
+
overrides: merged.overrides ?? overrides,
|
|
12
|
+
clear: merged.clear ?? false,
|
|
13
|
+
strict: merged.strict ?? false,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Delete every row, logging but not hiding a clear failure. */
|
|
18
|
+
export async function clearTable(db: DatabaseAdapter, tableName: string): Promise<void> {
|
|
19
|
+
try {
|
|
20
|
+
await adapterExecute(db, `DELETE FROM "${tableName}"`);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
Log.warning(`Seeder: could not clear '${tableName}': ${(e as Error).message}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sqlTypeToFieldType(sqlType: string): FieldType {
|
|
27
|
+
const type = (sqlType || "").toUpperCase();
|
|
28
|
+
if (type.includes("INT")) return "integer";
|
|
29
|
+
if (type.includes("BOOL")) return "boolean";
|
|
30
|
+
if (["REAL", "FLOA", "DOUB", "NUM", "DEC"].some((part) => type.includes(part))) return "number";
|
|
31
|
+
if (type.includes("DATE") || type.includes("TIME")) return "datetime";
|
|
32
|
+
if (type.includes("TEXT") || type.includes("CLOB")) return "text";
|
|
33
|
+
return "string";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build generators from live table metadata for the explicit seedTable path. */
|
|
37
|
+
export async function autoFieldMap(
|
|
38
|
+
db: DatabaseAdapter,
|
|
39
|
+
table: string,
|
|
40
|
+
fake: FakeData = new FakeData(),
|
|
41
|
+
): Promise<Record<string, () => unknown>> {
|
|
42
|
+
const columns = await adapterColumns(db, table);
|
|
43
|
+
const fieldMap: Record<string, () => unknown> = {};
|
|
44
|
+
for (const column of columns) {
|
|
45
|
+
const name = column.name;
|
|
46
|
+
const sqlType = String(column.type ?? "").toUpperCase();
|
|
47
|
+
const generatedPk = column.primaryKey === true &&
|
|
48
|
+
(sqlType.includes("AUTO") || sqlType.includes("SERIAL") || sqlType.includes("IDENTITY") || name.toLowerCase() === "id");
|
|
49
|
+
if (generatedPk) continue;
|
|
50
|
+
fieldMap[name] = () => fake.forField({ type: sqlTypeToFieldType(sqlType) }, name, table);
|
|
51
|
+
}
|
|
52
|
+
return fieldMap;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Seed a table through the adapter insert path, counting or re-raising row failures. */
|
|
56
|
+
export async function seedTable(
|
|
57
|
+
db: DatabaseAdapter,
|
|
58
|
+
tableName: string,
|
|
59
|
+
count = 10,
|
|
60
|
+
fieldMap?: Record<string, (() => unknown) | unknown>,
|
|
61
|
+
overrides?: Record<string, unknown>,
|
|
62
|
+
opts?: SeedOptions,
|
|
63
|
+
): Promise<SeedSummary> {
|
|
64
|
+
if (opts?.seed !== undefined) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"seedTable() no longer accepts opts.seed: it has no generators of its own to seed " +
|
|
67
|
+
"(fieldMap callables are opaque). Build a seeded FakeData yourself and close over it " +
|
|
68
|
+
"in fieldMap, e.g. const fake = new FakeData(42); seedTable(db, table, count, " +
|
|
69
|
+
"{ name: () => fake.name() }).",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const { overrides: effectiveOverrides, clear, strict } = normaliseOptions(overrides, opts);
|
|
73
|
+
if (!fieldMap || Object.keys(fieldMap).length === 0) return { seeded: 0, failed: 0, errors: [] };
|
|
74
|
+
if (clear) await clearTable(db, tableName);
|
|
75
|
+
|
|
76
|
+
let seeded = 0;
|
|
77
|
+
let failed = 0;
|
|
78
|
+
const errors: Array<{ row: number; message: string }> = [];
|
|
79
|
+
for (let i = 0; i < count; i++) {
|
|
80
|
+
try {
|
|
81
|
+
const row: Record<string, unknown> = {};
|
|
82
|
+
for (const [column, generator] of Object.entries(fieldMap)) {
|
|
83
|
+
row[column] = typeof generator === "function" ? (generator as () => unknown)() : generator;
|
|
84
|
+
}
|
|
85
|
+
for (const [column, value] of Object.entries(effectiveOverrides ?? {})) row[column] = value;
|
|
86
|
+
await adapterInsert(db, tableName, row);
|
|
87
|
+
seeded++;
|
|
88
|
+
} catch (e) {
|
|
89
|
+
const message = (e as Error).message ?? String(e);
|
|
90
|
+
if (strict) {
|
|
91
|
+
Log.error(`Seeder: row ${i} failed seeding '${tableName}' (strict): ${message}`);
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
failed++;
|
|
95
|
+
errors.push({ row: i, message });
|
|
96
|
+
Log.warning(`Seeder: row ${i} failed seeding '${tableName}', skipped: ${message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
Log.info(`Seeder: '${tableName}' — seeded ${seeded}, ${failed} failed`);
|
|
100
|
+
return { seeded, failed, errors };
|
|
101
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Result of a seed run. */
|
|
2
|
+
export interface SeedSummary {
|
|
3
|
+
seeded: number;
|
|
4
|
+
failed: number;
|
|
5
|
+
errors: Array<{ row: number; message: string }>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Options shared by the table and ORM seed paths. */
|
|
9
|
+
export interface SeedOptions {
|
|
10
|
+
overrides?: Record<string, unknown>;
|
|
11
|
+
clear?: boolean;
|
|
12
|
+
seed?: number;
|
|
13
|
+
strict?: boolean;
|
|
14
|
+
}
|
|
@@ -5,6 +5,101 @@ export interface ValidationError {
|
|
|
5
5
|
message: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
function error(field: string, message: string): ValidationError {
|
|
9
|
+
return { field, message };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function validateString(
|
|
13
|
+
name: string,
|
|
14
|
+
value: string,
|
|
15
|
+
def: FieldDefinition,
|
|
16
|
+
pattern: RegExp | undefined,
|
|
17
|
+
): ValidationError[] {
|
|
18
|
+
const errors: ValidationError[] = [];
|
|
19
|
+
if (def.minLength !== undefined && value.length < def.minLength) {
|
|
20
|
+
errors.push(error(name, `must be at least ${def.minLength} characters`));
|
|
21
|
+
}
|
|
22
|
+
if (def.maxLength !== undefined && value.length > def.maxLength) {
|
|
23
|
+
errors.push(error(name, `must be at most ${def.maxLength} characters`));
|
|
24
|
+
}
|
|
25
|
+
if (pattern && !pattern.test(value)) {
|
|
26
|
+
errors.push(error(name, "does not match the required format"));
|
|
27
|
+
}
|
|
28
|
+
return errors;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateNumber(name: string, value: unknown, def: FieldDefinition): ValidationError[] {
|
|
32
|
+
const num = typeof value === "string" ? Number(value) : value;
|
|
33
|
+
if (typeof num !== "number" || isNaN(num)) return [error(name, "must be a number")];
|
|
34
|
+
|
|
35
|
+
const errors: ValidationError[] = [];
|
|
36
|
+
if (def.type === "integer" && !Number.isInteger(num)) {
|
|
37
|
+
errors.push(error(name, "must be an integer"));
|
|
38
|
+
}
|
|
39
|
+
if (def.min !== undefined && num < def.min) {
|
|
40
|
+
errors.push(error(name, `must be at least ${def.min}`));
|
|
41
|
+
}
|
|
42
|
+
if (def.max !== undefined && num > def.max) {
|
|
43
|
+
errors.push(error(name, `must be at most ${def.max}`));
|
|
44
|
+
}
|
|
45
|
+
return errors;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateBoolean(name: string, value: unknown): ValidationError[] {
|
|
49
|
+
return typeof value === "boolean" || value === 0 || value === 1 || value === "true" || value === "false"
|
|
50
|
+
? []
|
|
51
|
+
: [error(name, "must be a boolean")];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateDatetime(name: string, value: unknown): ValidationError[] {
|
|
55
|
+
return typeof value === "string" && isNaN(Date.parse(value))
|
|
56
|
+
? [error(name, "must be a valid date/time")]
|
|
57
|
+
: [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateJson(name: string, value: unknown): ValidationError[] {
|
|
61
|
+
return value !== null && typeof value !== "object" && typeof value !== "string"
|
|
62
|
+
? [error(name, "must be a JSON object or array")]
|
|
63
|
+
: [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateForeignKey(name: string, value: unknown): ValidationError[] {
|
|
67
|
+
const fkNum = typeof value === "string" ? Number(value) : value;
|
|
68
|
+
return typeof fkNum !== "number" || isNaN(fkNum) || !Number.isInteger(fkNum)
|
|
69
|
+
? [error(name, "must be a valid foreign key (integer)")]
|
|
70
|
+
: [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateField(
|
|
74
|
+
name: string,
|
|
75
|
+
value: unknown,
|
|
76
|
+
def: FieldDefinition,
|
|
77
|
+
pattern: RegExp | undefined,
|
|
78
|
+
): ValidationError[] {
|
|
79
|
+
if (def.type === "string" || def.type === "text") {
|
|
80
|
+
return typeof value === "string"
|
|
81
|
+
? validateString(name, value, def, pattern)
|
|
82
|
+
: [error(name, "must be a string")];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (def.type === "integer" || def.type === "number" || def.type === "numeric") {
|
|
86
|
+
return validateNumber(name, value, def);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
switch (def.type) {
|
|
90
|
+
case "boolean":
|
|
91
|
+
return validateBoolean(name, value);
|
|
92
|
+
case "datetime":
|
|
93
|
+
return validateDatetime(name, value);
|
|
94
|
+
case "json":
|
|
95
|
+
return validateJson(name, value);
|
|
96
|
+
case "foreignKey":
|
|
97
|
+
return validateForeignKey(name, value);
|
|
98
|
+
default:
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
8
103
|
export function validate(
|
|
9
104
|
data: Record<string, unknown>,
|
|
10
105
|
fields: Record<string, FieldDefinition>,
|
|
@@ -28,92 +123,14 @@ export function validate(
|
|
|
28
123
|
|
|
29
124
|
// Required check (skip on update if field not provided)
|
|
30
125
|
if (def.required && !isUpdate && (value === undefined || value === null || value === "")) {
|
|
31
|
-
errors.push(
|
|
126
|
+
errors.push(error(name, "is required"));
|
|
32
127
|
continue;
|
|
33
128
|
}
|
|
34
129
|
|
|
35
130
|
// Skip further validation if value not provided
|
|
36
131
|
if (value === undefined || value === null) continue;
|
|
37
132
|
|
|
38
|
-
|
|
39
|
-
switch (def.type) {
|
|
40
|
-
case "string":
|
|
41
|
-
case "text":
|
|
42
|
-
if (typeof value !== "string") {
|
|
43
|
-
errors.push({ field: name, message: "must be a string" });
|
|
44
|
-
} else {
|
|
45
|
-
if (def.minLength !== undefined && value.length < def.minLength) {
|
|
46
|
-
errors.push({ field: name, message: `must be at least ${def.minLength} characters` });
|
|
47
|
-
}
|
|
48
|
-
if (def.maxLength !== undefined && value.length > def.maxLength) {
|
|
49
|
-
errors.push({ field: name, message: `must be at most ${def.maxLength} characters` });
|
|
50
|
-
}
|
|
51
|
-
const regex = compiledPatterns.get(name);
|
|
52
|
-
if (regex && !regex.test(value)) {
|
|
53
|
-
// Feature 19 (VALID-TWO-MESSAGES): one canonical wording per rule
|
|
54
|
-
// across BOTH validators. The request Validator says "does not match
|
|
55
|
-
// the required format"; the ORM validator must say the same so a
|
|
56
|
-
// client keying on the message matches either surface.
|
|
57
|
-
errors.push({ field: name, message: `does not match the required format` });
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
break;
|
|
61
|
-
|
|
62
|
-
case "integer":
|
|
63
|
-
case "number":
|
|
64
|
-
case "numeric": {
|
|
65
|
-
const num = typeof value === "string" ? Number(value) : value;
|
|
66
|
-
if (typeof num !== "number" || isNaN(num)) {
|
|
67
|
-
errors.push({ field: name, message: "must be a number" });
|
|
68
|
-
} else {
|
|
69
|
-
if (def.type === "integer" && !Number.isInteger(num)) {
|
|
70
|
-
errors.push({ field: name, message: "must be an integer" });
|
|
71
|
-
}
|
|
72
|
-
if (def.min !== undefined && num < def.min) {
|
|
73
|
-
errors.push({ field: name, message: `must be at least ${def.min}` });
|
|
74
|
-
}
|
|
75
|
-
if (def.max !== undefined && num > def.max) {
|
|
76
|
-
errors.push({ field: name, message: `must be at most ${def.max}` });
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
case "boolean":
|
|
83
|
-
if (typeof value !== "boolean" && value !== 0 && value !== 1 && value !== "true" && value !== "false") {
|
|
84
|
-
errors.push({ field: name, message: "must be a boolean" });
|
|
85
|
-
}
|
|
86
|
-
break;
|
|
87
|
-
|
|
88
|
-
case "datetime":
|
|
89
|
-
if (typeof value === "string" && isNaN(Date.parse(value))) {
|
|
90
|
-
errors.push({ field: name, message: "must be a valid date/time" });
|
|
91
|
-
}
|
|
92
|
-
break;
|
|
93
|
-
|
|
94
|
-
case "json":
|
|
95
|
-
// A JSON column holds an object/array (or a pre-serialised JSON
|
|
96
|
-
// string); reject a bare scalar. A value that can't be JSON-encoded
|
|
97
|
-
// (a circular reference, a BigInt) fails loud at save time.
|
|
98
|
-
if (value !== null && typeof value !== "object" && typeof value !== "string") {
|
|
99
|
-
errors.push({ field: name, message: "must be a JSON object or array" });
|
|
100
|
-
}
|
|
101
|
-
break;
|
|
102
|
-
|
|
103
|
-
case "foreignKey": {
|
|
104
|
-
// Outlier D: previously there was no foreignKey case, so ANY value
|
|
105
|
-
// passed validation silently. A foreign key references another model's
|
|
106
|
-
// primary key — by default an auto-increment integer — so validate it
|
|
107
|
-
// as an integer (a numeric string like "12" is coerced and accepted).
|
|
108
|
-
// This catches the common bug of assigning a whole object / array /
|
|
109
|
-
// non-numeric string to an *_id column before it reaches the driver.
|
|
110
|
-
const fkNum = typeof value === "string" ? Number(value) : value;
|
|
111
|
-
if (typeof fkNum !== "number" || isNaN(fkNum) || !Number.isInteger(fkNum)) {
|
|
112
|
-
errors.push({ field: name, message: "must be a valid foreign key (integer)" });
|
|
113
|
-
}
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
133
|
+
errors.push(...validateField(name, value, def, compiledPatterns.get(name)));
|
|
117
134
|
}
|
|
118
135
|
|
|
119
136
|
return errors;
|
|
@@ -80,12 +80,46 @@ function sanitizeSecurity(reqs, schemes) {
|
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
function generate(routes, models = []) {
|
|
83
|
+
const schemes = resolveSecuritySchemes();
|
|
84
|
+
const spec = {
|
|
85
|
+
openapi: resolveOpenApiVersion(),
|
|
86
|
+
info: buildInfo(),
|
|
87
|
+
servers: resolveServers(),
|
|
88
|
+
paths: {},
|
|
89
|
+
components: {
|
|
90
|
+
schemas: {},
|
|
91
|
+
// Configurable security schemes (v3.13.42): bearerFormat via env, optional
|
|
92
|
+
// apiKey scheme, plus any programmatically-registered schemes (which may
|
|
93
|
+
// override bearerAuth — e.g. an oauth2 scheme with scopes).
|
|
94
|
+
securitySchemes: schemes
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const tableToSchema = /* @__PURE__ */ new Map();
|
|
98
|
+
buildComponentSchemas(models, spec, tableToSchema);
|
|
99
|
+
const ctx = {
|
|
100
|
+
models,
|
|
101
|
+
tableToSchema,
|
|
102
|
+
schemes,
|
|
103
|
+
// Default scheme secured routes use when no explicit meta.security is set.
|
|
104
|
+
defaultScheme: process.env.TINA4_SWAGGER_DEFAULT_SCHEME ?? "bearerAuth",
|
|
105
|
+
// Path filters (comma-separated raw-path prefixes).
|
|
106
|
+
includePrefixes: csv(process.env.TINA4_SWAGGER_INCLUDE),
|
|
107
|
+
excludePrefixes: csv(process.env.TINA4_SWAGGER_EXCLUDE),
|
|
108
|
+
// Reusable custom schemas referenced by routes via meta.requestSchema/responseSchemas.
|
|
109
|
+
refSchemas: /* @__PURE__ */ new Set(),
|
|
110
|
+
usedTags: [],
|
|
111
|
+
seenIds: /* @__PURE__ */ new Set()
|
|
112
|
+
};
|
|
113
|
+
for (const route of routes) {
|
|
114
|
+
buildOperation(route, spec, ctx);
|
|
115
|
+
}
|
|
116
|
+
buildRefSchemas(spec, ctx.refSchemas);
|
|
117
|
+
buildTags(spec, ctx.usedTags);
|
|
118
|
+
return spec;
|
|
119
|
+
}
|
|
120
|
+
function buildInfo() {
|
|
83
121
|
const info = {
|
|
84
122
|
title: process.env.TINA4_SWAGGER_TITLE ?? "Tina4 API",
|
|
85
|
-
// The app's version, defaulting to 1.0.0 — NOT the framework's (Node shipped
|
|
86
|
-
// 0.0.1). description defaults to the empty string, not a canned sentence.
|
|
87
|
-
// Both are the settled cross-framework defaults (parity with the Python
|
|
88
|
-
// master); TINA4_SWAGGER_VERSION / _DESCRIPTION still override.
|
|
89
123
|
version: process.env.TINA4_SWAGGER_VERSION ?? "1.0.0",
|
|
90
124
|
description: process.env.TINA4_SWAGGER_DESCRIPTION ?? ""
|
|
91
125
|
};
|
|
@@ -102,135 +136,134 @@ function generate(routes, models = []) {
|
|
|
102
136
|
const [name, url] = licenseRaw.split("|").map((s) => s.trim());
|
|
103
137
|
info.license = url ? { name, url } : { name };
|
|
104
138
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
info,
|
|
109
|
-
servers: resolveServers(),
|
|
110
|
-
paths: {},
|
|
111
|
-
components: {
|
|
112
|
-
schemas: {},
|
|
113
|
-
// Configurable security schemes (v3.13.42): bearerFormat via env, optional
|
|
114
|
-
// apiKey scheme, plus any programmatically-registered schemes (which may
|
|
115
|
-
// override bearerAuth — e.g. an oauth2 scheme with scopes).
|
|
116
|
-
securitySchemes: schemes
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
const defaultScheme = process.env.TINA4_SWAGGER_DEFAULT_SCHEME ?? "bearerAuth";
|
|
120
|
-
const includePrefixes = csv(process.env.TINA4_SWAGGER_INCLUDE);
|
|
121
|
-
const excludePrefixes = csv(process.env.TINA4_SWAGGER_EXCLUDE);
|
|
122
|
-
const refSchemas = /* @__PURE__ */ new Set();
|
|
123
|
-
const tableToSchema = /* @__PURE__ */ new Map();
|
|
139
|
+
return info;
|
|
140
|
+
}
|
|
141
|
+
function buildComponentSchemas(models, spec, tableToSchema) {
|
|
124
142
|
for (const model of models) {
|
|
125
143
|
const schemaKey = schemaNameForModel(model);
|
|
126
144
|
tableToSchema.set(model.tableName, schemaKey);
|
|
127
145
|
spec.components.schemas[schemaKey] = modelToSchema(model);
|
|
128
146
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
}
|
|
148
|
+
function buildOperation(route, spec, ctx) {
|
|
149
|
+
if (!isIncludedPath(route.pattern, ctx.includePrefixes, ctx.excludePrefixes)) return;
|
|
150
|
+
const openApiPath = patternToOpenAPI(route.pattern);
|
|
151
|
+
const method = route.method.toLowerCase();
|
|
152
|
+
if (!spec.paths[openApiPath]) {
|
|
153
|
+
spec.paths[openApiPath] = {};
|
|
154
|
+
}
|
|
155
|
+
const tags = route.meta?.tags ?? inferTags(route.pattern);
|
|
156
|
+
for (const t of tags) {
|
|
157
|
+
if (!ctx.usedTags.includes(t)) ctx.usedTags.push(t);
|
|
158
|
+
}
|
|
159
|
+
const operation = {
|
|
160
|
+
operationId: uniqueOperationId(method, openApiPath, ctx.seenIds),
|
|
161
|
+
summary: route.meta?.summary ?? `${route.method} ${route.pattern}`,
|
|
162
|
+
tags,
|
|
163
|
+
responses: route.meta?.responses ?? {
|
|
164
|
+
"200": { description: "Successful response" }
|
|
137
165
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
166
|
+
};
|
|
167
|
+
if (route.meta?.description) operation.description = route.meta.description;
|
|
168
|
+
if (route.meta?.deprecated) operation.deprecated = true;
|
|
169
|
+
const parameters = operationParameters(route, method, ctx.models);
|
|
170
|
+
if (parameters.length > 0) operation.parameters = parameters;
|
|
171
|
+
operationRequestBody(route, method, operation, ctx);
|
|
172
|
+
operationResponseSchemas(route, operation, ctx.refSchemas);
|
|
173
|
+
operationSecurity(route, method, operation, ctx.schemes, ctx.defaultScheme);
|
|
174
|
+
spec.paths[openApiPath][method] = operation;
|
|
175
|
+
}
|
|
176
|
+
function operationParameters(route, method, models) {
|
|
177
|
+
let parameters = [];
|
|
178
|
+
const pathParams = extractPathParams(route.pattern);
|
|
179
|
+
if (pathParams.length > 0) {
|
|
180
|
+
parameters = pathParams.map(({ name, schema }) => ({
|
|
181
|
+
name,
|
|
182
|
+
in: "path",
|
|
183
|
+
required: true,
|
|
184
|
+
schema
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
if (method === "get" && !route.pattern.includes("[id]") && !route.pattern.includes("[...")) {
|
|
188
|
+
const modelName = inferModelFromPath(route.pattern);
|
|
189
|
+
if (modelName && models.some((m) => m.tableName === modelName)) {
|
|
190
|
+
parameters = [
|
|
191
|
+
...parameters,
|
|
192
|
+
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
|
|
193
|
+
{ name: "limit", in: "query", schema: { type: "integer", default: 20 } },
|
|
194
|
+
{ name: "sort", in: "query", schema: { type: "string" }, description: "Sort fields (prefix with - for descending)" }
|
|
195
|
+
];
|
|
141
196
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
197
|
+
}
|
|
198
|
+
return parameters;
|
|
199
|
+
}
|
|
200
|
+
function mediaWithExample(schema, example) {
|
|
201
|
+
const media = { schema };
|
|
202
|
+
if (example !== void 0) media.example = example;
|
|
203
|
+
return media;
|
|
204
|
+
}
|
|
205
|
+
function operationRequestBody(route, method, operation, ctx) {
|
|
206
|
+
const reqSchemaRef = parseRequestSchema(route.meta?.requestSchema);
|
|
207
|
+
if (reqSchemaRef && (method === "post" || method === "put" || method === "patch")) {
|
|
208
|
+
ctx.refSchemas.add(reqSchemaRef.name);
|
|
209
|
+
const media = mediaWithExample({ $ref: `#/components/schemas/${reqSchemaRef.name}` }, route.meta?.example);
|
|
210
|
+
operation.requestBody = {
|
|
211
|
+
content: { [reqSchemaRef.contentType]: media }
|
|
149
212
|
};
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
name,
|
|
156
|
-
in: "path",
|
|
157
|
-
required: true,
|
|
158
|
-
schema
|
|
159
|
-
}));
|
|
160
|
-
}
|
|
161
|
-
if (method === "get" && !route.pattern.includes("[id]") && !route.pattern.includes("[...")) {
|
|
162
|
-
const modelName = inferModelFromPath(route.pattern);
|
|
163
|
-
if (modelName && models.some((m) => m.tableName === modelName)) {
|
|
164
|
-
operation.parameters = [
|
|
165
|
-
...operation.parameters ?? [],
|
|
166
|
-
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
|
|
167
|
-
{ name: "limit", in: "query", schema: { type: "integer", default: 20 } },
|
|
168
|
-
{ name: "sort", in: "query", schema: { type: "string" }, description: "Sort fields (prefix with - for descending)" }
|
|
169
|
-
];
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const reqSchemaRef = parseRequestSchema(route.meta?.requestSchema);
|
|
173
|
-
if (reqSchemaRef && (method === "post" || method === "put" || method === "patch")) {
|
|
174
|
-
refSchemas.add(reqSchemaRef.name);
|
|
175
|
-
const media = {
|
|
176
|
-
schema: { $ref: `#/components/schemas/${reqSchemaRef.name}` }
|
|
177
|
-
};
|
|
178
|
-
if (route.meta?.example !== void 0) media.example = route.meta.example;
|
|
213
|
+
} else if (method === "post" || method === "put") {
|
|
214
|
+
const modelName = inferModelFromPath(route.pattern);
|
|
215
|
+
const schemaKey = modelName ? ctx.tableToSchema.get(modelName) : void 0;
|
|
216
|
+
if (schemaKey) {
|
|
217
|
+
const sref = `#/components/schemas/${schemaKey}`;
|
|
179
218
|
operation.requestBody = {
|
|
180
|
-
|
|
219
|
+
required: true,
|
|
220
|
+
content: { "application/json": mediaWithExample({ $ref: sref }, route.meta?.example) }
|
|
181
221
|
};
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (schemaKey) {
|
|
186
|
-
const sref = `#/components/schemas/${schemaKey}`;
|
|
187
|
-
const media = { schema: { $ref: sref } };
|
|
188
|
-
if (route.meta?.example !== void 0) media.example = route.meta.example;
|
|
189
|
-
operation.requestBody = {
|
|
190
|
-
required: true,
|
|
191
|
-
content: { "application/json": media }
|
|
192
|
-
};
|
|
193
|
-
if (route.meta?.responses === void 0) {
|
|
194
|
-
operation.responses = {
|
|
195
|
-
"200": { description: "Successful response", content: { "application/json": { schema: { $ref: sref } } } }
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
} else if (route.meta?.example !== void 0) {
|
|
199
|
-
operation.requestBody = {
|
|
200
|
-
content: { "application/json": { schema: inferSchema(route.meta.example), example: route.meta.example } }
|
|
222
|
+
if (route.meta?.responses === void 0) {
|
|
223
|
+
operation.responses = {
|
|
224
|
+
"200": { description: "Successful response", content: { "application/json": { schema: { $ref: sref } } } }
|
|
201
225
|
};
|
|
202
226
|
}
|
|
227
|
+
} else if (route.meta?.example !== void 0) {
|
|
228
|
+
operation.requestBody = {
|
|
229
|
+
content: { "application/json": { schema: inferSchema(route.meta.example), example: route.meta.example } }
|
|
230
|
+
};
|
|
203
231
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function operationResponseSchemas(route, operation, refSchemas) {
|
|
235
|
+
const respSchemas = parseResponseSchemas(route.meta?.responseSchemas);
|
|
236
|
+
if (respSchemas.length > 0) {
|
|
237
|
+
const responses = operation.responses;
|
|
238
|
+
for (const { status, name, isList } of respSchemas) {
|
|
239
|
+
refSchemas.add(name);
|
|
240
|
+
const sref = `#/components/schemas/${name}`;
|
|
241
|
+
const schema = isList ? { type: "array", items: { $ref: sref } } : { $ref: sref };
|
|
242
|
+
responses[status] = {
|
|
243
|
+
description: status.startsWith("2") ? "Successful response" : "Response",
|
|
244
|
+
content: { "application/json": { schema } }
|
|
245
|
+
};
|
|
216
246
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
} else if (routeRequiresAuth(route, method)) {
|
|
226
|
-
const requirements = [{ [defaultScheme]: [] }];
|
|
227
|
-
if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
|
|
228
|
-
operation.security = sanitizeSecurity(requirements, schemes);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function operationSecurity(route, method, operation, schemes, defaultScheme) {
|
|
250
|
+
const hasExplicitSecurity = route.meta?.security !== void 0 || route.meta?.scopes !== void 0 && route.meta.scopes.length > 0;
|
|
251
|
+
if (hasExplicitSecurity) {
|
|
252
|
+
const normalized = normalizeSecurity(route.meta?.security, route.meta?.scopes);
|
|
253
|
+
operation.security = normalized.length > 0 ? sanitizeSecurity(normalized, schemes) : [];
|
|
254
|
+
if (normalized.length > 0) {
|
|
229
255
|
const responses = operation.responses;
|
|
230
256
|
if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
|
|
231
257
|
}
|
|
232
|
-
|
|
258
|
+
} else if (routeRequiresAuth(route, method)) {
|
|
259
|
+
const requirements = [{ [defaultScheme]: [] }];
|
|
260
|
+
if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
|
|
261
|
+
operation.security = sanitizeSecurity(requirements, schemes);
|
|
262
|
+
const responses = operation.responses;
|
|
263
|
+
if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
|
|
233
264
|
}
|
|
265
|
+
}
|
|
266
|
+
function buildRefSchemas(spec, refSchemas) {
|
|
234
267
|
if (refSchemas.size > 0) {
|
|
235
268
|
const schemas = spec.components.schemas;
|
|
236
269
|
for (const name of refSchemas) {
|
|
@@ -239,10 +272,11 @@ function generate(routes, models = []) {
|
|
|
239
272
|
}
|
|
240
273
|
}
|
|
241
274
|
}
|
|
275
|
+
}
|
|
276
|
+
function buildTags(spec, usedTags) {
|
|
242
277
|
if (usedTags.length > 0) {
|
|
243
278
|
spec.tags = usedTags.map((name) => ({ name }));
|
|
244
279
|
}
|
|
245
|
-
return spec;
|
|
246
280
|
}
|
|
247
281
|
function routeRequiresAuth(route, method) {
|
|
248
282
|
if (route.noAuth) return false;
|