diff --git a/README.md b/README.md index 64221dc..622b183 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,10 @@ nvm use --silent npm run openapi:generate ``` -Команда собирает backend и записывает `openapi.json` в корень репозитория. +Команда собирает backend и записывает `openapi.json` в корень репозитория +атомарно: через временный файл и rename, без частичной записи при сбое. +`npm run openapi:check` только сравнивает runtime schema с файлом и завершается +с ошибкой при расхождении, не перезаписывая его. Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger diff --git a/package.json b/package.json index 4f195a1..c01afbc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "build": "prisma generate && nest build", "openapi:generate": "npm run build && node scripts/generate-openapi.cjs", + "openapi:check": "npm run build && node scripts/generate-openapi.cjs --check", "format": "npm run lint:fix", "start": "nest start", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", diff --git a/scripts/generate-openapi.cjs b/scripts/generate-openapi.cjs index ad8b646..a24a8ba 100644 --- a/scripts/generate-openapi.cjs +++ b/scripts/generate-openapi.cjs @@ -1,5 +1,10 @@ const { randomBytes } = require('node:crypto'); -const { writeFile } = require('node:fs/promises'); +const { readFile, rename, rm, writeFile } = require('node:fs/promises'); +const { resolve } = require('node:path'); + +// Relative to the working directory, as before: the contract test runs the script in a temporary directory. +const target = resolve(process.cwd(), 'openapi.json'); +const checkOnly = process.argv.includes('--check'); // Schema generation needs no deployment configuration or real credentials. Object.assign(process.env, { @@ -19,23 +24,48 @@ const { NestFactory } = require('@nestjs/core'); const { AppModule } = require('../dist/src/app.module.js'); const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js'); -async function generate() { +async function render() { const app = await NestFactory.create(AppModule, { preview: true, logger: false, }); try { app.setGlobalPrefix(API_PREFIX); - await writeFile( - 'openapi.json', - JSON.stringify(createOpenApiDocument(app), null, 2) + '\n', - ); + return JSON.stringify(createOpenApiDocument(app), null, 2) + '\n'; } finally { await app.close(); } } -generate().catch((error) => { - console.error(error); - process.exitCode = 1; -}); +// Read-only: compares the runtime document with the committed artifact and never writes. +async function check(content) { + const current = await readFile(target, 'utf8').catch(() => null); + if (current !== content) { + console.error( + 'openapi.json is out of date with the runtime schema. Run `npm run openapi:generate`.', + ); + process.exitCode = 1; + return; + } + console.log('openapi.json matches the runtime schema.'); +} + +// Atomic update: write a sibling temp file, then rename over the target, so a failed run never leaves partial output. +async function update(content) { + const temp = `${target}.${process.pid}.tmp`; + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input. + await writeFile(temp, content); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input. + await rename(temp, target); + } finally { + await rm(temp, { force: true }); + } +} + +render() + .then((content) => (checkOnly ? check(content) : update(content))) + .catch((error) => { + console.error(error); + process.exitCode = 1; + });