bonescript-compiler 0.5.5 → 0.5.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 +17 -1
- package/dist/cli.js.map +1 -1
- package/dist/emit_admin.d.ts +7 -0
- package/dist/emit_admin.js +130 -0
- package/dist/emit_admin.js.map +1 -0
- package/dist/emit_audit.d.ts +7 -0
- package/dist/emit_audit.js +89 -0
- package/dist/emit_audit.js.map +1 -0
- package/dist/emit_full.js +22 -0
- package/dist/emit_full.js.map +1 -1
- package/dist/emit_openapi.d.ts +7 -0
- package/dist/emit_openapi.js +333 -0
- package/dist/emit_openapi.js.map +1 -0
- package/dist/emit_postman.d.ts +6 -0
- package/dist/emit_postman.js +126 -0
- package/dist/emit_postman.js.map +1 -0
- package/dist/emit_runtime.js +30 -6
- package/dist/emit_runtime.js.map +1 -1
- package/dist/emit_sdk.d.ts +7 -0
- package/dist/emit_sdk.js +162 -0
- package/dist/emit_sdk.js.map +1 -0
- package/dist/emit_seed.d.ts +6 -0
- package/dist/emit_seed.js +88 -0
- package/dist/emit_seed.js.map +1 -0
- package/dist/emit_zod.d.ts +7 -0
- package/dist/emit_zod.js +115 -0
- package/dist/emit_zod.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +19 -1
- package/dist/index.js.map +1 -1
- package/dist/lowering.js +5 -2
- package/dist/lowering.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +14 -1
- package/src/emit_admin.ts +131 -0
- package/src/emit_audit.ts +112 -0
- package/src/emit_full.ts +29 -0
- package/src/emit_openapi.ts +344 -0
- package/src/emit_postman.ts +145 -0
- package/src/emit_runtime.ts +31 -6
- package/src/emit_sdk.ts +195 -0
- package/src/emit_seed.ts +91 -0
- package/src/emit_zod.ts +111 -0
- package/src/index.ts +9 -0
- package/src/lowering.ts +5 -2
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BoneScript OpenAPI Emitter
|
|
3
|
+
* Generates OpenAPI 3.0.3 YAML and JSON specs from an IRSystem.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as IR from "./ir";
|
|
7
|
+
|
|
8
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function toSnakeCase(s: string): string {
|
|
11
|
+
return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toDashCase(s: string): string {
|
|
15
|
+
return toSnakeCase(s).replace(/_/g, "-");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toPascalCase(s: string): string {
|
|
19
|
+
return s.replace(/(^|_)([a-z])/g, (_: string, _p: string, c: string) => c.toUpperCase());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function irTypeToOpenApi(irType: string): Record<string, unknown> {
|
|
23
|
+
if (irType === "string") return { type: "string" };
|
|
24
|
+
if (irType === "uint" || irType === "int") return { type: "integer" };
|
|
25
|
+
if (irType === "float") return { type: "number" };
|
|
26
|
+
if (irType === "bool") return { type: "boolean" };
|
|
27
|
+
if (irType === "timestamp") return { type: "string", format: "date-time" };
|
|
28
|
+
if (irType === "uuid") return { type: "string", format: "uuid" };
|
|
29
|
+
if (irType === "bytes") return { type: "string", format: "byte" };
|
|
30
|
+
if (irType === "json") return { type: "object" };
|
|
31
|
+
const listMatch = irType.match(/^list<(.+)>$/);
|
|
32
|
+
if (listMatch) return { type: "array", items: irTypeToOpenApi(listMatch[1]) };
|
|
33
|
+
const setMatch = irType.match(/^set<(.+)>$/);
|
|
34
|
+
if (setMatch) return { type: "array", items: irTypeToOpenApi(setMatch[1]) };
|
|
35
|
+
const optMatch = irType.match(/^optional<(.+)>$/);
|
|
36
|
+
if (optMatch) return { ...irTypeToOpenApi(optMatch[1]), nullable: true };
|
|
37
|
+
return { type: "string" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ind(n: number): string {
|
|
41
|
+
return " ".repeat(n);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function yamlValue(v: unknown, depth: number): string {
|
|
45
|
+
if (v === null || v === undefined) return "null";
|
|
46
|
+
if (typeof v === "boolean") return String(v);
|
|
47
|
+
if (typeof v === "number") return String(v);
|
|
48
|
+
if (typeof v === "string") {
|
|
49
|
+
if (
|
|
50
|
+
v.includes(":") ||
|
|
51
|
+
v.includes("#") ||
|
|
52
|
+
v.includes("'") ||
|
|
53
|
+
v.startsWith("{") ||
|
|
54
|
+
v.startsWith("[")
|
|
55
|
+
) {
|
|
56
|
+
return JSON.stringify(v);
|
|
57
|
+
}
|
|
58
|
+
return v;
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(v)) {
|
|
61
|
+
if (v.length === 0) return "[]";
|
|
62
|
+
return (
|
|
63
|
+
"\n" +
|
|
64
|
+
v
|
|
65
|
+
.map((item) => ind(depth) + "- " + yamlValue(item, depth + 1))
|
|
66
|
+
.join("\n")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (typeof v === "object") {
|
|
70
|
+
const entries = Object.entries(v as Record<string, unknown>);
|
|
71
|
+
if (entries.length === 0) return "{}";
|
|
72
|
+
return (
|
|
73
|
+
"\n" +
|
|
74
|
+
entries
|
|
75
|
+
.map(([k, val]) => {
|
|
76
|
+
const valStr = yamlValue(val, depth + 1);
|
|
77
|
+
if (valStr.startsWith("\n")) {
|
|
78
|
+
return ind(depth) + k + ":" + valStr;
|
|
79
|
+
}
|
|
80
|
+
return ind(depth) + k + ": " + valStr;
|
|
81
|
+
})
|
|
82
|
+
.join("\n")
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return String(v);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function objToYaml(obj: Record<string, unknown>, depth = 0): string {
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
91
|
+
const valStr = yamlValue(v, depth + 1);
|
|
92
|
+
if (valStr.startsWith("\n")) {
|
|
93
|
+
lines.push(ind(depth) + k + ":" + valStr);
|
|
94
|
+
} else {
|
|
95
|
+
lines.push(ind(depth) + k + ": " + valStr);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Spec builder ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function buildSpec(system: IR.IRSystem): Record<string, unknown> {
|
|
104
|
+
const paths: Record<string, unknown> = {};
|
|
105
|
+
const schemas: Record<string, unknown> = {};
|
|
106
|
+
|
|
107
|
+
for (const mod of system.modules) {
|
|
108
|
+
if (mod.kind !== "api_service" || mod.models.length === 0) continue;
|
|
109
|
+
|
|
110
|
+
const model = mod.models[0];
|
|
111
|
+
const tableName = toSnakeCase(model.name);
|
|
112
|
+
const modelName = toPascalCase(model.name);
|
|
113
|
+
const collectionPath = "/" + tableName + "s";
|
|
114
|
+
const itemPath = "/" + tableName + "s/{id}";
|
|
115
|
+
|
|
116
|
+
const allMethods: IR.IRMethod[] = mod.interfaces.flatMap((i) => i.methods);
|
|
117
|
+
const crudNames = new Set(["create", "read", "update", "delete", "list"]);
|
|
118
|
+
const capabilityMethods = allMethods.filter(
|
|
119
|
+
(m) => !crudNames.has(m.name.toLowerCase())
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const securityRef = [{ BearerAuth: [] }];
|
|
123
|
+
|
|
124
|
+
const listOp: Record<string, unknown> = {
|
|
125
|
+
summary: "List " + modelName,
|
|
126
|
+
operationId: "list" + modelName,
|
|
127
|
+
tags: [modelName],
|
|
128
|
+
parameters: [
|
|
129
|
+
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
|
|
130
|
+
{
|
|
131
|
+
name: "page_size",
|
|
132
|
+
in: "query",
|
|
133
|
+
schema: { type: "integer", default: 50 },
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
responses: {
|
|
137
|
+
"200": {
|
|
138
|
+
description: "List of " + modelName,
|
|
139
|
+
content: {
|
|
140
|
+
"application/json": {
|
|
141
|
+
schema: {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: {
|
|
144
|
+
items: {
|
|
145
|
+
type: "array",
|
|
146
|
+
items: { $ref: "#/components/schemas/" + modelName },
|
|
147
|
+
},
|
|
148
|
+
total: { type: "integer" },
|
|
149
|
+
page: { type: "integer" },
|
|
150
|
+
page_size: { type: "integer" },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
"401": { description: "Unauthorized" },
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const createOp: Record<string, unknown> = {
|
|
161
|
+
summary: "Create " + modelName,
|
|
162
|
+
operationId: "create" + modelName,
|
|
163
|
+
tags: [modelName],
|
|
164
|
+
security: securityRef,
|
|
165
|
+
requestBody: {
|
|
166
|
+
required: true,
|
|
167
|
+
content: {
|
|
168
|
+
"application/json": {
|
|
169
|
+
schema: { $ref: "#/components/schemas/" + modelName },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
responses: {
|
|
174
|
+
"200": {
|
|
175
|
+
description: "Created",
|
|
176
|
+
content: {
|
|
177
|
+
"application/json": {
|
|
178
|
+
schema: { $ref: "#/components/schemas/" + modelName },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
"401": { description: "Unauthorized" },
|
|
183
|
+
"422": { description: "Precondition failed" },
|
|
184
|
+
"400": { description: "Bad request" },
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
paths[collectionPath] = { get: listOp, post: createOp };
|
|
189
|
+
|
|
190
|
+
const idParam = [
|
|
191
|
+
{
|
|
192
|
+
name: "id",
|
|
193
|
+
in: "path",
|
|
194
|
+
required: true,
|
|
195
|
+
schema: { type: "string", format: "uuid" },
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
paths[itemPath] = {
|
|
200
|
+
get: {
|
|
201
|
+
summary: "Get " + modelName,
|
|
202
|
+
operationId: "get" + modelName,
|
|
203
|
+
tags: [modelName],
|
|
204
|
+
parameters: idParam,
|
|
205
|
+
security: securityRef,
|
|
206
|
+
responses: {
|
|
207
|
+
"200": {
|
|
208
|
+
description: "Found",
|
|
209
|
+
content: {
|
|
210
|
+
"application/json": {
|
|
211
|
+
schema: { $ref: "#/components/schemas/" + modelName },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
"401": { description: "Unauthorized" },
|
|
216
|
+
"400": { description: "Not found" },
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
put: {
|
|
220
|
+
summary: "Update " + modelName,
|
|
221
|
+
operationId: "update" + modelName,
|
|
222
|
+
tags: [modelName],
|
|
223
|
+
parameters: idParam,
|
|
224
|
+
security: securityRef,
|
|
225
|
+
requestBody: {
|
|
226
|
+
required: true,
|
|
227
|
+
content: {
|
|
228
|
+
"application/json": {
|
|
229
|
+
schema: { $ref: "#/components/schemas/" + modelName },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
responses: {
|
|
234
|
+
"200": {
|
|
235
|
+
description: "Updated",
|
|
236
|
+
content: {
|
|
237
|
+
"application/json": {
|
|
238
|
+
schema: { $ref: "#/components/schemas/" + modelName },
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
"401": { description: "Unauthorized" },
|
|
243
|
+
"422": { description: "Precondition failed" },
|
|
244
|
+
"400": { description: "Bad request" },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
delete: {
|
|
248
|
+
summary: "Delete " + modelName,
|
|
249
|
+
operationId: "delete" + modelName,
|
|
250
|
+
tags: [modelName],
|
|
251
|
+
parameters: idParam,
|
|
252
|
+
security: securityRef,
|
|
253
|
+
responses: {
|
|
254
|
+
"200": { description: "Deleted" },
|
|
255
|
+
"401": { description: "Unauthorized" },
|
|
256
|
+
"400": { description: "Not found" },
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
for (const method of capabilityMethods) {
|
|
262
|
+
const capPath = collectionPath + "/" + toDashCase(method.name);
|
|
263
|
+
const capOp: Record<string, unknown> = {
|
|
264
|
+
summary: method.name + " on " + modelName,
|
|
265
|
+
operationId: method.name + modelName,
|
|
266
|
+
tags: [modelName],
|
|
267
|
+
requestBody: {
|
|
268
|
+
required: true,
|
|
269
|
+
content: {
|
|
270
|
+
"application/json": {
|
|
271
|
+
schema: { $ref: "#/components/schemas/" + modelName },
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
responses: {
|
|
276
|
+
"200": {
|
|
277
|
+
description: "Success",
|
|
278
|
+
content: {
|
|
279
|
+
"application/json": {
|
|
280
|
+
schema: {
|
|
281
|
+
type: "object",
|
|
282
|
+
properties: {
|
|
283
|
+
ok: { type: "boolean" },
|
|
284
|
+
action: { type: "string" },
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
"401": { description: "Unauthorized" },
|
|
291
|
+
"422": { description: "Precondition failed" },
|
|
292
|
+
"400": { description: "Bad request" },
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
if (method.authenticated) {
|
|
296
|
+
capOp.security = securityRef;
|
|
297
|
+
}
|
|
298
|
+
paths[capPath] = { post: capOp };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const properties: Record<string, unknown> = {};
|
|
302
|
+
for (const field of model.fields) {
|
|
303
|
+
properties[field.name] = irTypeToOpenApi(field.type);
|
|
304
|
+
}
|
|
305
|
+
schemas[modelName] = {
|
|
306
|
+
type: "object",
|
|
307
|
+
properties,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
openapi: "3.0.3",
|
|
313
|
+
info: {
|
|
314
|
+
title: system.name,
|
|
315
|
+
version: system.version,
|
|
316
|
+
description: "Generated by BoneScript compiler",
|
|
317
|
+
},
|
|
318
|
+
servers: [{ url: "http://localhost:3000" }],
|
|
319
|
+
paths,
|
|
320
|
+
components: {
|
|
321
|
+
securitySchemes: {
|
|
322
|
+
BearerAuth: {
|
|
323
|
+
type: "http",
|
|
324
|
+
scheme: "bearer",
|
|
325
|
+
bearerFormat: "JWT",
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
schemas,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
export function emitOpenApiSpec(system: IR.IRSystem): string {
|
|
336
|
+
const spec = buildSpec(system);
|
|
337
|
+
const lines: string[] = ["# Generated by BoneScript compiler"];
|
|
338
|
+
lines.push(objToYaml(spec));
|
|
339
|
+
return lines.join("\n") + "\n";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function emitOpenApiJson(system: IR.IRSystem): string {
|
|
343
|
+
return JSON.stringify(buildSpec(system), null, 2);
|
|
344
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BoneScript Postman Collection Emitter
|
|
3
|
+
* Generates a Postman Collection v2.1 JSON from an IRSystem.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as IR from "./ir";
|
|
7
|
+
|
|
8
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function toSnakeCase(s: string): string {
|
|
11
|
+
return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toDashCase(s: string): string {
|
|
15
|
+
return toSnakeCase(s).replace(/_/g, "-");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sampleValue(irType: string): unknown {
|
|
19
|
+
if (irType === "string") return "example";
|
|
20
|
+
if (irType === "uint" || irType === "int") return 1;
|
|
21
|
+
if (irType === "float") return 1.0;
|
|
22
|
+
if (irType === "bool") return true;
|
|
23
|
+
if (irType === "uuid") return "00000000-0000-0000-0000-000000000001";
|
|
24
|
+
if (irType === "timestamp") return "2024-01-01T00:00:00.000Z";
|
|
25
|
+
if (irType === "bytes") return "";
|
|
26
|
+
if (irType === "json") return {};
|
|
27
|
+
const listMatch = irType.match(/^list<(.+)>$/);
|
|
28
|
+
if (listMatch) return [];
|
|
29
|
+
const setMatch = irType.match(/^set<(.+)>$/);
|
|
30
|
+
if (setMatch) return [];
|
|
31
|
+
const optMatch = irType.match(/^optional<(.+)>$/);
|
|
32
|
+
if (optMatch) return null;
|
|
33
|
+
return "example";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildSampleBody(model: IR.IRModel): Record<string, unknown> {
|
|
37
|
+
const body: Record<string, unknown> = {};
|
|
38
|
+
for (const field of model.fields) {
|
|
39
|
+
body[field.name] = sampleValue(field.type);
|
|
40
|
+
}
|
|
41
|
+
return body;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeRequest(
|
|
45
|
+
name: string,
|
|
46
|
+
method: string,
|
|
47
|
+
url: string,
|
|
48
|
+
body?: Record<string, unknown>
|
|
49
|
+
): Record<string, unknown> {
|
|
50
|
+
const headers = [
|
|
51
|
+
{ key: "Content-Type", value: "application/json" },
|
|
52
|
+
{ key: "Authorization", value: "Bearer {{token}}" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const req: Record<string, unknown> = {
|
|
56
|
+
name,
|
|
57
|
+
request: {
|
|
58
|
+
method,
|
|
59
|
+
header: headers,
|
|
60
|
+
url: {
|
|
61
|
+
raw: url,
|
|
62
|
+
host: ["{{baseUrl}}"],
|
|
63
|
+
path: url
|
|
64
|
+
.replace("{{baseUrl}}/", "")
|
|
65
|
+
.split("/")
|
|
66
|
+
.filter(Boolean),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (body !== undefined) {
|
|
72
|
+
(req.request as Record<string, unknown>).body = {
|
|
73
|
+
mode: "raw",
|
|
74
|
+
raw: JSON.stringify(body, null, 2),
|
|
75
|
+
options: { raw: { language: "json" } },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return req;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export function emitPostmanCollection(system: IR.IRSystem): string {
|
|
85
|
+
const folders: unknown[] = [];
|
|
86
|
+
|
|
87
|
+
for (const mod of system.modules) {
|
|
88
|
+
if (mod.kind !== "api_service" || mod.models.length === 0) continue;
|
|
89
|
+
|
|
90
|
+
const model = mod.models[0];
|
|
91
|
+
const tableName = toSnakeCase(model.name);
|
|
92
|
+
const baseUrl = `{{baseUrl}}/${tableName}s`;
|
|
93
|
+
const sampleBody = buildSampleBody(model);
|
|
94
|
+
|
|
95
|
+
const items: unknown[] = [
|
|
96
|
+
makeRequest(`List ${model.name}s`, "GET", baseUrl),
|
|
97
|
+
makeRequest(`Create ${model.name}`, "POST", baseUrl, sampleBody),
|
|
98
|
+
makeRequest(`Get ${model.name}`, "GET", `${baseUrl}/:id`),
|
|
99
|
+
makeRequest(`Update ${model.name}`, "PUT", `${baseUrl}/:id`, sampleBody),
|
|
100
|
+
makeRequest(`Delete ${model.name}`, "DELETE", `${baseUrl}/:id`),
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const crudNames = new Set(["create", "read", "update", "delete", "list"]);
|
|
104
|
+
const allMethods: IR.IRMethod[] = mod.interfaces.flatMap((i) => i.methods);
|
|
105
|
+
const capabilityMethods = allMethods.filter(
|
|
106
|
+
(m) => !crudNames.has(m.name.toLowerCase())
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
for (const method of capabilityMethods) {
|
|
110
|
+
const dashName = toDashCase(method.name);
|
|
111
|
+
items.push(
|
|
112
|
+
makeRequest(
|
|
113
|
+
method.name + " " + model.name,
|
|
114
|
+
"POST",
|
|
115
|
+
`${baseUrl}/${dashName}`,
|
|
116
|
+
sampleBody
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
folders.push({
|
|
122
|
+
name: mod.name,
|
|
123
|
+
item: items,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const collection = {
|
|
128
|
+
info: {
|
|
129
|
+
name: system.name,
|
|
130
|
+
schema:
|
|
131
|
+
"https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
|
132
|
+
},
|
|
133
|
+
auth: {
|
|
134
|
+
type: "bearer",
|
|
135
|
+
bearer: [{ key: "token", value: "{{token}}", type: "string" }],
|
|
136
|
+
},
|
|
137
|
+
variable: [
|
|
138
|
+
{ key: "baseUrl", value: "http://localhost:3000" },
|
|
139
|
+
{ key: "token", value: "" },
|
|
140
|
+
],
|
|
141
|
+
item: folders,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return JSON.stringify(collection, null, 2);
|
|
145
|
+
}
|
package/src/emit_runtime.ts
CHANGED
|
@@ -217,6 +217,11 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
217
217
|
lines.push(`import { query, queryOne, execute, pool } from "../db";`);
|
|
218
218
|
lines.push(`import { eventBus } from "../events";`);
|
|
219
219
|
lines.push(`import { requireAuth, AuthContext } from "../auth";`);
|
|
220
|
+
lines.push(`import rateLimit from "express-rate-limit";`);
|
|
221
|
+
// Only import audit if module has audit: true
|
|
222
|
+
if (mod.config["audit"]) {
|
|
223
|
+
lines.push(`import { auditLog } from "../audit";`);
|
|
224
|
+
}
|
|
220
225
|
lines.push(`import { logger } from "../logger";`);
|
|
221
226
|
lines.push(`import { counter } from "../metrics";`);
|
|
222
227
|
lines.push(`import * as __algorithms from "../algorithms";`);
|
|
@@ -242,10 +247,22 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
242
247
|
lines.push(`export const ${toCamelCase(routeBase)}Router = Router();`);
|
|
243
248
|
lines.push(``);
|
|
244
249
|
|
|
250
|
+
// Per-module rate limiter (from policy declaration)
|
|
251
|
+
const modRateLimit = typeof mod.config["rate_limit"] === "number" && (mod.config["rate_limit"] as number) > 0
|
|
252
|
+
? mod.config["rate_limit"] as number : 0;
|
|
253
|
+
const modRateLimitWindowMs = typeof mod.config["rate_limit_window_ms"] === "number"
|
|
254
|
+
? mod.config["rate_limit_window_ms"] as number : 60000;
|
|
255
|
+
if (modRateLimit > 0) {
|
|
256
|
+
lines.push(`// Rate limiter from policy declaration`);
|
|
257
|
+
lines.push(`const __routeRateLimit = rateLimit({ windowMs: ${modRateLimitWindowMs}, max: ${modRateLimit}, standardHeaders: true, legacyHeaders: false });`);
|
|
258
|
+
lines.push(``);
|
|
259
|
+
}
|
|
260
|
+
|
|
245
261
|
// CREATE
|
|
246
262
|
const insertFields = entityModel.fields.filter(f => f.name !== "id" && f.name !== "created_at" && f.name !== "updated_at");
|
|
247
263
|
lines.push(`// CREATE`);
|
|
248
|
-
|
|
264
|
+
const __crudMiddlewares = modRateLimit > 0 ? "__routeRateLimit, requireAuth" : "requireAuth";
|
|
265
|
+
lines.push(`${toCamelCase(routeBase)}Router.post("/", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
249
266
|
lines.push(` try {`);
|
|
250
267
|
lines.push(` const id = uuid();`);
|
|
251
268
|
lines.push(` const { ${insertFields.map(f => f.name).join(", ")} } = req.body;`);
|
|
@@ -270,7 +287,7 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
270
287
|
|
|
271
288
|
// READ
|
|
272
289
|
lines.push(`// READ`);
|
|
273
|
-
lines.push(`${toCamelCase(routeBase)}Router.get("/:id",
|
|
290
|
+
lines.push(`${toCamelCase(routeBase)}Router.get("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
274
291
|
lines.push(` try {`);
|
|
275
292
|
lines.push(` const row = await queryOne(\`SELECT * FROM ${tableName} WHERE id = $1\`, [req.params.id]);`);
|
|
276
293
|
lines.push(` if (!row) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
|
|
@@ -284,7 +301,7 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
284
301
|
// LIST — with optional JOINs for has_one/belongs_to relations
|
|
285
302
|
const joinRelations = mod.relations.filter(r => r.kind === "has_one" || r.kind === "belongs_to");
|
|
286
303
|
lines.push(`// LIST`);
|
|
287
|
-
lines.push(`${toCamelCase(routeBase)}Router.get("/",
|
|
304
|
+
lines.push(`${toCamelCase(routeBase)}Router.get("/", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
288
305
|
lines.push(` try {`);
|
|
289
306
|
lines.push(` const page = parseInt(req.query.page as string) || 1;`);
|
|
290
307
|
lines.push(` const pageSize = Math.min(parseInt(req.query.page_size as string) || 50, 100);`);
|
|
@@ -314,7 +331,7 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
314
331
|
|
|
315
332
|
// UPDATE — with state machine enforcement
|
|
316
333
|
lines.push(`// UPDATE`);
|
|
317
|
-
lines.push(`${toCamelCase(routeBase)}Router.put("/:id",
|
|
334
|
+
lines.push(`${toCamelCase(routeBase)}Router.put("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
318
335
|
lines.push(` const fields = { ...req.body };`);
|
|
319
336
|
if (mod.state_machines.length > 0) {
|
|
320
337
|
const sm = mod.state_machines[0];
|
|
@@ -339,7 +356,7 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
339
356
|
|
|
340
357
|
// DELETE
|
|
341
358
|
lines.push(`// DELETE`);
|
|
342
|
-
lines.push(`${toCamelCase(routeBase)}Router.delete("/:id",
|
|
359
|
+
lines.push(`${toCamelCase(routeBase)}Router.delete("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
343
360
|
lines.push(` try {`);
|
|
344
361
|
lines.push(` const count = await execute(\`DELETE FROM ${tableName} WHERE id = $1\`, [req.params.id]);`);
|
|
345
362
|
lines.push(` if (count === 0) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
|
|
@@ -400,7 +417,15 @@ function emitCapabilityEndpoint(method: IR.IRMethod, mod: IR.IRModule, tableName
|
|
|
400
417
|
const isTransactional = method.sync === "transactional";
|
|
401
418
|
|
|
402
419
|
lines.push(`// CAPABILITY: ${method.name}${isTransactional ? " [transactional]" : ""}${method.retry ? ` [retry: ${method.retry.max_attempts}x ${method.retry.backoff}]` : ""}`);
|
|
403
|
-
|
|
420
|
+
// Build middleware chain: optional rate limiter + requireAuth + optional audit
|
|
421
|
+
const capMiddlewares: string[] = ["requireAuth"];
|
|
422
|
+
if (typeof mod.config["rate_limit"] === "number" && (mod.config["rate_limit"] as number) > 0) {
|
|
423
|
+
capMiddlewares.unshift("__routeRateLimit");
|
|
424
|
+
}
|
|
425
|
+
if (mod.config["audit"]) {
|
|
426
|
+
capMiddlewares.push(`auditLog("${method.name}", "${mod.models[0]?.name ?? ""}")`);
|
|
427
|
+
}
|
|
428
|
+
lines.push(`${routerName}.post("${endpoint}", ${capMiddlewares.join(", ")}, async (req: Request, res: Response) => {`);
|
|
404
429
|
lines.push(` const auth: AuthContext = (req as any).auth;`);
|
|
405
430
|
|
|
406
431
|
// Wrap in retry logic if declared
|