diff --git a/package-lock.json b/package-lock.json index c9f0a3d..ad93529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "compression": "^1.8.1", + "joi": "^18.2.1", "pg": "^8.13.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -1035,6 +1036,54 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2565,6 +2614,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -7171,6 +7226,24 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index a7ec527..4ac810c 100644 --- a/package.json +++ b/package.json @@ -21,27 +21,28 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "compression": "^1.8.1", + "joi": "^18.2.1", "pg": "^8.13.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, "devDependencies": { + "@eslint/js": "^9.18.0", "@nestjs/cli": "^11.0.0", "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/pg": "^8.11.10", + "eslint": "^9.18.0", + "globals": "^16.0.0", "jest": "^30.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0", - "@eslint/js": "^9.18.0", - "eslint": "^9.18.0", - "globals": "^16.0.0" + "typescript-eslint": "^8.20.0" }, "jest": { "moduleFileExtensions": [ diff --git a/src/app.module.ts b/src/app.module.ts index 453e2a1..a5c6c38 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { envValidationSchema } from './common/env.validation'; import { CurrentModule } from './current/current.module'; import { DbModule } from './db/db.module'; import { EdgeModule } from './edge/edge.module'; @@ -9,7 +10,10 @@ import { IngestModule } from './ingest/ingest.module'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ + isGlobal: true, + validationSchema: envValidationSchema, + }), DbModule, IngestModule, EdgeModule, diff --git a/src/common/config-number.ts b/src/common/config-number.ts deleted file mode 100644 index 4f65a79..0000000 --- a/src/common/config-number.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ConfigService } from '@nestjs/config'; - -// Читает числовой env-параметр с безопасным fallback, чтобы конфиг не ломал старт сервиса. -export function getNumberConfig( - config: ConfigService, - key: string, - fallback: number, -): number { - const value = config.get(key); - if (value === undefined || value === '') { - return fallback; - } - - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} - -// Используется для лимитов и размеров пулов, где дробные значения не имеют смысла. -export function getIntegerConfig( - config: ConfigService, - key: string, - fallback: number, -): number { - return Math.floor(getNumberConfig(config, key, fallback)); -} diff --git a/src/common/env.validation.ts b/src/common/env.validation.ts new file mode 100644 index 0000000..fbe3f7b --- /dev/null +++ b/src/common/env.validation.ts @@ -0,0 +1,30 @@ +import * as Joi from 'joi'; + +export interface EnvironmentVariables { + PORT: number; + DATABASE_URL: string; + CORS_ALLOWED_ORIGINS: string; + INGEST_API_KEY: string; + PG_POOL_MAX: number; + INGEST_MAX_BATCH_SIZE: number; + HISTORY_TARGET_POINTS: number; + HISTORY_MAX_TARGET_POINTS: number; +} + +export const envValidationSchema = Joi.object({ + PORT: Joi.number().port().default(3100), + + DATABASE_URL: Joi.string().required(), + + CORS_ALLOWED_ORIGINS: Joi.string().allow('').default(''), + + INGEST_API_KEY: Joi.string().allow('').default(''), + + PG_POOL_MAX: Joi.number().integer().min(1).default(20), + + INGEST_MAX_BATCH_SIZE: Joi.number().integer().min(1).default(10_000), + + HISTORY_TARGET_POINTS: Joi.number().integer().min(100).default(2000), + + HISTORY_MAX_TARGET_POINTS: Joi.number().integer().min(100).default(2000), +}); diff --git a/src/common/ingest-api-key.guard.ts b/src/common/ingest-api-key.guard.ts index 43cddf6..584543f 100644 --- a/src/common/ingest-api-key.guard.ts +++ b/src/common/ingest-api-key.guard.ts @@ -1,13 +1,14 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { Request } from 'express'; +import { EnvironmentVariables } from './env.validation'; @Injectable() export class IngestApiKeyGuard implements CanActivate { - constructor(private readonly config: ConfigService) {} + constructor(private readonly config: ConfigService) {} canActivate(context: ExecutionContext): boolean { - const expectedKey = this.config.get('INGEST_API_KEY'); + const expectedKey = this.config.get('INGEST_API_KEY'); if (!expectedKey) { return true; } diff --git a/src/db/db.service.ts b/src/db/db.service.ts index f026895..d19d857 100644 --- a/src/db/db.service.ts +++ b/src/db/db.service.ts @@ -1,8 +1,7 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg'; -import { getIntegerConfig } from '../common/config-number'; - +import { EnvironmentVariables } from '../common/env.validation'; type DatabaseHealth = { now: Date; timescaledb_installed: boolean; @@ -13,18 +12,13 @@ type DatabaseHealth = { export class DbService implements OnModuleInit, OnModuleDestroy { private pool!: Pool; - constructor(private readonly config: ConfigService) {} + constructor(private readonly config: ConfigService) {} // Инициализируем пул один раз на модуль и сразу проверяем доступность БД. async onModuleInit(): Promise { - const connectionString = this.config.get('DATABASE_URL'); - if (!connectionString) { - throw new Error('DATABASE_URL is required.'); - } - this.pool = new Pool({ - connectionString, - max: getIntegerConfig(this.config, 'PG_POOL_MAX', 20), + connectionString: this.config.get('DATABASE_URL'), + max: this.config.get('PG_POOL_MAX'), idleTimeoutMillis: 30_000, connectionTimeoutMillis: 5_000, statement_timeout: 60_000, diff --git a/src/history/history.service.ts b/src/history/history.service.ts index deb8b6b..83c99f9 100644 --- a/src/history/history.service.ts +++ b/src/history/history.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { getIntegerConfig } from '../common/config-number'; +import { EnvironmentVariables } from '../common/env.validation'; import { normalizeRequiredText } from '../common/normalize-text'; import { GetHistoryDto } from './dto/get-history.dto'; import { chooseHistorySource } from './history-source'; @@ -12,7 +12,7 @@ import { HistoryResponse } from './history.types'; export class HistoryService { constructor( private readonly repository: HistoryRepository, - private readonly config: ConfigService, + private readonly config: ConfigService, ) {} // Use-case для графиков: выбирает latest/raw/aggregate и возвращает единый API-формат. @@ -84,8 +84,8 @@ export class HistoryService { // Ограничивает плотность ответа, чтобы API не возвращал больше точек, чем способен отрисовать UI. private getTargetPoints(requested?: number): number { - const defaultValue = getIntegerConfig(this.config, 'HISTORY_TARGET_POINTS', 2000); - const maxValue = getIntegerConfig(this.config, 'HISTORY_MAX_TARGET_POINTS', 2000); + const defaultValue = this.config.get('HISTORY_TARGET_POINTS'); + const maxValue = this.config.get('HISTORY_MAX_TARGET_POINTS'); const value = requested ?? defaultValue; return Math.min(Math.max(Math.floor(value), 100), maxValue); } diff --git a/src/ingest/ingest.service.ts b/src/ingest/ingest.service.ts index 3309cf6..33267b3 100644 --- a/src/ingest/ingest.service.ts +++ b/src/ingest/ingest.service.ts @@ -1,13 +1,11 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { getIntegerConfig } from '../common/config-number'; +import { EnvironmentVariables } from '../common/env.validation'; import { normalizeRequiredText } from '../common/normalize-text'; import { CurrentEventsService } from '../current/current-events.service'; import { DbService } from '../db/db.service'; import { IngestPointDto } from './dto/ingest-point.dto'; -const DEFAULT_MAX_BATCH_SIZE = 10_000; - type NormalizedPoint = { time: Date; edge: string; @@ -33,7 +31,7 @@ type CurrentValueRow = { export class IngestService { constructor( private readonly db: DbService, - private readonly config: ConfigService, + private readonly config: ConfigService, private readonly currentEvents: CurrentEventsService, ) {} @@ -185,10 +183,7 @@ export class IngestService { // Ограничивает размер одного запроса, чтобы один producer не занял память и соединение надолго. private assertBatchSize(size: number): void { - const maxBatchSize = Math.max( - getIntegerConfig(this.config, 'INGEST_MAX_BATCH_SIZE', DEFAULT_MAX_BATCH_SIZE), - 1, - ); + const maxBatchSize = this.config.get('INGEST_MAX_BATCH_SIZE'); if (size > maxBatchSize) { throw new BadRequestException(`Batch size must not exceed ${maxBatchSize} points.`); diff --git a/src/main.ts b/src/main.ts index 2c01f54..a333fe7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,18 @@ import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import compression from 'compression'; import { AppModule } from './app.module'; +import { EnvironmentVariables } from './common/env.validation'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.use(compression()); - const corsAllowedOrigins = process.env.CORS_ALLOWED_ORIGINS - ? process.env.CORS_ALLOWED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean) + const config = app.get(ConfigService); + const corsOrigins = config.get('CORS_ALLOWED_ORIGINS'); + const corsAllowedOrigins = corsOrigins + ? corsOrigins.split(',').map((origin: string) => origin.trim()).filter(Boolean) : true; // CORS настраивается через env, чтобы один build работал локально и в окружениях деплоя. @@ -28,7 +32,7 @@ async function bootstrap() { }), ); - await app.listen(process.env.PORT ?? 3100); + await app.listen(config.get('PORT')); } void bootstrap();