Add AID export: OpenAPI from api-format and app generator bundle

Nest: POST /aid/export/openapi, POST /aid/export/app. Tools: api-format-to-openapi CLI. Generator: --print-bundle-json. Optional env: AID_EXPORT_API_KEY, AID_GENERATOR_ALLOW_APPLY.
This commit is contained in:
time_
2026-03-19 16:49:27 +03:00
parent 5b8d8a85c4
commit 2bc1aea56a
14 changed files with 895 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Generated OpenAPI (local runs; commit only if you want to publish the spec)
openapi.generated.json
openapi.llm.json

View File

@@ -95,6 +95,19 @@ Open the Vite URL in a browser; the React Admin app should load and use the back
--- ---
## Optional: api-format → OpenAPI 3.0
Experimental tooling lives in `tools/api-format-to-openapi/`: a sample **domain api-format** JSON, a **prompt** for LLM conversion, and `convert.mjs` (**deterministic** mapping for `apiFormatVersion: "1"` or **`--mode llm`** via OpenAI). See `tools/api-format-to-openapi/README.md`.
```bash
cd tools/api-format-to-openapi
node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
```
**AID / HTTP:** Nest exposes `POST /aid/export/openapi` and `POST /aid/export/app` (see `server/src/aid-export/README.md`). CLI bundle: `node generation/generate.mjs --print-bundle-json --dsl <file.dsl>`.
---
# Summary # Summary
| Step | Command / location | | Step | Command / location |

View File

