feat(api): app NestJS con configuración validada y health check

Nest 12 (ESM, vitest, oxlint). El .env se valida con zod al arrancar;
prefijo global /api, CORS configurable y GET /api/health.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 18:13:52 -05:00
1 parent e4417705e7
commit 445efc998d
21 files changed
+6145 -1

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AppConfigModule } from './config/config.module.js';
import { HealthController } from './health/health.controller.js';
@Module({
imports: [AppConfigModule],
controllers: [HealthController],
})
export class AppModule {}
+13
View File
@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Env } from './env.schema.js';
/** Acceso tipado a la configuración ya validada. */
@Injectable()
export class AppConfigService {
constructor(private readonly config: ConfigService<Env, true>) {}
get<K extends keyof Env>(key: K): Env[K] {
return this.config.get(key, { infer: true });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppConfigService } from './app-config.service.js';
import { validateEnv } from './env.schema.js';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env'],
validate: validateEnv,
}),
],
providers: [AppConfigService],
exports: [AppConfigService],
})
export class AppConfigModule {}
+20
View File
@@ -0,0 +1,20 @@
import { validateEnv } from './env.schema.js';
describe('validateEnv', () => {
it('aplica valores por defecto', () => {
const env = validateEnv({});
expect(env.PORT).toBe(3000);
expect(env.CORS_ORIGINS).toEqual([]);
expect(env.LOG_LEVEL).toBe('log');
});
it('convierte PORT y separa CORS_ORIGINS', () => {
const env = validateEnv({ PORT: '8080', CORS_ORIGINS: 'https://a.local, https://b.local' });
expect(env.PORT).toBe(8080);
expect(env.CORS_ORIGINS).toEqual(['https://a.local', 'https://b.local']);
});
it('rechaza valores inválidos con un mensaje claro', () => {
expect(() => validateEnv({ PORT: 'abc' })).toThrow(/PORT/);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
// Orígenes permitidos por CORS, separados por coma. Vacío = mismo origen.
CORS_ORIGINS: z
.string()
.default('')
.transform((v) =>
v
.split(',')
.map((s) => s.trim())
.filter(Boolean),
),
LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug', 'verbose']).default('log'),
});
export type Env = z.infer<typeof envSchema>;
export function validateEnv(raw: Record<string, unknown>): Env {
const result = envSchema.safeParse(raw);
if (!result.success) {
const detail = result.error.issues
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join('; ');
throw new Error(`Configuración inválida (.env): ${detail}`);
}
return result.data;
}
+9
View File
@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok', uptime: Math.round(process.uptime()) };
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NestFactory } from '@nestjs/core';
import type { LogLevel } from '@nestjs/common';
import { AppModule } from './app.module.js';
import { AppConfigService } from './config/app-config.service.js';
const LOG_LEVELS: LogLevel[] = ['error', 'warn', 'log', 'debug', 'verbose'];
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(AppConfigService);
app.setGlobalPrefix('api');
app.useLogger(LOG_LEVELS.slice(0, LOG_LEVELS.indexOf(config.get('LOG_LEVEL')) + 1));
app.enableCors({ origin: config.get('CORS_ORIGINS') });
app.enableShutdownHooks();
await app.listen(config.get('PORT'));
}
await bootstrap();