Merge add_aid_exporters into feat/keycloak (local)
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -35,3 +35,7 @@ Thumbs.db
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.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/
|
||||||
|
|||||||
@@ -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.
|
`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)**.
|
||||||
|
|||||||
164
docs/AID_EXPORT_README.md
Normal file
164
docs/AID_EXPORT_README.md
Normal file
@@ -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://<host>:<port>` (например `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 лучше зафиксировать отдельно.
|
||||||
@@ -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() {
|
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] : 'domain/TOiR.domain.dsl';
|
const dslPath = dslArgIdx >= 0 ? args[dslArgIdx + 1] : 'domain/TOiR.domain.dsl';
|
||||||
|
|
||||||
@@ -680,6 +712,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);
|
||||||
|
|||||||
@@ -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 domain/TOiR.domain.dsl",
|
"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"
|
"postinstall": "prisma generate"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
|
|||||||
87
server/src/aid-export/README.md
Normal file
87
server/src/aid-export/README.md
Normal file
@@ -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/...`.
|
||||||
112
server/src/aid-export/aid-export.controller.ts
Normal file
112
server/src/aid-export/aid-export.controller.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/src/aid-export/aid-export.module.ts
Normal file
9
server/src/aid-export/aid-export.module.ts
Normal 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 {}
|
||||||
154
server/src/aid-export/aid-export.service.ts
Normal file
154
server/src/aid-export/aid-export.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ 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({
|
imports: [ConfigModule.forRoot({
|
||||||
@@ -16,6 +17,7 @@ import { RepairOrderModule } from './modules/repair-order/repair-order.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
AidExportModule,
|
||||||
EquipmentTypeModule,
|
EquipmentTypeModule,
|
||||||
EquipmentModule,
|
EquipmentModule,
|
||||||
RepairOrderModule,
|
RepairOrderModule,
|
||||||
|
|||||||
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` одним предложением.
|
||||||
Reference in New Issue
Block a user