Initial commit
This commit is contained in:
78
tools/api-format-to-openapi/README.md
Normal file
78
tools/api-format-to-openapi/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# api-format → OpenAPI 3.0
|
||||
|
||||
Абстрактная заготовка под задачу: **из доменного описания API получить OpenAPI 3.0**, затем встроить в общий пайплайн (кнопка / CI / генератор).
|
||||
|
||||
## Что внутри
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `examples/api-format.example.json` | Пример **не-OpenAPI** формата: ресурсы, поля, операции, query для списка |
|
||||
| `prompts/llm-system.md` | Системный промпт для LLM: «верни только JSON OpenAPI 3.0.3» |
|
||||
| `convert.mjs` | CLI: режим `deterministic` (маппинг в коде) и `llm` (OpenAI API) |
|
||||
|
||||
## Пошаговая демонстрация в терминале
|
||||
|
||||
Чтобы **постепенно** увидеть: входной api-format → что делает конвертер → структура OpenAPI:
|
||||
|
||||
```bash
|
||||
cd tools/api-format-to-openapi
|
||||
npm run demo
|
||||
```
|
||||
|
||||
С паузой после каждого шага (нажимай Enter):
|
||||
|
||||
```bash
|
||||
npm run demo:pause
|
||||
```
|
||||
|
||||
Результат кладётся в `demo-output/openapi.json`.
|
||||
|
||||
## Детерминированный режим (без LLM)
|
||||
|
||||
Подходит для **фиксированной** схемы `apiFormatVersion: "1"` как в примере.
|
||||
|
||||
```bash
|
||||
cd tools/api-format-to-openapi
|
||||
node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
|
||||
```
|
||||
|
||||
Или через npm:
|
||||
|
||||
```bash
|
||||
cd tools/api-format-to-openapi
|
||||
npm run convert
|
||||
```
|
||||
|
||||
## Режим LLM
|
||||
|
||||
Когда реальный формат отличается или богаче — прогон через модель с промптом из `prompts/llm-system.md`.
|
||||
|
||||
```bash
|
||||
set OPENAI_API_KEY=sk-...
|
||||
cd tools/api-format-to-openapi
|
||||
node convert.mjs --mode llm --in path/to/your-api-format.json --out ../../openapi.llm.json
|
||||
```
|
||||
|
||||
Переменные:
|
||||
|
||||
- `OPENAI_API_KEY` — обязательно
|
||||
- `OPENAI_MODEL` — по умолчанию `gpt-4o-mini`
|
||||
- `OPENAI_BASE_URL` — по умолчанию `https://api.openai.com/v1` (совместимо с прокси)
|
||||
|
||||
## HTTP-экспортёр для AID (NestJS)
|
||||
|
||||
В `server` добавлен модуль **`AidExportModule`**: `POST /aid/export/openapi` принимает `{ "apiFormat": {...}, "mode"?: "deterministic"|"llm" }` и возвращает `{ "openapi": {...} }`. Подробности: `server/src/aid-export/README.md`.
|
||||
|
||||
## Интеграция позже
|
||||
|
||||
1. Заменить/расширить `examples/api-format.example.json` под ваш настоящий контракт из «алабужского» гита.
|
||||
2. Либо расширить `toOpenApiDeterministic` в `convert.mjs`, либо перейти на `--mode llm` с отточенным промптом.
|
||||
3. Согласовать с AID точный URL, заголовки и (при необходимости) обёртку ответа; при необходимости добавить отдельный маршрут «сырой» OpenAPI без `{ openapi: ... }`.
|
||||
|
||||
## Валидация OpenAPI (опционально)
|
||||
|
||||
После генерации можно проверить спеку любым валидатором, например:
|
||||
|
||||
```bash
|
||||
npx -y @apidevtools/swagger-cli validate ../../openapi.generated.json
|
||||
```
|
||||
341
tools/api-format-to-openapi/convert.mjs
Normal file
341
tools/api-format-to-openapi/convert.mjs
Normal file
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* api-format → OpenAPI 3.0
|
||||
*
|
||||
* Режимы:
|
||||
* --mode deterministic — маппинг только для схемы examples/api-format.example.json (и совместимых)
|
||||
* --mode llm — отправка входного JSON в OpenAI Chat Completions (нужен OPENAI_API_KEY)
|
||||
*
|
||||
* Примеры:
|
||||
* node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
|
||||
* node convert.mjs --mode llm --in my-api.json --out openapi.json
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { mode: "deterministic", input: null, output: null };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--mode") out.mode = argv[++i];
|
||||
else if (a === "--in") out.input = argv[++i];
|
||||
else if (a === "--out") out.output = argv[++i];
|
||||
else if (a === "-h" || a === "--help") out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`
|
||||
Usage: node convert.mjs --in <api-format.json> --out <openapi.json> [--mode deterministic|llm]
|
||||
|
||||
Environment (llm mode):
|
||||
OPENAI_API_KEY required
|
||||
OPENAI_MODEL optional, default gpt-4o-mini
|
||||
OPENAI_BASE_URL optional, default https://api.openai.com/v1
|
||||
`);
|
||||
}
|
||||
|
||||
/** @param {string} t */
|
||||
function fieldToSchema(t) {
|
||||
const map = {
|
||||
string: { type: "string" },
|
||||
uuid: { type: "string", format: "uuid" },
|
||||
int: { type: "integer" },
|
||||
integer: { type: "integer" },
|
||||
number: { type: "number" },
|
||||
float: { type: "number" },
|
||||
boolean: { type: "boolean" },
|
||||
date: { type: "string", format: "date" },
|
||||
datetime: { type: "string", format: "date-time" },
|
||||
};
|
||||
return map[t] || { type: "string", description: `unknown type: ${t}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Детерминированная конвертация для apiFormatVersion "1" с полями как в example.
|
||||
* @param {any} api
|
||||
*/
|
||||
function toOpenApiDeterministic(api) {
|
||||
if (!api || api.apiFormatVersion !== "1") {
|
||||
throw new Error(
|
||||
'deterministic mode: ожидается apiFormatVersion "1". Для другого формата используйте --mode llm или расширьте маппинг в convert.mjs.',
|
||||
);
|
||||
}
|
||||
|
||||
const base = (api.server?.basePath || "/api").replace(/\/$/, "");
|
||||
const info = api.info || { title: "API", version: "1.0.0" };
|
||||
const paths = {};
|
||||
const schemas = {};
|
||||
|
||||
for (const res of api.resources || []) {
|
||||
const name = res.name;
|
||||
const seg = res.pathSegment || name.toLowerCase();
|
||||
const idParam = res.idParam || "id";
|
||||
const idType = res.idType || "uuid";
|
||||
|
||||
const props = {};
|
||||
const required = [];
|
||||
for (const f of res.fields || []) {
|
||||
let sch;
|
||||
if (f.type === "enum" && Array.isArray(f.enumValues)) {
|
||||
sch = { type: "string", enum: f.enumValues };
|
||||
} else {
|
||||
sch = { ...fieldToSchema(f.type) };
|
||||
}
|
||||
if (f.readOnly) sch.readOnly = true;
|
||||
props[f.name] = sch;
|
||||
if (f.required) required.push(f.name);
|
||||
}
|
||||
|
||||
schemas[name] = {
|
||||
type: "object",
|
||||
properties: props,
|
||||
...(required.length ? { required } : {}),
|
||||
};
|
||||
|
||||
const listPath = `${base}/${seg}`;
|
||||
const itemPath = `${base}/${seg}/{${idParam}}`;
|
||||
const idSchema = fieldToSchema(idType);
|
||||
|
||||
const listQuery = [];
|
||||
const lq = res.listQuery;
|
||||
if (lq?.pagination) {
|
||||
for (const p of lq.pagination) {
|
||||
if (p === "_start" || p === "_end")
|
||||
listQuery.push({ name: p, in: "query", schema: { type: "integer" }, description: "pagination" });
|
||||
}
|
||||
}
|
||||
if (lq?.sort) {
|
||||
for (const p of lq.sort) {
|
||||
if (p === "_sort")
|
||||
listQuery.push({ name: "_sort", in: "query", schema: { type: "string" }, description: "sort field" });
|
||||
if (p === "_order")
|
||||
listQuery.push({
|
||||
name: "_order",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["asc", "desc"] },
|
||||
description: "sort order",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (lq?.filters) {
|
||||
for (const p of lq.filters) {
|
||||
if (p === "q")
|
||||
listQuery.push({ name: "q", in: "query", schema: { type: "string" }, description: "full-text search" });
|
||||
else {
|
||||
const field = (res.fields || []).find((x) => x.name === p);
|
||||
const isEnum = field?.type === "enum";
|
||||
listQuery.push({
|
||||
name: p,
|
||||
in: "query",
|
||||
schema: isEnum
|
||||
? { type: "array", items: { type: "string", enum: field.enumValues || [] } }
|
||||
: { type: "string" },
|
||||
style: isEnum ? "form" : undefined,
|
||||
explode: isEnum ? true : undefined,
|
||||
description: isEnum ? "repeat param for multiple values" : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ops = new Set(res.operations || []);
|
||||
|
||||
if (ops.has("list")) {
|
||||
paths[listPath] = paths[listPath] || {};
|
||||
paths[listPath].get = {
|
||||
tags: [name],
|
||||
summary: `List ${name}`,
|
||||
parameters: listQuery,
|
||||
responses: {
|
||||
"200": {
|
||||
description: "OK",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: `#/components/schemas/${name}` } },
|
||||
total: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (ops.has("create")) {
|
||||
paths[listPath] = paths[listPath] || {};
|
||||
paths[listPath].post = {
|
||||
tags: [name],
|
||||
summary: `Create ${name}`,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created",
|
||||
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||
},
|
||||
"400": { description: "Bad request" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (ops.has("get")) {
|
||||
paths[itemPath] = paths[itemPath] || {};
|
||||
paths[itemPath].get = {
|
||||
tags: [name],
|
||||
summary: `Get ${name} by ${idParam}`,
|
||||
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "OK",
|
||||
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||
},
|
||||
"404": { description: "Not found" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (ops.has("update")) {
|
||||
paths[itemPath] = paths[itemPath] || {};
|
||||
paths[itemPath].patch = {
|
||||
tags: [name],
|
||||
summary: `Update ${name}`,
|
||||
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
|
||||
requestBody: {
|
||||
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "OK",
|
||||
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||
},
|
||||
"404": { description: "Not found" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (ops.has("delete")) {
|
||||
paths[itemPath] = paths[itemPath] || {};
|
||||
paths[itemPath].delete = {
|
||||
tags: [name],
|
||||
summary: `Delete ${name}`,
|
||||
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
|
||||
responses: {
|
||||
"204": { description: "No content" },
|
||||
"404": { description: "Not found" },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const doc = {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: info.title,
|
||||
version: info.version,
|
||||
...(info.description ? { description: info.description } : {}),
|
||||
},
|
||||
servers: [{ url: base || "/" }],
|
||||
paths,
|
||||
components: {
|
||||
schemas,
|
||||
...(api.security?.type === "bearer" || api.security?.scheme === "JWT"
|
||||
? {
|
||||
securitySchemes: {
|
||||
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (doc.components.securitySchemes) {
|
||||
doc.security = [{ bearerAuth: [] }];
|
||||
for (const method of Object.values(paths)) {
|
||||
for (const op of Object.values(method)) {
|
||||
if (op && typeof op === "object" && op.responses) op.security = [{ bearerAuth: [] }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function toOpenApiLlm(apiJson) {
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key) throw new Error("OPENAI_API_KEY не задан");
|
||||
|
||||
const model = process.env.OPENAI_MODEL || "gpt-4o-mini";
|
||||
const baseUrl = (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").replace(/\/$/, "");
|
||||
const systemPath = resolve(__dirname, "prompts", "llm-system.md");
|
||||
const system = readFileSync(systemPath, "utf8");
|
||||
const user = JSON.stringify(apiJson, null, 2);
|
||||
|
||||
const res = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: 0.1,
|
||||
messages: [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: `Преобразуй следующий api-format в OpenAPI 3.0.3 JSON:\n\n${user}` },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`OpenAI HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error("Пустой ответ от модели");
|
||||
|
||||
const trimmed = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (args.help || !args.input || !args.output) {
|
||||
usage();
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const inputPath = resolve(process.cwd(), args.input);
|
||||
const outputPath = resolve(process.cwd(), args.output);
|
||||
const raw = readFileSync(inputPath, "utf8");
|
||||
const api = JSON.parse(raw);
|
||||
|
||||
let openapi;
|
||||
if (args.mode === "llm") {
|
||||
openapi = await toOpenApiLlm(api);
|
||||
} else {
|
||||
openapi = toOpenApiDeterministic(api);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, JSON.stringify(openapi, null, 2), "utf8");
|
||||
console.log(`Written: ${outputPath}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e.message || e);
|
||||
process.exit(1);
|
||||
});
|
||||
117
tools/api-format-to-openapi/demo-steps.mjs
Normal file
117
tools/api-format-to-openapi/demo-steps.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Пошаговая демонстрация: api-format → OpenAPI 3.0 (детерминированный режим).
|
||||
*
|
||||
* node demo-steps.mjs — все шаги подряд в консоли
|
||||
* node demo-steps.mjs --pause — пауза после каждого шага (Enter)
|
||||
*
|
||||
* Результат также пишется в demo-output/openapi.json рядом со скриптом.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const EXAMPLE = join(__dirname, "examples", "api-format.example.json");
|
||||
const OUT_DIR = join(__dirname, "demo-output");
|
||||
const OUT_OPENAPI = join(OUT_DIR, "openapi.json");
|
||||
const usePause = process.argv.includes("--pause");
|
||||
|
||||
function banner(title) {
|
||||
const line = "═".repeat(Math.min(60, title.length + 8));
|
||||
console.log(`\n${line}\n ${title}\n${line}\n`);
|
||||
}
|
||||
|
||||
async function pause(msg = "Нажми Enter, чтобы перейти к следующему шагу…") {
|
||||
if (!usePause) return;
|
||||
const rl = createInterface({ input, output });
|
||||
await rl.question(msg);
|
||||
rl.close();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.clear?.();
|
||||
|
||||
banner("Шаг 0. Задача");
|
||||
console.log(
|
||||
"У нас есть описание API в СВОЁМ формате (api-format), не OpenAPI.\n" +
|
||||
"Нужно получить стандартную спецификацию OpenAPI 3.0 — для Swagger, клиентов, AID.\n" +
|
||||
"Сейчас покажем путь на учебном примере (детерминированный маппинг в convert.mjs).",
|
||||
);
|
||||
await pause();
|
||||
|
||||
banner("Шаг 1. Входной файл (фрагмент api-format)");
|
||||
console.log(`Файл: ${EXAMPLE}\n`);
|
||||
const rawIn = readFileSync(EXAMPLE, "utf8");
|
||||
const apiFormat = JSON.parse(rawIn);
|
||||
console.log(JSON.stringify(apiFormat, null, 2));
|
||||
console.log(
|
||||
"\n↑ Это НЕ OpenAPI. Здесь: версия формата, info, basePath, ресурс Equipment с полями и операциями CRUD.",
|
||||
);
|
||||
await pause();
|
||||
|
||||
banner("Шаг 2. Что делает конвертер (логика)");
|
||||
console.log(`
|
||||
• apiFormatVersion "1" → включается ветка toOpenApiDeterministic в convert.mjs
|
||||
• Ресурс Equipment → components.schemas.Equipment + пути /api/equipment и /api/equipment/{id}
|
||||
• Поля (string, uuid, enum…) → JSON Schema в components.schemas
|
||||
• listQuery → query-параметры (_start, _end, _sort, _order, q, status…)
|
||||
• security bearer → components.securitySchemes + security на операциях
|
||||
`);
|
||||
await pause();
|
||||
|
||||
banner("Шаг 3. Запуск convert.mjs");
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
const convertScript = join(__dirname, "convert.mjs");
|
||||
console.log(`Команда:\n node convert.mjs --in examples/api-format.example.json --out demo-output/openapi.json\n`);
|
||||
execFileSync(process.execPath, [convertScript, "--in", EXAMPLE, "--out", OUT_OPENAPI], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
console.log(`\nГотово. Файл: ${OUT_OPENAPI}`);
|
||||
await pause();
|
||||
|
||||
banner("Шаг 4. Результат — структура OpenAPI");
|
||||
const spec = JSON.parse(readFileSync(OUT_OPENAPI, "utf8"));
|
||||
console.log(`openapi: ${spec.openapi}`);
|
||||
console.log(`title: ${spec.info?.title}`);
|
||||
console.log(`version: ${spec.info?.version}`);
|
||||
console.log("\nПути (paths):");
|
||||
for (const p of Object.keys(spec.paths || {}).sort()) {
|
||||
const methods = Object.keys(spec.paths[p]).join(", ");
|
||||
console.log(` ${p} [${methods}]`);
|
||||
}
|
||||
console.log("\nСхемы (components.schemas):", Object.keys(spec.components?.schemas || {}).join(", "));
|
||||
await pause();
|
||||
|
||||
banner("Шаг 5. Фрагмент: GET список (одна операция)");
|
||||
const listPath = Object.keys(spec.paths || {}).find((k) => k.endsWith("/equipment") && !k.includes("{"));
|
||||
if (listPath && spec.paths[listPath]?.get) {
|
||||
console.log(JSON.stringify({ [listPath]: { get: spec.paths[listPath].get } }, null, 2));
|
||||
} else {
|
||||
console.log("(путь списка не найден — открой demo-output/openapi.json)");
|
||||
}
|
||||
await pause();
|
||||
|
||||
banner("Шаг 6. Как проверить дальше");
|
||||
console.log(`
|
||||
1) Открой целиком: ${OUT_OPENAPI}
|
||||
2) Валидация (из корня репозитория):
|
||||
npx -y @apidevtools/swagger-cli validate tools/api-format-to-openapi/demo-output/openapi.json
|
||||
3) Через Nest (сервер на 3001):
|
||||
POST http://127.0.0.1:3001/aid/export/openapi
|
||||
тело: { "apiFormat": <содержимое api-format.example.json>, "mode": "deterministic" }
|
||||
4) Режим LLM (другой входной JSON):
|
||||
node convert.mjs --mode llm --in your.json --out openapi.llm.json
|
||||
(нужен OPENAI_API_KEY)
|
||||
`);
|
||||
console.log("Демо завершено.\n");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
36
tools/api-format-to-openapi/examples/api-format.example.json
Normal file
36
tools/api-format-to-openapi/examples/api-format.example.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"apiFormatVersion": "1",
|
||||
"info": {
|
||||
"title": "TOiR Demo API",
|
||||
"version": "1.0.0",
|
||||
"description": "Абстрактный пример доменного описания API (не OpenAPI)."
|
||||
},
|
||||
"server": {
|
||||
"basePath": "/api"
|
||||
},
|
||||
"security": {
|
||||
"type": "bearer",
|
||||
"scheme": "JWT"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"name": "Equipment",
|
||||
"pathSegment": "equipment",
|
||||
"idParam": "id",
|
||||
"idType": "uuid",
|
||||
"fields": [
|
||||
{ "name": "id", "type": "uuid", "readOnly": true },
|
||||
{ "name": "inventoryNumber", "type": "string", "required": true },
|
||||
{ "name": "name", "type": "string", "required": true },
|
||||
{ "name": "status", "type": "enum", "enumValues": ["Active", "Repair", "Decommissioned"] },
|
||||
{ "name": "location", "type": "string" }
|
||||
],
|
||||
"operations": ["list", "get", "create", "update", "delete"],
|
||||
"listQuery": {
|
||||
"pagination": ["_start", "_end"],
|
||||
"sort": ["_sort", "_order"],
|
||||
"filters": ["q", "status"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
12
tools/api-format-to-openapi/package.json
Normal file
12
tools/api-format-to-openapi/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-format-to-openapi",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Конвертация доменного api-format в OpenAPI 3.0 (детерминированно или через LLM)",
|
||||
"scripts": {
|
||||
"convert": "node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json",
|
||||
"convert:llm": "node convert.mjs --mode llm --in examples/api-format.example.json --out ../../openapi.llm.json",
|
||||
"demo": "node demo-steps.mjs",
|
||||
"demo:pause": "node demo-steps.mjs --pause"
|
||||
}
|
||||
}
|
||||
30
tools/api-format-to-openapi/prompts/llm-system.md
Normal file
30
tools/api-format-to-openapi/prompts/llm-system.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Роль
|
||||
|
||||
Ты конвертер доменного описания API в спецификацию **OpenAPI 3.0.3** (JSON).
|
||||
|
||||
# Вход
|
||||
|
||||
Пользователь пришлёт один JSON-файл в произвольном «доменном» формате (api-format). В нём могут быть сущности, поля, типы, пути, операции, фильтры, авторизация.
|
||||
|
||||
# Выход
|
||||
|
||||
- Верни **только** валидный JSON объекта OpenAPI 3.0.3.
|
||||
- Без markdown, без комментариев, без текста до или после JSON.
|
||||
- Используй `openapi: "3.0.3"`.
|
||||
- Опиши `info`, `servers`, при необходимости `tags`.
|
||||
- Для каждой сущности/ресурса создай `components.schemas` и `paths` с типичными REST-операциями, если они указаны.
|
||||
- Типы полей маппь так:
|
||||
- `string` → `type: string`
|
||||
- `uuid` → `type: string`, `format: uuid`
|
||||
- `int` / `integer` → `type: integer`
|
||||
- `number` / `float` → `type: number`
|
||||
- `boolean` → `type: boolean`
|
||||
- `date` / `datetime` → `type: string`, `format: date` или `date-time`
|
||||
- `enum` + список значений → `type: string`, `enum: [...]`
|
||||
- Для списков с пагинацией добавь query-параметры из входа (`_start`, `_end`, `_sort`, `_order`, фильтры).
|
||||
- Для `401/403/404/500` добавь минимальные `responses` с `description`.
|
||||
- Если во входе указана Bearer/JWT — добавь `components.securitySchemes` и `security` на путях или глобально.
|
||||
|
||||
# Если чего-то не хватает
|
||||
|
||||
Делай разумные допущения и кратко отражай их в `info.description` одним предложением.
|
||||
357
tools/dsl-summary.mjs
Normal file
357
tools/dsl-summary.mjs
Normal file
@@ -0,0 +1,357 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function stripInlineComment(line) {
|
||||
let inString = false;
|
||||
let result = '';
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const current = line[index];
|
||||
const next = line[index + 1];
|
||||
|
||||
if (current === '"' && line[index - 1] !== '\\') {
|
||||
inString = !inString;
|
||||
result += current;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inString && current === '/' && next === '/') {
|
||||
break;
|
||||
}
|
||||
|
||||
result += current;
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
function parseDefaultValue(rawValue) {
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
return Number(trimmed);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function getDslFiles(rootDir) {
|
||||
const domainDir = path.join(rootDir, 'domain');
|
||||
|
||||
return readdirSync(domainDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.dsl'))
|
||||
.map((entry) => path.join(domainDir, entry.name))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function getOverrideFiles(rootDir) {
|
||||
const overridesDir = path.join(rootDir, 'overrides');
|
||||
const files = ['api-overrides.dsl', 'ui-overrides.dsl']
|
||||
.map((name) => path.join(overridesDir, name))
|
||||
.filter((filePath) => {
|
||||
try {
|
||||
return readdirSync(path.dirname(filePath)).includes(path.basename(filePath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function parseDslFiles(rootDir) {
|
||||
const dslFiles = getDslFiles(rootDir);
|
||||
const enums = [];
|
||||
const entities = [];
|
||||
const stack = [];
|
||||
|
||||
for (const filePath of dslFiles) {
|
||||
const contents = readFileSync(filePath, 'utf8');
|
||||
const lines = contents.split(/\r?\n/);
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = stripInlineComment(rawLine);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const top = stack.at(-1);
|
||||
|
||||
const enumMatch = line.match(/^enum\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||
if (!top && enumMatch) {
|
||||
const enumDefinition = { name: enumMatch[1], values: [] };
|
||||
enums.push(enumDefinition);
|
||||
stack.push({ type: 'enum', ref: enumDefinition });
|
||||
continue;
|
||||
}
|
||||
|
||||
const entityMatch = line.match(/^entity\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||
if (!top && entityMatch) {
|
||||
const entityDefinition = {
|
||||
name: entityMatch[1],
|
||||
primaryKey: null,
|
||||
fields: [],
|
||||
foreignKeys: [],
|
||||
};
|
||||
entities.push(entityDefinition);
|
||||
stack.push({ type: 'entity', ref: entityDefinition });
|
||||
continue;
|
||||
}
|
||||
|
||||
const valueMatch = line.match(/^value\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||
if (top?.type === 'enum' && valueMatch) {
|
||||
const enumValue = { name: valueMatch[1] };
|
||||
top.ref.values.push(enumValue);
|
||||
stack.push({ type: 'enumValue', ref: enumValue });
|
||||
continue;
|
||||
}
|
||||
|
||||
const attributeMatch = line.match(/^attribute\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||
if (top?.type === 'entity' && attributeMatch) {
|
||||
const field = {
|
||||
name: attributeMatch[1],
|
||||
type: null,
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
};
|
||||
top.ref.fields.push(field);
|
||||
stack.push({ type: 'field', ref: field, entity: top.ref });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (top?.type === 'field' && /^key\s+foreign\s*\{$/.test(line)) {
|
||||
stack.push({ type: 'foreignKey', ref: top.ref, entity: top.entity });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (top?.type === 'enumValue') {
|
||||
const labelMatch = line.match(/^label\s+"(.*)"\s*;$/);
|
||||
if (labelMatch) {
|
||||
top.ref.label = labelMatch[1];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (top?.type === 'field') {
|
||||
const typeMatch = line.match(/^type\s+([A-Za-z][A-Za-z0-9_]*)\s*;$/);
|
||||
if (typeMatch) {
|
||||
top.ref.type = typeMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^key\s+primary\s*;$/.test(line)) {
|
||||
top.ref.primary = true;
|
||||
top.entity.primaryKey = top.ref.name;
|
||||
continue;
|
||||
}
|
||||
|
||||
const defaultMatch = line.match(/^default\s+(.+)\s*;$/);
|
||||
if (defaultMatch) {
|
||||
top.ref.default = parseDefaultValue(defaultMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^is\s+required\s*;$/.test(line)) {
|
||||
top.ref.required = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^is\s+unique\s*;$/.test(line)) {
|
||||
top.ref.unique = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (top?.type === 'foreignKey') {
|
||||
const relatesMatch = line.match(
|
||||
/^relates\s+([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\s*;$/,
|
||||
);
|
||||
if (relatesMatch) {
|
||||
const foreignKey = {
|
||||
field: top.ref.name,
|
||||
references: {
|
||||
entity: relatesMatch[1],
|
||||
field: relatesMatch[2],
|
||||
},
|
||||
};
|
||||
|
||||
top.ref.foreignKey = foreignKey.references;
|
||||
top.entity.foreignKeys.push(foreignKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^}\s*;?$/.test(line)) {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dslFiles, enums, entities };
|
||||
}
|
||||
|
||||
function assertNoDomainRedefinition(line, filePath) {
|
||||
const blocked = [/^\s*entity\s+/i, /^\s*enum\s+/i, /^\s*attribute\s+/i, /^\s*key\s+primary/i, /^\s*key\s+foreign/i];
|
||||
for (const pattern of blocked) {
|
||||
if (pattern.test(line)) {
|
||||
throw new Error(
|
||||
`Override file ${path.basename(filePath)} attempts to redefine domain structure: ${line.trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOverrides(rootDir, entities) {
|
||||
const overrideFiles = getOverrideFiles(rootDir);
|
||||
const entityNames = new Set(entities.map((entity) => entity.name));
|
||||
const fieldNames = new Set(
|
||||
entities.flatMap((entity) => entity.fields.map((field) => `${entity.name}.${field.name}`)),
|
||||
);
|
||||
|
||||
const api = { resources: {} };
|
||||
const ui = { fields: {} };
|
||||
|
||||
for (const filePath of overrideFiles) {
|
||||
const isApi = filePath.endsWith('api-overrides.dsl');
|
||||
const isUi = filePath.endsWith('ui-overrides.dsl');
|
||||
const lines = readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = stripInlineComment(rawLine);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assertNoDomainRedefinition(line, filePath);
|
||||
|
||||
if (isApi) {
|
||||
const match = line.match(/^resource\s+([A-Za-z][A-Za-z0-9_]*)\s+path\s+"([^"]+)"\s*;$/);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Unsupported API override syntax in ${path.basename(filePath)}: ${line}`,
|
||||
);
|
||||
}
|
||||
const [, entityName, resourcePath] = match;
|
||||
if (!entityNames.has(entityName)) {
|
||||
throw new Error(`API override references unknown entity ${entityName}`);
|
||||
}
|
||||
api.resources[entityName] = { path: resourcePath };
|
||||
}
|
||||
|
||||
if (isUi) {
|
||||
const match = line.match(
|
||||
/^field\s+([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\s+widget\s+"([^"]+)"\s*;$/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Unsupported UI override syntax in ${path.basename(filePath)}: ${line}`,
|
||||
);
|
||||
}
|
||||
const [, entityName, fieldName, widget] = match;
|
||||
const key = `${entityName}.${fieldName}`;
|
||||
if (!fieldNames.has(key)) {
|
||||
throw new Error(`UI override references unknown field ${key}`);
|
||||
}
|
||||
ui.fields[key] = { widget };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { api, ui, sourceFiles: overrideFiles.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')) };
|
||||
}
|
||||
|
||||
export function buildDomainSummary(rootDir) {
|
||||
const { dslFiles, enums, entities } = parseDslFiles(rootDir);
|
||||
parseOverrides(rootDir, entities);
|
||||
const entityByName = new Map(entities.map((entity) => [entity.name, entity]));
|
||||
const enumByName = new Map(enums.map((entry) => [entry.name, entry]));
|
||||
|
||||
for (const entity of entities) {
|
||||
if (!entity.primaryKey) {
|
||||
throw new Error(`Entity ${entity.name} is missing a primary key`);
|
||||
}
|
||||
|
||||
const primaryKeyField = entity.fields.find((field) => field.name === entity.primaryKey);
|
||||
if (!primaryKeyField) {
|
||||
throw new Error(
|
||||
`Entity ${entity.name} declares primary key ${entity.primaryKey}, but the field is missing`,
|
||||
);
|
||||
}
|
||||
|
||||
if (entity.fields.some((field) => !field.type)) {
|
||||
throw new Error(`Entity ${entity.name} has attributes with missing type`);
|
||||
}
|
||||
|
||||
for (const foreignKey of entity.foreignKeys) {
|
||||
const targetEntity = entityByName.get(foreignKey.references.entity);
|
||||
if (!targetEntity) {
|
||||
throw new Error(
|
||||
`Foreign key ${entity.name}.${foreignKey.field} references missing entity ${foreignKey.references.entity}`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetField = targetEntity.fields.find(
|
||||
(field) => field.name === foreignKey.references.field,
|
||||
);
|
||||
if (!targetField) {
|
||||
throw new Error(
|
||||
`Foreign key ${entity.name}.${foreignKey.field} references missing field ${foreignKey.references.entity}.${foreignKey.references.field}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceField = entity.fields.find((field) => field.name === foreignKey.field);
|
||||
if (sourceField && sourceField.type !== targetField.type) {
|
||||
throw new Error(
|
||||
`Foreign key ${entity.name}.${foreignKey.field} type ${sourceField.type} does not match ${foreignKey.references.entity}.${foreignKey.references.field} type ${targetField.type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldNames = new Set();
|
||||
for (const field of entity.fields) {
|
||||
if (fieldNames.has(field.name)) {
|
||||
throw new Error(`Entity ${entity.name} has duplicate field ${field.name}`);
|
||||
}
|
||||
fieldNames.add(field.name);
|
||||
}
|
||||
}
|
||||
|
||||
const entityNames = new Set();
|
||||
for (const entity of entities) {
|
||||
if (entityNames.has(entity.name)) {
|
||||
throw new Error(`Duplicate entity definition: ${entity.name}`);
|
||||
}
|
||||
entityNames.add(entity.name);
|
||||
}
|
||||
|
||||
for (const [enumName, enumDefinition] of enumByName.entries()) {
|
||||
const valueNames = new Set();
|
||||
for (const value of enumDefinition.values) {
|
||||
if (valueNames.has(value.name)) {
|
||||
throw new Error(`Enum ${enumName} has duplicate value ${value.name}`);
|
||||
}
|
||||
valueNames.add(value.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceFiles: dslFiles.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')),
|
||||
entities: entities.map((entity) => ({
|
||||
name: entity.name,
|
||||
primaryKey: entity.primaryKey,
|
||||
fields: entity.fields.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
unique: field.unique,
|
||||
default: field.default,
|
||||
})),
|
||||
foreignKeys: entity.foreignKeys,
|
||||
})),
|
||||
enums,
|
||||
};
|
||||
}
|
||||
13
tools/generate-domain-summary.mjs
Normal file
13
tools/generate-domain-summary.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildDomainSummary } from './dsl-summary.mjs';
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const outputPath = path.join(rootDir, 'domain-summary.json');
|
||||
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const summary = buildDomainSummary(rootDir);
|
||||
writeFileSync(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
|
||||
console.log(`Generated ${path.relative(rootDir, outputPath)}`);
|
||||
513
tools/validate-generation.mjs
Normal file
513
tools/validate-generation.mjs
Normal file
@@ -0,0 +1,513 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
buildDomainSummary,
|
||||
getDslFiles,
|
||||
parseDslFiles,
|
||||
parseOverrides,
|
||||
} from './dsl-summary.mjs';
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const artifactsOnly = args.has('--artifacts-only');
|
||||
const runRuntime = args.has('--run-runtime');
|
||||
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
|
||||
function assertCondition(condition, message) {
|
||||
if (!condition) {
|
||||
failures.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
function warn(message) {
|
||||
warnings.push(message);
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return readFileSync(path.join(rootDir, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function readIfExists(relativePath) {
|
||||
const filePath = path.join(rootDir, relativePath);
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function requireFile(relativePath) {
|
||||
assertCondition(existsSync(path.join(rootDir, relativePath)), `Missing file: ${relativePath}`);
|
||||
}
|
||||
|
||||
function requireFiles(relativePaths) {
|
||||
relativePaths.forEach(requireFile);
|
||||
}
|
||||
|
||||
function requireContent(relativePath, pattern, message) {
|
||||
const contents = readIfExists(relativePath);
|
||||
assertCondition(Boolean(contents), `Missing file: ${relativePath}`);
|
||||
if (!contents) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertCondition(pattern.test(contents), `${message} (${relativePath})`);
|
||||
}
|
||||
|
||||
function parseJson(relativePath) {
|
||||
const raw = readIfExists(relativePath);
|
||||
if (!raw) {
|
||||
failures.push(`Missing file: ${relativePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
failures.push(`Invalid JSON in ${relativePath}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function kebabCase(value) {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||
.replace(/\s+/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function getRealmArtifactPath() {
|
||||
const rootFiles = readdirSync(rootDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name);
|
||||
|
||||
const realmArtifacts = rootFiles.filter((entry) => /-realm\.json$/i.test(entry));
|
||||
assertCondition(realmArtifacts.length === 1, 'Expected exactly one root-level *-realm.json artifact');
|
||||
|
||||
return realmArtifacts[0] ?? null;
|
||||
}
|
||||
|
||||
function getWorkspaceInfo() {
|
||||
return {
|
||||
server: {
|
||||
dir: path.join(rootDir, 'server'),
|
||||
packagePath: 'server/package.json',
|
||||
scaffoldFiles: [
|
||||
'server/package.json',
|
||||
'server/tsconfig.json',
|
||||
'server/tsconfig.build.json',
|
||||
'server/nest-cli.json',
|
||||
'server/src/main.ts',
|
||||
'server/src/app.module.ts',
|
||||
],
|
||||
},
|
||||
client: {
|
||||
dir: path.join(rootDir, 'client'),
|
||||
packagePath: 'client/package.json',
|
||||
scaffoldFiles: [
|
||||
'client/package.json',
|
||||
'client/index.html',
|
||||
'client/tsconfig.json',
|
||||
'client/tsconfig.node.json',
|
||||
'client/vite.config.ts',
|
||||
'client/src/main.tsx',
|
||||
'client/src/vite-env.d.ts',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateBuildChecks() {
|
||||
requireFiles([
|
||||
'README.md',
|
||||
'package.json',
|
||||
'domain/dsl-spec.md',
|
||||
'domain-summary.json',
|
||||
'server/prisma/schema.prisma',
|
||||
'server/.env.example',
|
||||
'client/.env.example',
|
||||
'prompts/general-prompt.md',
|
||||
'prompts/auth-rules.md',
|
||||
'prompts/backend-rules.md',
|
||||
'prompts/frontend-rules.md',
|
||||
'prompts/runtime-rules.md',
|
||||
'prompts/validation-rules.md',
|
||||
]);
|
||||
|
||||
const dslFiles = getDslFiles(rootDir).map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/'));
|
||||
assertCondition(dslFiles.length > 0, 'Expected at least one domain/*.dsl file');
|
||||
|
||||
const actualSummaryRaw = readIfExists('domain-summary.json');
|
||||
if (actualSummaryRaw) {
|
||||
const expectedSummary = JSON.stringify(buildDomainSummary(rootDir), null, 2);
|
||||
assertCondition(
|
||||
actualSummaryRaw.trim() === expectedSummary,
|
||||
'domain-summary.json is out of date. Run `npm run generate:domain-summary`.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { entities } = parseDslFiles(rootDir);
|
||||
parseOverrides(rootDir, entities);
|
||||
} catch (error) {
|
||||
failures.push(`Override validation failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const { server, client } = getWorkspaceInfo();
|
||||
requireFiles(server.scaffoldFiles);
|
||||
requireFiles(client.scaffoldFiles);
|
||||
|
||||
const serverPackage = parseJson(server.packagePath);
|
||||
if (serverPackage) {
|
||||
assertCondition(serverPackage.scripts?.build === 'nest build', 'server/package.json must keep `build = nest build`');
|
||||
assertCondition(serverPackage.scripts?.start === 'nest start', 'server/package.json must keep `start = nest start`');
|
||||
assertCondition(serverPackage.scripts?.['start:dev'] === 'nest start --watch', 'server/package.json must keep `start:dev = nest start --watch`');
|
||||
assertCondition(Boolean(serverPackage.scripts?.['start:prod']), 'server/package.json must define a start:prod script');
|
||||
assertCondition(Boolean(serverPackage.dependencies?.['@nestjs/core']), 'server/package.json must keep Nest runtime dependencies');
|
||||
}
|
||||
|
||||
const clientPackage = parseJson(client.packagePath);
|
||||
if (clientPackage) {
|
||||
assertCondition(clientPackage.scripts?.dev === 'vite', 'client/package.json must keep `dev = vite`');
|
||||
assertCondition(clientPackage.scripts?.build === 'vite build', 'client/package.json must keep `build = vite build`');
|
||||
assertCondition(clientPackage.scripts?.preview === 'vite preview', 'client/package.json must keep `preview = vite preview`');
|
||||
assertCondition(Boolean(clientPackage.devDependencies?.vite), 'client/package.json must keep Vite as a dev dependency');
|
||||
assertCondition(Boolean(clientPackage.devDependencies?.['@vitejs/plugin-react']), 'client/package.json must keep @vitejs/plugin-react as a dev dependency');
|
||||
}
|
||||
}
|
||||
|
||||
function validateAuthChecks() {
|
||||
requireFiles([
|
||||
'client/src/auth/keycloak.ts',
|
||||
'client/src/auth/authProvider.ts',
|
||||
'client/src/dataProvider.ts',
|
||||
'client/src/config/env.ts',
|
||||
'client/src/main.tsx',
|
||||
'client/src/App.tsx',
|
||||
'server/src/auth/auth.module.ts',
|
||||
'server/src/auth/auth.service.ts',
|
||||
'server/src/auth/guards/jwt-auth.guard.ts',
|
||||
'server/src/auth/guards/roles.guard.ts',
|
||||
'server/src/auth/decorators/public.decorator.ts',
|
||||
'server/src/auth/decorators/roles.decorator.ts',
|
||||
]);
|
||||
|
||||
requireContent(
|
||||
'client/src/auth/keycloak.ts',
|
||||
/onLoad:\s*'login-required'/,
|
||||
'Frontend auth must initialize Keycloak with login-required',
|
||||
);
|
||||
requireContent(
|
||||
'client/src/auth/keycloak.ts',
|
||||
/pkceMethod:\s*'S256'/,
|
||||
'Frontend auth must use PKCE S256',
|
||||
);
|
||||
requireContent(
|
||||
'client/src/auth/keycloak.ts',
|
||||
/updateToken\(/,
|
||||
'Frontend auth must refresh access tokens through the Keycloak adapter',
|
||||
);
|
||||
|
||||
const keycloakSource = readIfExists('client/src/auth/keycloak.ts') ?? '';
|
||||
assertCondition(
|
||||
!/loadUserProfile\(/.test(keycloakSource),
|
||||
'Frontend auth must not call keycloak.loadUserProfile()',
|
||||
);
|
||||
|
||||
requireContent(
|
||||
'client/src/dataProvider.ts',
|
||||
/Authorization', `Bearer \$\{token\}`/,
|
||||
'dataProvider must attach bearer tokens in the shared request seam',
|
||||
);
|
||||
|
||||
const authProvider = readIfExists('client/src/auth/authProvider.ts') ?? '';
|
||||
assertCondition(
|
||||
/status === 401/.test(authProvider) && /status === 403/.test(authProvider),
|
||||
'authProvider must distinguish 401 and 403 semantics',
|
||||
);
|
||||
|
||||
const authService = readIfExists('server/src/auth/auth.service.ts') ?? '';
|
||||
assertCondition(
|
||||
/jwtVerify/.test(authService) && /KEYCLOAK_ISSUER_URL/.test(authService) && /KEYCLOAK_AUDIENCE/.test(authService),
|
||||
'Backend auth must verify JWTs with issuer and audience',
|
||||
);
|
||||
assertCondition(/realm_access/.test(authService), 'Backend auth must extract roles from realm_access.roles');
|
||||
assertCondition(/KEYCLOAK_JWKS_URL/.test(authService), 'Backend auth must support explicit KEYCLOAK_JWKS_URL');
|
||||
assertCondition(/\.well-known\/openid-configuration/.test(authService), 'Backend auth must try OIDC discovery before fallback certs');
|
||||
assertCondition(/protocol\/openid-connect\/certs/.test(authService), 'Backend auth must keep Keycloak certs fallback resolution');
|
||||
}
|
||||
|
||||
function validateNaturalKeyChecks() {
|
||||
const summary = parseJson('domain-summary.json');
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const naturalKeyEntities = summary.entities.filter((entity) => entity.primaryKey !== 'id');
|
||||
|
||||
for (const entity of naturalKeyEntities) {
|
||||
const moduleName = kebabCase(entity.name);
|
||||
const controllerPath = `server/src/modules/${moduleName}/${moduleName}.controller.ts`;
|
||||
const servicePath = `server/src/modules/${moduleName}/${moduleName}.service.ts`;
|
||||
const controller = readIfExists(controllerPath) ?? '';
|
||||
const service = readIfExists(servicePath) ?? '';
|
||||
|
||||
assertCondition(Boolean(controller), `Missing file: ${controllerPath}`);
|
||||
assertCondition(Boolean(service), `Missing file: ${servicePath}`);
|
||||
if (!controller || !service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assertCondition(
|
||||
controller.includes(`@Get(':${entity.primaryKey}')`) &&
|
||||
controller.includes(`@Patch(':${entity.primaryKey}')`) &&
|
||||
controller.includes(`@Delete(':${entity.primaryKey}')`),
|
||||
`${entity.name} controller must use :${entity.primaryKey} route params`,
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
service.includes(`id: item.${entity.primaryKey}`) || service.includes(`id: record.${entity.primaryKey}`),
|
||||
`${entity.name} service must map the natural key to React Admin id`,
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
service.includes(`const { id, ${entity.primaryKey}: _pk`) || service.includes(`const { id: _pk, ${entity.primaryKey}`),
|
||||
`${entity.name} update path must sanitize id and primary key from Prisma update data`,
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
/sortField\s*===\s*'id'/.test(service) || /sortField\s*===\s*"id"/.test(service),
|
||||
`${entity.name} natural-key sort must map React Admin id sorting back to the real primary key`,
|
||||
);
|
||||
assertCondition(
|
||||
!service.includes("query._sort || 'id'"),
|
||||
`${entity.name} natural-key sort must not fall back to the physical id field`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRealmChecks() {
|
||||
const realmArtifactName = getRealmArtifactPath();
|
||||
if (!realmArtifactName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = parseJson(realmArtifactName);
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
const realmRoles = artifact.roles?.realm?.map((role) => role.name) ?? [];
|
||||
const frontendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-frontend'));
|
||||
const backendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-backend'));
|
||||
const audienceScope = artifact.clientScopes?.find((scope) => scope.name === 'api-audience');
|
||||
|
||||
['admin', 'editor', 'viewer'].forEach((role) => {
|
||||
assertCondition(realmRoles.includes(role), `Realm artifact must define realm role ${role}`);
|
||||
});
|
||||
|
||||
assertCondition(Boolean(frontendClient), 'Realm artifact must define the frontend SPA client');
|
||||
assertCondition(Boolean(backendClient), 'Realm artifact must define the backend resource client');
|
||||
assertCondition(Boolean(audienceScope), 'Realm artifact must define the api-audience client scope');
|
||||
|
||||
if (frontendClient) {
|
||||
assertCondition(frontendClient.publicClient === true, 'Frontend realm client must be public');
|
||||
assertCondition(
|
||||
frontendClient.standardFlowEnabled === true &&
|
||||
frontendClient.implicitFlowEnabled === false &&
|
||||
frontendClient.directAccessGrantsEnabled === false,
|
||||
'Frontend realm client must use standard flow only',
|
||||
);
|
||||
assertCondition(
|
||||
frontendClient.attributes?.['pkce.code.challenge.method'] === 'S256',
|
||||
'Frontend realm client must enforce PKCE S256',
|
||||
);
|
||||
|
||||
const mapperNames = new Set((frontendClient.protocolMappers ?? []).map((mapper) => mapper.name));
|
||||
['sub', 'preferred_username', 'email', 'name', 'realm roles'].forEach((mapperName) => {
|
||||
assertCondition(mapperNames.has(mapperName), `Frontend realm client must include protocol mapper ${mapperName}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (backendClient && audienceScope) {
|
||||
const audienceMapper = (audienceScope.protocolMappers ?? []).find(
|
||||
(mapper) => mapper.protocolMapper === 'oidc-audience-mapper',
|
||||
);
|
||||
assertCondition(Boolean(audienceMapper), 'api-audience scope must include an audience mapper');
|
||||
assertCondition(
|
||||
audienceMapper?.config?.['included.client.audience'] === backendClient.clientId,
|
||||
'api-audience scope must deliver the backend audience/client id',
|
||||
);
|
||||
assertCondition(backendClient.bearerOnly === true, 'Backend realm client must be bearer-only');
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeContractChecks() {
|
||||
requireFile('docker-compose.yml');
|
||||
const compose = readIfExists('docker-compose.yml') ?? '';
|
||||
assertCondition(/image:\s*postgres:16/.test(compose), 'docker-compose must provision postgres:16');
|
||||
assertCondition(!/keycloak/i.test(compose), 'docker-compose must remain PostgreSQL-only');
|
||||
|
||||
const serverEnvExample = readIfExists('server/.env.example') ?? '';
|
||||
assertCondition(
|
||||
/KEYCLOAK_ISSUER_URL="https:\/\/sso\.greact\.ru\/realms\/toir"/.test(serverEnvExample),
|
||||
'server/.env.example must keep the working Keycloak issuer example',
|
||||
);
|
||||
assertCondition(
|
||||
/KEYCLOAK_AUDIENCE="?toir-backend"?/.test(serverEnvExample),
|
||||
'server/.env.example must keep the working backend audience example',
|
||||
);
|
||||
assertCondition(
|
||||
/CORS_ALLOWED_ORIGINS="http:\/\/localhost:5173,https:\/\/toir-frontend\.greact\.ru"/.test(serverEnvExample),
|
||||
'server/.env.example must keep the working CORS example with the production frontend domain',
|
||||
);
|
||||
assertCondition(
|
||||
!/KEYCLOAK_ISSUER_URL=http:\/\/localhost:8080\/realms\/toir/.test(serverEnvExample),
|
||||
'server/.env.example must not regress to localhost Keycloak as the baseline issuer example',
|
||||
);
|
||||
|
||||
const clientEnvExample = readIfExists('client/.env.example') ?? '';
|
||||
assertCondition(
|
||||
/VITE_KEYCLOAK_URL=https:\/\/sso\.greact\.ru/.test(clientEnvExample),
|
||||
'client/.env.example must keep the working domain-based Keycloak URL example',
|
||||
);
|
||||
assertCondition(
|
||||
/VITE_KEYCLOAK_REALM=toir/.test(clientEnvExample) && /VITE_KEYCLOAK_CLIENT_ID=toir-frontend/.test(clientEnvExample),
|
||||
'client/.env.example must keep the working realm and frontend client examples',
|
||||
);
|
||||
assertCondition(
|
||||
!/VITE_KEYCLOAK_URL=http:\/\/localhost:8080/.test(clientEnvExample),
|
||||
'client/.env.example must not regress to localhost Keycloak as the baseline example',
|
||||
);
|
||||
|
||||
const healthController = readIfExists('server/src/health/health.controller.ts') ?? '';
|
||||
assertCondition(Boolean(healthController), 'Missing file: server/src/health/health.controller.ts');
|
||||
if (healthController) {
|
||||
assertCondition(
|
||||
/@Public\(\)/.test(healthController) && /@Controller\('health'\)/.test(healthController),
|
||||
'/health must stay public',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command, commandArgs, workdir, failureLabel) {
|
||||
const runtimeEnv = { ...process.env };
|
||||
const envExamplePath = path.join(workdir, '.env.example');
|
||||
if (existsSync(envExamplePath)) {
|
||||
const envExample = readFileSync(envExamplePath, 'utf8');
|
||||
for (const line of envExample.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separator = trimmed.indexOf('=');
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
const value = trimmed.slice(separator + 1).trim().replace(/^"|"$/g, '');
|
||||
if (!(key in runtimeEnv)) {
|
||||
runtimeEnv[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commandLine = [command, ...commandArgs].join(' ');
|
||||
const result = spawnSync(commandLine, {
|
||||
cwd: workdir,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
failures.push(`${failureLabel}: ${commandLine}\n${result.error.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim();
|
||||
const stdout = result.stdout?.trim();
|
||||
failures.push(
|
||||
`${failureLabel}: ${commandLine}${stderr ? `\n${stderr}` : stdout ? `\n${stdout}` : ''}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeValidateWorkspaceBuild(relativeDir) {
|
||||
const workspaceDir = path.join(rootDir, relativeDir);
|
||||
if (!existsSync(path.join(workspaceDir, 'package.json'))) {
|
||||
failures.push(`Missing file: ${relativeDir}/package.json`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(path.join(workspaceDir, 'node_modules'))) {
|
||||
warn(`Skipped build verification for ${relativeDir}: install dependencies in ${relativeDir}/ to validate workspace buildability.`);
|
||||
return;
|
||||
}
|
||||
|
||||
runCommand('npm', ['run', 'build'], workspaceDir, `Build verification failed in ${relativeDir}`);
|
||||
}
|
||||
|
||||
function validateBuildExecutionChecks() {
|
||||
maybeValidateWorkspaceBuild('server');
|
||||
maybeValidateWorkspaceBuild('client');
|
||||
}
|
||||
|
||||
function validateRuntimeExecutionChecks() {
|
||||
const serverDir = path.join(rootDir, 'server');
|
||||
if (!existsSync(path.join(serverDir, 'node_modules'))) {
|
||||
failures.push(
|
||||
'Runtime validation requires installed backend dependencies. Run `npm install` in server/ before `npm run validate:generation:runtime`.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runCommand('npx', ['prisma', 'generate'], serverDir, 'Prisma generate failed');
|
||||
runCommand(
|
||||
'npx',
|
||||
['prisma', 'migrate', 'dev', '--name', 'baseline', '--skip-generate'],
|
||||
serverDir,
|
||||
'Prisma migrate failed',
|
||||
);
|
||||
runCommand('npx', ['prisma', 'db', 'seed'], serverDir, 'Prisma seed failed');
|
||||
}
|
||||
|
||||
validateBuildChecks();
|
||||
validateAuthChecks();
|
||||
validateNaturalKeyChecks();
|
||||
validateRealmChecks();
|
||||
validateRuntimeContractChecks();
|
||||
|
||||
if (!artifactsOnly) {
|
||||
validateBuildExecutionChecks();
|
||||
}
|
||||
|
||||
if (!artifactsOnly && runRuntime) {
|
||||
validateRuntimeExecutionChecks();
|
||||
} else if (!artifactsOnly) {
|
||||
warn('Runtime command execution skipped. Use --run-runtime after installing dependencies and starting the local database.');
|
||||
}
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.warn(`WARN: ${warning}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('Generation validation failed:');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Generation validation passed.');
|
||||
Reference in New Issue
Block a user