@@ -519,9 +519,41 @@ function ensureClientApp(apply, frontendResources) {
}); });
} }
/** Собирает файлы как при --apply, без записи. Учитывает текущие app.module.ts и App.tsx на диске. */
function collectGeneratedBundle(parsed) {
const files = {};
const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma');
const pr = ensurePrismaSchema(parsed, prismaPath, false);
files['server/prisma/schema.prisma'] = pr.content;
const backendModules = [];
const frontendResources = [];
for (const [entityName, ent] of Object.entries(parsed.entities)) {
const pk = ent.primaryKey;
const resource = pluralize(toKebab(entityName));
const be = renderBackendModule(entityName, ent, resource, pk);
const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums);
backendModules.push(be);
frontendResources.push(fe);
Object.assign(files, be.files, fe.files);
}
const appMod = ensureAppModule(false, backendModules);
files['server/src/app.module.ts'] = appMod.content;
const clientApp = ensureClientApp(false, frontendResources);
files['client/src/App.tsx'] = clientApp.content;
return {
entityCount: Object.keys(parsed.entities).length,
enumCount: Object.keys(parsed.enums).length,
files,
};
}
function main() { function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const apply = args.includes('--apply'); const apply = args.includes('--apply');
const printBundleJson = args.includes('--print-bundle-json');
const dslArgIdx = args.indexOf('--dsl'); const dslArgIdx = args.indexOf('--dsl');
const dslPath = dslArgIdx >= 0 ? args[dslArgIdx + 1] : 'examples/TOiR.domain.dsl'; const dslPath = dslArgIdx >= 0 ? args[dslArgIdx + 1] : 'examples/TOiR.domain.dsl';
@@ -529,6 +561,12 @@ function main() {
const dslText = readFile(absDsl); const dslText = readFile(absDsl);
const parsed = parseDomainDSL(dslText); const parsed = parseDomainDSL(dslText);
if (printBundleJson) {
const bundle = collectGeneratedBundle(parsed);
process.stdout.write(JSON.stringify(bundle));
return;
}
// Prisma schema // Prisma schema
const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma'); const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma');
ensurePrismaSchema(parsed, prismaPath, apply); ensurePrismaSchema(parsed, prismaPath, apply);

View File

@@ -19,6 +19,7 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"generate:from-dsl": "node ../generation/generate.mjs --apply --dsl examples/TOiR.domain.dsl", "generate:from-dsl": "node ../generation/generate.mjs --apply --dsl examples/TOiR.domain.dsl",
"generate:bundle-json": "node ../generation/generate.mjs --print-bundle-json --dsl examples/TOiR.domain.dsl",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"prisma": { "prisma": {

View File

@@ -0,0 +1,85 @@
# AID export: OpenAPI + генератор приложения
HTTP-эндпоинты для интеграции с **AID** как **сервис-экспортёры**.
---
## 1. `api-format` → OpenAPI 3.0
`POST /aid/export/openapi`
Внутри: `tools/api-format-to-openapi/convert.mjs` (режимы `deterministic` и `llm`).
**Тело:**
```json
{
"apiFormat": { "apiFormatVersion": "1", "...": "..." },
"mode": "deterministic"
}
```
**Ответ:** `{ "openapi": { ... } }`
---
## 2. DSL → сгенерированное приложение (бандл или запись на диск)
`POST /aid/export/app`
Внутри: `generation/generate.mjs`.
**Тело:**
```json
{
"dsl": "domain TOiR {\n ...\n}\n",
"apply": false
}
```
- **`apply` по умолчанию `false` (рекомендуется для AID):** ответ содержит `files` — карта **путь от корня репозитория → текст файла** (Prisma, Nest-модули, React Admin и обновлённые `app.module.ts` / `App.tsx`). **На диск ничего не пишется.**
- **`apply: true`:** выполняется тот же процесс, что и `npm run generate:from-dsl` с `--apply`**перезапись файлов в рабочей копии** на машине, где запущен Nest. Включено только если в окружении задано **`AID_GENERATOR_ALLOW_APPLY=1`** (или `true`). Иначе `403 Forbidden`.
**Ответ (бандл):**
```json
{
"applied": false,
"entityCount": 3,
"enumCount": 3,
"files": {
"server/prisma/schema.prisma": "...",
"server/src/modules/equipment/equipment.controller.ts": "...",
"client/src/App.tsx": "..."
}
}
```
**Ответ (apply):**
```json
{
"applied": true,
"message": "Generated 3 entities from ..."
}
```
### CLI-аналог бандла (без Nest)
Из **корня репозитория**:
```bash
node generation/generate.mjs --print-bundle-json --dsl examples/TOiR.domain.dsl > bundle.json
```
---
## Безопасность
- Если в `.env` задан **`AID_EXPORT_API_KEY`**, для **обоих** эндпоинтов нужен заголовок **`X-AID-Export-Key`** с тем же значением.
- Не включайте **`AID_GENERATOR_ALLOW_APPLY`** на публичных инстансах без понимания рисков.
## Требования
- Запуск Nest с **cwd = `server/`** относительно корня репо, чтобы находились `../generation/generate.mjs` и `../tools/...`.

View File

@@ -0,0 +1,112 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Headers,
HttpCode,
Post,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AidExportService, OpenApiExportMode } from './aid-export.service';
/**
* HTTP-экспортёры для AID: OpenAPI из api-format и генерация приложения из DSL.
*/
@Controller('aid/export')
export class AidExportController {
constructor(
private readonly aidExport: AidExportService,
private readonly config: ConfigService,
) {}
private assertExportKey(exportKey: string | undefined) {
const requiredKey = this.config.get<string>('AID_EXPORT_API_KEY');
if (requiredKey && exportKey !== requiredKey) {
throw new UnauthorizedException('Invalid or missing X-AID-Export-Key');
}
}
/**
* POST /aid/export/openapi
* Body: { "apiFormat": <объект api-format>, "mode"?: "deterministic" | "llm" }
* Response: { "openapi": <OpenAPI 3.0.3 document> }
*
* mode=llm требует OPENAI_API_KEY в окружении сервера.
*
* Если задан AID_EXPORT_API_KEY, клиент должен передать заголовок X-AID-Export-Key с тем же значением.
*/
@Post('openapi')
@HttpCode(200)
async exportOpenApi(
@Body()
body: { apiFormat?: unknown; mode?: string },
@Headers('x-aid-export-key') exportKey?: string,
) {
this.assertExportKey(exportKey);
if (body == null || typeof body !== 'object' || body.apiFormat == null) {
throw new BadRequestException('Body must be a JSON object with an "apiFormat" property');
}
if (typeof body.apiFormat !== 'object' || Array.isArray(body.apiFormat)) {
throw new BadRequestException('"apiFormat" must be a JSON object');
}
const mode: OpenApiExportMode =
body.mode === 'llm' ? 'llm' : 'deterministic';
const openapi = await this.aidExport.convertApiFormatToOpenApi(
body.apiFormat,
mode,
);
return { openapi };
}
/**
* POST /aid/export/app
* Body: { "dsl": "<текст DSL как в examples/TOiR.domain.dsl>", "apply"?: boolean }
*
* По умолчанию `apply: false` — возвращается JSON с полем `files` (пути относительно корня репо → содержимое),
* без записи на диск (безопасно для вызова из AID).
*
* `apply: true` перезаписывает файлы в **текущей** рабочей копии репозитория на машине, где крутится Nest.
* Разрешено только если в окружении задано `AID_GENERATOR_ALLOW_APPLY=1` (или `true`).
*/
@Post('app')
@HttpCode(200)
async exportApp(
@Body()
body: { dsl?: string; apply?: boolean },
@Headers('x-aid-export-key') exportKey?: string,
) {
this.assertExportKey(exportKey);
if (body == null || typeof body !== 'object') {
throw new BadRequestException('Body must be a JSON object');
}
if (typeof body.dsl !== 'string' || !body.dsl.trim()) {
throw new BadRequestException('Body must include a non-empty string "dsl"');
}
const apply = body.apply === true;
if (apply) {
const allow = this.config.get<string>('AID_GENERATOR_ALLOW_APPLY');
if (allow !== '1' && allow !== 'true') {
throw new ForbiddenException(
'apply=true is disabled. Set AID_GENERATOR_ALLOW_APPLY=1 on the server to allow writing generated files to disk.',
);
}
const { message } = await this.aidExport.generateAppApply(body.dsl);
return { applied: true, message };
}
const bundle = await this.aidExport.generateAppBundle(body.dsl);
return {
applied: false,
entityCount: bundle.entityCount,
enumCount: bundle.enumCount,
files: bundle.files,
};
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AidExportController } from './aid-export.controller';
import { AidExportService } from './aid-export.service';
@Module({
controllers: [AidExportController],
providers: [AidExportService],
})
export class AidExportModule {}

View File

@@ -0,0 +1,154 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { execFile } from 'child_process';
import { access, readFile, unlink, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const LARGE_BUFFER = 64 * 1024 * 1024;
export type OpenApiExportMode = 'deterministic' | 'llm';
export type AppGeneratorBundle = {
entityCount: number;
enumCount: number;
files: Record<string, string>;
};
@Injectable()
export class AidExportService {
/**
* Путь к tools/api-format-to-openapi/convert.mjs относительно cwd процесса (обычно каталог server/).
*/
private resolveConvertScript(): string {
return join(process.cwd(), '..', 'tools', 'api-format-to-openapi', 'convert.mjs');
}
/** Путь к generation/generate.mjs относительно cwd = server/. */
private resolveGenerateScript(): string {
return join(process.cwd(), '..', 'generation', 'generate.mjs');
}
async convertApiFormatToOpenApi(
apiFormat: unknown,
mode: OpenApiExportMode,
): Promise<Record<string, unknown>> {
const script = this.resolveConvertScript();
try {
await access(script);
} catch {
throw new InternalServerErrorException(
`Converter script not found at ${script}. Run the server with cwd = server/ from repo root.`,
);
}
const id = randomUUID();
const inPath = join(tmpdir(), `api-format-${id}.json`);
const outPath = join(tmpdir(), `openapi-${id}.json`);
try {
await writeFile(inPath, JSON.stringify(apiFormat), 'utf8');
const { stderr } = await execFileAsync(
process.execPath,
[script, '--in', inPath, '--out', outPath, '--mode', mode],
{
env: { ...process.env },
maxBuffer: 16 * 1024 * 1024,
},
);
if (stderr?.trim()) {
// convert.mjs пишет ошибки в stderr при падении; при успехе обычно пусто
console.warn('[aid-export]', stderr);
}
const raw = await readFile(outPath, 'utf8');
return JSON.parse(raw) as Record<string, unknown>;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new InternalServerErrorException(`OpenAPI conversion failed: ${msg}`);
} finally {
await unlink(inPath).catch(() => undefined);
await unlink(outPath).catch(() => undefined);
}
}
/**
* DSL → снимок сгенерированных файлов (без записи в репозиторий).
* Использует `generation/generate.mjs --print-bundle-json`.
*/
async generateAppBundle(dsl: string): Promise<AppGeneratorBundle> {
const script = this.resolveGenerateScript();
try {
await access(script);
} catch {
throw new InternalServerErrorException(
`Generator script not found at ${script}. Run the server with cwd = server/ from repo root.`,
);
}
const id = randomUUID();
const dslPath = join(tmpdir(), `domain-${id}.dsl`);
try {
await writeFile(dslPath, dsl, 'utf8');
const { stdout, stderr } = await execFileAsync(
process.execPath,
[script, '--print-bundle-json', '--dsl', dslPath],
{
env: { ...process.env },
maxBuffer: LARGE_BUFFER,
},
);
if (stderr?.trim()) console.warn('[aid-export][generate]', stderr);
const bundle = JSON.parse(stdout) as AppGeneratorBundle;
if (!bundle.files || typeof bundle.files !== 'object') {
throw new Error('Invalid bundle: missing files');
}
return bundle;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new InternalServerErrorException(`App generator (bundle) failed: ${msg}`);
} finally {
await unlink(dslPath).catch(() => undefined);
}
}
/**
* DSL → запись сгенерированного кода в рабочую копию репозитория (`--apply`).
* Опасно для публичных эндпоинтов; включать только осознанно.
*/
async generateAppApply(dsl: string): Promise<{ message: string }> {
const script = this.resolveGenerateScript();
try {
await access(script);
} catch {
throw new InternalServerErrorException(
`Generator script not found at ${script}. Run the server with cwd = server/ from repo root.`,
);
}
const id = randomUUID();
const dslPath = join(tmpdir(), `domain-${id}.dsl`);
try {
await writeFile(dslPath, dsl, 'utf8');
const { stdout, stderr } = await execFileAsync(
process.execPath,
[script, '--apply', '--dsl', dslPath],
{
env: { ...process.env },
maxBuffer: LARGE_BUFFER,
},
);
if (stderr?.trim()) console.warn('[aid-export][generate-apply]', stderr);
return { message: (stdout || 'ok').trim() };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new InternalServerErrorException(`App generator (apply) failed: ${msg}`);
} finally {
await unlink(dslPath).catch(() => undefined);
}
}
}

View File

@@ -5,11 +5,13 @@ import { HealthModule } from './health/health.module';
import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module'; import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module';
import { EquipmentModule } from './modules/equipment/equipment.module'; import { EquipmentModule } from './modules/equipment/equipment.module';
import { RepairOrderModule } from './modules/repair-order/repair-order.module'; import { RepairOrderModule } from './modules/repair-order/repair-order.module';
import { AidExportModule } from './aid-export/aid-export.module';
@Module({ @Module({
imports: [ConfigModule.forRoot({ isGlobal: true }), imports: [ConfigModule.forRoot({ isGlobal: true }),
PrismaModule, PrismaModule,
HealthModule, HealthModule,
AidExportModule,
EquipmentTypeModule, EquipmentTypeModule,
EquipmentModule, EquipmentModule,
RepairOrderModule, RepairOrderModule,

View File

@@ -0,0 +1,61 @@
# 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) |
## Детерминированный режим (без 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
```

View 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);
});

View 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"]
}
}
]
}

View File

@@ -0,0 +1,10 @@
{
"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"
}
}

View 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` одним предложением.