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

+1
View File
@@ -13,3 +13,4 @@ logs/
.idea/
.vscode/*
!.vscode/extensions.json
*.tsbuildinfo
+5
View File
@@ -0,0 +1,5 @@
NODE_ENV=development
PORT=3000
# Orígenes permitidos por CORS, separados por coma (ej. https://erp.local)
CORS_ORIGINS=http://localhost:4200
LOG_LEVEL=log
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"rules": {
"typescript/no-explicit-any": "off",
"typescript/no-floating-promises": "error"
},
"env": {
"node": true
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+15
View File
@@ -0,0 +1,15 @@
# api
Backend NestJS (ESM) del ERP base. Proxy hacia SAP Business One Service Layer.
```bash
cp .env.example .env
npm run start:dev # http://localhost:3000/api/health
npm test # unitarios
npm run test:e2e # e2e
npm run lint
```
Configuración: `src/config/env.schema.ts` valida el `.env` al arrancar (zod); si algo es inválido, el proceso no inicia.
Nota: instalar dependencias con npm 11 (`npx npm@11 install`); npm 10 falla al resolver peers con TypeScript 6.
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"build": "nest build",
"deploy": "nest deploy",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "oxlint --type-aware src/ test/",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect-brk --no-file-parallelism",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
},
"dependencies": {
"@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.1",
"@nestjs/core": "^12.0.1",
"@nestjs/platform-express": "^12.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@nestjs/cli": "^12.0.0",
"@nestjs/mau": "^0.2.6",
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@types/express": "^5.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"@vitest/coverage-v8": "^4.1.2",
"oxlint": "^1.58.0",
"oxlint-tsgolint": "^7.0.2001",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"typescript": "^6.0.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.2"
}
}
+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();
+24
View File
@@ -0,0 +1,24 @@
import { Test } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
describe('Health (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api');
await app.init();
});
afterAll(async () => {
await app.close();
});
it('GET /api/health responde ok', async () => {
const res = await request(app.getHttpServer()).get('/api/health').expect(200);
expect(res.body.status).toBe('ok');
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"rootDir": ".",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictPropertyInitialization": false,
"types": ["vitest/globals", "node"]
}
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
root: './',
include: ['**/*.e2e-spec.ts'],
},
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
// Resolves the path aliases declared in tsconfig.json, including the ones
// added by `nest g library`.
plugins: [tsconfigPaths()],
test: {
globals: true,
root: './',
include: ['**/*.spec.ts'],
},
});
+5852
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -8,6 +8,7 @@
"libs/*"
],
"engines": {
"node": ">=22"
"node": ">=22",
"npm": ">=11"
}
}