env variables

This commit is contained in:
Nikita Bizyaev
2026-06-17 11:26:57 +03:00
parent 123bd97018
commit f3e3280967
10 changed files with 134 additions and 57 deletions

View File

@@ -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,

View File

@@ -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<string>(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));
}

View File

@@ -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),
});

View File

@@ -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<EnvironmentVariables, true>) {}
canActivate(context: ExecutionContext): boolean {
const expectedKey = this.config.get<string>('INGEST_API_KEY');
const expectedKey = this.config.get('INGEST_API_KEY');
if (!expectedKey) {
return true;
}

View File

@@ -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<EnvironmentVariables, true>) {}
// Инициализируем пул один раз на модуль и сразу проверяем доступность БД.
async onModuleInit(): Promise<void> {
const connectionString = this.config.get<string>('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,

View File

@@ -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<EnvironmentVariables, true>,
) {}
// 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);
}

View File

@@ -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<EnvironmentVariables, true>,
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.`);

View File

@@ -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<EnvironmentVariables, true>);
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();