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,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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user