diff --git a/.gitignore b/.gitignore index 5ad6e4e..e769025 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ Thumbs.db *.njsproj *.sln *.sw? +# Generated OpenAPI (local runs; commit only if you want to publish the spec) +openapi.generated.json +openapi.llm.json +tools/api-format-to-openapi/demo-output/ diff --git a/README.md b/README.md index 4a99e51..a035d58 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,7 @@ npm run validate:generation:runtime ``` `npm run validate:generation` now checks both contract shape and workspace validity. When dependencies are installed, it also verifies `npm run build` in `server/` and `client/`. If dependencies are missing, it reports build verification as skipped instead of pretending the baseline is fully green. + +## AID export (OpenAPI + app generator) + +HTTP-экспортёры для интеграции с AID: **`POST /aid/export/openapi`** (api-format → OpenAPI 3.0) и **`POST /aid/export/app`** (DSL → бандл файлов или `--apply`). Подробно: **[docs/AID_EXPORT_README.md](docs/AID_EXPORT_README.md)**. diff --git a/docs/AID_EXPORT_README.md b/docs/AID_EXPORT_README.md new file mode 100644 index 0000000..c10a546 --- /dev/null +++ b/docs/AID_EXPORT_README.md @@ -0,0 +1,164 @@ +# AID: экспорт OpenAPI и генератор приложения + +В репозитории добавлены **сервисы-экспортёры** для интеграции с **AID** (или любым другим клиентом по HTTP): автоматическое получение **OpenAPI 3.0** из доменного **api-format** и выдача **сгенерированного fullstack-приложения** из **DSL** без ручного копирования файлов. + +--- + +## Что сделано + +| Компонент | Назначение | +|-----------|------------| +| **`POST /aid/export/openapi`** (NestJS) | На вход JSON **api-format** → на выход документ **OpenAPI 3.0** в поле `openapi`. | +| **`POST /aid/export/app`** (NestJS) | На вход текст **DSL** → либо JSON-бандл всех сгенерированных файлов (`files`), либо запись в рабочую копию репозитория (`apply: true`, опционально). | +| **`tools/api-format-to-openapi/`** | CLI и промпт для LLM: тот же конвертер, что вызывает Nest. | +| **`generation/generate.mjs`** | Новый флаг **`--print-bundle-json`**: вывод в stdout JSON с `entityCount`, `enumCount`, `files` — без записи на диск (аналог «сухого» экспорта для AID). | +| **`server/src/aid-export/`** | Модуль Nest: контроллер, сервис, краткая справка в `README.md` рядом с кодом. | + +Ветка с этими изменениями: **`add_aid_exporters`**. + +--- + +## Требования к запуску + +1. Репозиторий клонирован целиком (есть `generation/`, `tools/`, `server/`, `client/`). +2. Backend запускается из каталога **`server/`** (`npm run start` / `start:dev`), чтобы относительные пути `../generation/generate.mjs` и `../tools/api-format-to-openapi/convert.mjs` были корректны. +3. Для режима OpenAPI через LLM на сервере нужны **`OPENAI_API_KEY`** (и при необходимости `OPENAI_MODEL`, `OPENAI_BASE_URL`). + +--- + +## Переменные окружения (`server/.env`) + +| Переменная | Зачем | +|------------|--------| +| `AID_EXPORT_API_KEY` | Если задана, к **`/aid/export/*`** нужен заголовок **`X-AID-Export-Key`** с тем же значением. | +| `AID_GENERATOR_ALLOW_APPLY` | Должна быть **`1`** или **`true`**, иначе **`POST /aid/export/app`** с **`apply: true`** вернёт **403** (защита от случайной перезаписи репозитория на сервере). | +| `OPENAI_API_KEY` | Для `POST /aid/export/openapi` с **`"mode": "llm"`**. | + +Остальное как для обычного бэкенда (`DATABASE_URL`, `PORT` и т.д.). + +--- + +## HTTP API (интеграция с AID) + +Базовый URL: `http://:` (например `http://localhost:3000`). + +### 1. OpenAPI из api-format + +**`POST /aid/export/openapi`** + +```http +Content-Type: application/json +X-AID-Export-Key: <если задан AID_EXPORT_API_KEY> +``` + +```json +{ + "apiFormat": { + "apiFormatVersion": "1", + "info": { "title": "API", "version": "1.0.0" }, + "server": { "basePath": "/api" }, + "resources": [] + }, + "mode": "deterministic" +} +``` + +- **`mode`**: `deterministic` (по умолчанию) — маппинг в коде для схемы версии `1`; **`llm`** — вызов OpenAI по промпту из `tools/api-format-to-openapi/prompts/llm-system.md`. + +**Ответ:** `{ "openapi": { "openapi": "3.0.3", ... } }` + +Пример входа для теста: `tools/api-format-to-openapi/examples/api-format.example.json` (подставьте как значение `apiFormat`). + +### 2. Генератор приложения из DSL + +**`POST /aid/export/app`** + +```json +{ + "dsl": "domain TOiR {\n ...\n}\n", + "apply": false +} +``` + +- **`apply: false`** (рекомендуется для AID): в ответе **`files`** — объект «путь от корня репо → текст файла». Диск на сервере не меняется. +- **`apply: true`**: выполняется запись файлов как у `npm run generate:from-dsl` с `--apply`; нужен **`AID_GENERATOR_ALLOW_APPLY=1`**. + +**Ответ (бандл):** `{ "applied": false, "entityCount": N, "enumCount": M, "files": { ... } }` +**Ответ (apply):** `{ "applied": true, "message": "Generated ..." }` + +Эталон DSL: `examples/TOiR.domain.dsl`. + +--- + +## CLI (без Nest) + +### Пошаговая демонстрация в терминале + +```bash +cd tools/api-format-to-openapi +npm run demo +# или с паузой после каждого шага (Enter): +npm run demo:pause +``` + +Показывает входной **api-format**, логику маппинга, запуск конвертера и структуру **OpenAPI**; результат — `demo-output/openapi.json`. + +### api-format → OpenAPI + +```bash +cd tools/api-format-to-openapi +node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json +``` + +LLM: + +```bash +set OPENAI_API_KEY=sk-... +node convert.mjs --mode llm --in your-api-format.json --out ../../openapi.llm.json +``` + +Подробнее: **`tools/api-format-to-openapi/README.md`**. + +### DSL → JSON-бандл + +Из **корня репозитория**: + +```bash +node generation/generate.mjs --print-bundle-json --dsl examples/TOiR.domain.dsl > bundle.json +``` + +Из **`server/`**: + +```bash +npm run generate:bundle-json > ../bundle.json +``` + +Применить генератор к файлам на диске (как раньше): + +```bash +cd server +npm run generate:from-dsl +``` + +--- + +## Типичный сценарий для AID + +1. AID уже сформировал **api-format** (как у вас принято после DTO). +2. AID вызывает **`POST /aid/export/openapi`** → получает **OpenAPI 3.0** → сохраняет в проект / отдаёт в Swagger / в реестр. +3. Для кода: AID передаёт **DSL** в **`POST /aid/export/app`** с **`apply: false`** → забирает **`files`** → применяет у себя (git apply, распаковка, PR). +4. Запись **`apply: true`** на общем сервере используйте только в доверенной среде и с **`AID_GENERATOR_ALLOW_APPLY`**. + +--- + +## Где смотреть код и короткую справку + +- Полное описание эндпоинтов рядом с реализацией: **`server/src/aid-export/README.md`** +- Общий dev-workflow (в т.ч. упоминание AID): **`generation/dev-workflow.md`** + +--- + +## Ограничения и дальнейшие шаги + +- Пример **api-format** в репозитории — **учебный**; под ваш продакшен-формат может понадобиться расширить маппинг в `convert.mjs` или отточить промпт **`llm-system.md`**. +- Ответ **`/aid/export/app`** с большим числом сущностей может быть объёмным; при необходимости добавьте сжатие, отдельное хранилище артефактов или пагинацию по файлам — контракт с AID лучше зафиксировать отдельно. diff --git a/generation/generate.mjs b/generation/generate.mjs index c37046d..fa36c3f 100644 --- a/generation/generate.mjs +++ b/generation/generate.mjs @@ -670,9 +670,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() { const args = process.argv.slice(2); const apply = args.includes('--apply'); + const printBundleJson = args.includes('--print-bundle-json'); const dslArgIdx = args.indexOf('--dsl'); const dslPath = dslArgIdx >= 0 ? args[dslArgIdx + 1] : 'domain/TOiR.domain.dsl'; @@ -680,6 +712,12 @@ function main() { const dslText = readFile(absDsl); const parsed = parseDomainDSL(dslText); + if (printBundleJson) { + const bundle = collectGeneratedBundle(parsed); + process.stdout.write(JSON.stringify(bundle)); + return; + } + // Prisma schema const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma'); ensurePrismaSchema(parsed, prismaPath, apply); diff --git a/server/package.json b/server/package.json index b85909f..bbad221 100644 --- a/server/package.json +++ b/server/package.json @@ -19,6 +19,7 @@ "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", "generate:from-dsl": "node ../generation/generate.mjs --apply --dsl domain/TOiR.domain.dsl", + "generate:bundle-json": "node ../generation/generate.mjs --print-bundle-json --dsl domain/TOiR.domain.dsl", "postinstall": "prisma generate" }, "prisma": { diff --git a/server/src/aid-export/README.md b/server/src/aid-export/README.md new file mode 100644 index 0000000..b46ed50 --- /dev/null +++ b/server/src/aid-export/README.md @@ -0,0 +1,87 @@ +# AID export: OpenAPI + генератор приложения + +**Полное описание задачи, сценариев и CLI:** [docs/AID_EXPORT_README.md](../../../docs/AID_EXPORT_README.md) + +Ниже — краткая справка по HTTP-эндпоинтам. + +--- + +## 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/...`. diff --git a/server/src/aid-export/aid-export.controller.ts b/server/src/aid-export/aid-export.controller.ts new file mode 100644 index 0000000..a92de55 --- /dev/null +++ b/server/src/aid-export/aid-export.controller.ts @@ -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('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": } + * + * 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('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, + }; + } +} diff --git a/server/src/aid-export/aid-export.module.ts b/server/src/aid-export/aid-export.module.ts new file mode 100644 index 0000000..4aff7a4 --- /dev/null +++ b/server/src/aid-export/aid-export.module.ts @@ -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 {} diff --git a/server/src/aid-export/aid-export.service.ts b/server/src/aid-export/aid-export.service.ts new file mode 100644 index 0000000..abbbac9 --- /dev/null +++ b/server/src/aid-export/aid-export.service.ts @@ -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; +}; + +@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> { + 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; + } 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 { + 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); + } + } +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index b2bb498..ee523cb 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -7,6 +7,7 @@ import { HealthModule } from './health/health.module'; import { EquipmentTypeModule } from './modules/equipment-type/equipment-type.module'; import { EquipmentModule } from './modules/equipment/equipment.module'; import { RepairOrderModule } from './modules/repair-order/repair-order.module'; +import { AidExportModule } from './aid-export/aid-export.module'; @Module({ imports: [ConfigModule.forRoot({ @@ -16,6 +17,7 @@ import { RepairOrderModule } from './modules/repair-order/repair-order.module'; AuthModule, PrismaModule, HealthModule, + AidExportModule, EquipmentTypeModule, EquipmentModule, RepairOrderModule, diff --git a/tools/api-format-to-openapi/README.md b/tools/api-format-to-openapi/README.md new file mode 100644 index 0000000..5ce3a31 --- /dev/null +++ b/tools/api-format-to-openapi/README.md @@ -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 +``` diff --git a/tools/api-format-to-openapi/convert.mjs b/tools/api-format-to-openapi/convert.mjs new file mode 100644 index 0000000..855ade0 --- /dev/null +++ b/tools/api-format-to-openapi/convert.mjs @@ -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 --out [--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); +}); diff --git a/tools/api-format-to-openapi/demo-steps.mjs b/tools/api-format-to-openapi/demo-steps.mjs new file mode 100644 index 0000000..38865fe --- /dev/null +++ b/tools/api-format-to-openapi/demo-steps.mjs @@ -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); +}); diff --git a/tools/api-format-to-openapi/examples/api-format.example.json b/tools/api-format-to-openapi/examples/api-format.example.json new file mode 100644 index 0000000..056882c --- /dev/null +++ b/tools/api-format-to-openapi/examples/api-format.example.json @@ -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"] + } + } + ] +} diff --git a/tools/api-format-to-openapi/package.json b/tools/api-format-to-openapi/package.json new file mode 100644 index 0000000..e457ff0 --- /dev/null +++ b/tools/api-format-to-openapi/package.json @@ -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" + } +} diff --git a/tools/api-format-to-openapi/prompts/llm-system.md b/tools/api-format-to-openapi/prompts/llm-system.md new file mode 100644 index 0000000..4b17758 --- /dev/null +++ b/tools/api-format-to-openapi/prompts/llm-system.md @@ -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` одним предложением.