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:
1 parent
e4417705e7
commit
445efc998d
21 files changed
+6145
-1
No files matched your search
@@ -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 {}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()) };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user