El repositorio nest-pokedex implementa una API RESTful utilizando el framework NestJS y el ODM Mongoose para gestionar una base de datos MongoDB. La API está diseñada para gestionar información sobre Pokémon, permitiendo a los usuarios realizar operaciones CRUD (Crear, Leer, Actualizar, Eliminar) sobre una colección de Pokémon almacenada en una base de datos.
- Node v18 - Descargar
- Docker - Descargar
- Nest CLI:
npm i -g @nestjs/cli - Imagen de MongoDB | imagen de mongo:5
--> docker-compose.yml
- Clonar el repositorio
- Ejecutar:
$ yarn install- Levantar la db:
$ docker compose up -d-
Cloanr el archivo
.env.templatey renombrar ka copia a.envy asignar las variables de entorno necesarias. -
Levantar la aplicación en modo watch:
$ yarn run start:dev- Correr el seed: http://localhost:3000/api/v2/seed
Adicionalmente si queremos preparar un Production Build realizaremos estos pasos:
-
Crear el archivo
.env.prod. -
Llenar las variables de entorno.
-
Crear la nueva imagen:
docker-compose -f docker-compose.prod.yml --env-file .env.prod up --builddocker-compose -f docker-compose.prod.yml --env-file .env.prod updocker-compose -f docker-compose.prod.yml --env-file .env.prod up -d
Por defecto, docker-compose usa el archivo .env, por lo que si tienen el archivo .env y lo configuran con sus variables de entorno de producción, bastaría con
docker-compose -f docker-compose.prod.yml up --build
- La opción
-fen el comando dedocker-composese utiliza para especificar un archivo de configuración diferente al predeterminado(docker-compose.yml). --env-fileespecifiva un.envdistinto, en este caso.env.prod.
# development
$ yarn run start
# watch mode
$ yarn run start:dev
# production mode
$ yarn run start:prodnest-pokedex/
├── src/
│ ├── shared/
│ │ └── pipes/
│ │ └── parse-mongo-id.pipe.ts
│ ├── pokemon/
│ │ ├── dto/
│ │ ├── entities/
│ │ ├── pokemon.controller.ts
│ │ ├── pokemon.module.ts
│ │ └── pokemon.service.ts
│ ├── app.module.ts
│ └── main.ts
├── test/
├── public/
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── README.md
├── docker-compose.yml
├── nest-cli.json
├── package.json
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock
El proyecto sigue el patrón de diseño Modular proporcionado por el framework NestJS. Este patrón permite organizar el código en módulos altamente cohesivos y desacoplados, facilitando el mantenimiento y escalabilidad de la aplicación.
El módulo common contiene componentes y servicios que son utilizados de manera transversal en toda la aplicación. Esto incluye pipes personalizados, filtros de excepciones y otros elementos reutilizables.
El módulo pokemon se encarga de la gestión de los Pokémon dentro de la aplicación. Incluye:
- Controladores: Definen las rutas y manejan las solicitudes HTTP relacionadas con los Pokémon.
- Servicios: Contienen la lógica de negocio para el manejo de datos y operaciones relacionadas con los Pokémon.
- Entidades: Definen la estructura de los datos de los Pokémon y su mapeo a la base de datos.
- DTOs (Data Transfer Objects): Especifican la forma de los datos que se envían y reciben a través de las interfaces de la aplicación.
Los DTOs se utilizan para definir la estructura de los datos que se intercambian entre el cliente y el servidor. Por otro lado, las entidades representan las estructuras de datos que se almacenan en la base de datos.
Ejemplo de DTO:
export class CreatePokemonDto {
@IsInt()
@IsPositive()
@Min(1)
no: number;
@IsString()
@MinLength(1)
name: string;
}Ejemplo de Entidad:
@Schema()
export class Pokemon extends Document {
//id: number; --> Generated by mongoDB
@Prop({
unique: true,
index: true,
})
name: string;
@Prop({
unique: true,
index: true,
})
no: number;
}
export const PokemonSchema = SchemaFactory.createForClass(Pokemon);-
@Schema():- Marca la clase como un esquema de Mongoose. Esto indica que la clase se traducirá a un esquema de MongoDB.
- Al aplicar este decorador, NestJS entiende que esta clase será utilizada para interactuar con MongoDB.
-
Herencia de Document:
- La clase Pokemon extiende de Document, que es la clase base de Mongoose para representar documentos en una colección.
- Esto permite que los objetos creados con esta clase tengan métodos de Mongoose como .save(), .update(), etc.
-
Propiedades Decoradas con
@Prop():- Define las propiedades que tendrán los documentos en la base de datos, junto con sus configuraciones específicas.
Propiedades:
name: string: Representa el nombre del Pokémon.unique: true: Garantiza que no haya dos documentos en la colección con el mismo valor para esta propiedad.index: true: Crea un índice en la base de datos para optimizar las búsquedas basadas en este campo.no: number: Representa el número del Pokémon.- También tiene
uniqueeindexconfigurados para garantizar la unicidad y mejorar el rendimiento en consultas.
- Define las propiedades que tendrán los documentos en la base de datos, junto con sus configuraciones específicas.
Propiedades:
-
Comentario
id: number:- Aunque la clase no declara explícitamente un campo id, al heredar de Document, Mongoose proporciona automáticamente un campo _id, que es el identificador único del documento generado por MongoDB.
export const PokemonSchema = SchemaFactory.createForClass(Pokemon);-
SchemaFactory.createForClass(Pokemon):- Convierte la clase Pokemon en un esquema de Mongoose válido.
- Genera automáticamente el esquema con base en las decoraciones aplicadas a las propiedades de la clase.
-
Exportación del Esquema:
- El esquema exportado (PokemonSchema) se utiliza para registrar el modelo en el módulo correspondiente (PokemonModule), como se muestra aquí:
MongooseModule.forFeature([ { name: Pokemon.name, schema: PokemonSchema }, ])
- El esquema exportado (PokemonSchema) se utiliza para registrar el modelo en el módulo correspondiente (PokemonModule), como se muestra aquí:
-
Modelo de Mongoose:
- NestJS usa
PokemonSchemapara crear un modelo de Mongoose (por ejemplo,PokemonModel). - Este modelo permite interactuar con la colección de MongoDB, incluyendo operaciones como
find,create,update, etc.
- NestJS usa
-
Configuraciones de los Campos:
unique: true: MongoDB aplica restricciones de unicidad a los camposnameyno. Intentar insertar un documento con valores duplicados en cualquiera de estos campos resultará en un error.index: true: MongoDB crea índices para estos campos, acelerando las búsquedas basadas en ellos.
-
Documentos Resultantes en MongoDB:
- Un documento de ejemplo en MongoDB podría verse así:
{ "_id": "648b5d2f9a0e9e001f4d6d1c", "name": "Pikachu", "no": 25, "__v": 0 }
-
Operaciones Comunes:
- GET
async findAll() { const pokemon = await this.pokemonModel.find(); return pokemon; }
- GET
async findOne(term: string) { let pokemon: Pokemon; if (!isNaN(+term)) { pokemon = await this.pokemonModel.findOne({ no: term }); } if (!pokemon && isValidObjectId(term)) { pokemon = await this.pokemonModel.findById(term); } if (!pokemon) { pokemon = await this.pokemonModel.findOne({ name: term.toLowerCase().trim() }); } if (!pokemon) throw new NotFoundException(`Pokemon with id, name or no ${term} not found`); return pokemon; }
- PATCH
async update(term: string, updatePokemonDto: UpdatePokemonDto) { const pokemon = await this.findOne(term); if (updatePokemonDto.name) { updatePokemonDto.name = updatePokemonDto.name.toLowerCase(); } try { await pokemon.updateOne(updatePokemonDto, { new: true }); return {...pokemon.toJSON(), ...updatePokemonDto}; } catch (error) { this.handleExceptions(error); } }
- DELETE
async remove(id: string) { // Common delete // const pokemon = await this.findOne(id); // await pokemon.deleteOne(); const { deletedCount } = await this.pokemonModel.deleteOne({_id: id}); if (deletedCount === 0) { throw new BadRequestException(`Pokemon with id: ${id} not found`); } return true; }
La inyección de la dependencia pokemonModel en pokemon.service permite que este servicio interactúe con la base de datos MongoDB utilizando Mongoose.
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { PokemonService } from './pokemon.service';
import { PokemonController } from './pokemon.controller';
import { Pokemon, PokemonSchema } from './entities/pokemon.entity';
@Module({
imports: [
MongooseModule.forFeature([
{
name: Pokemon.name,
schema: PokemonSchema,
}])
],
controllers: [PokemonController],
providers: [PokemonService],
})
export class PokemonModule {}import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Pokemon } from './entities/pokemon.entity';
@Injectable()
export class PokemonService {
constructor(
@InjectModel(Pokemon.name) private readonly pokemonModel: Model<Pokemon>,
) {}
}-
@InjectModel(Pokemon.name):- Inyecta el modelo registrado en el módulo
PokemonModule. - Usa el nombre
Pokemon.namepara identificar el modelo.
- Inyecta el modelo registrado en el módulo
-
private readonly pokemonModel: Model<Pokemon>:- Declara una propiedad privada en la clase para acceder al modelo inyectado.
-
Uso del modelo:
- Interactúa con la colección de MongoDB para realizar operaciones CRUD, por ejemplo:
async findAll(): Promise<Pokemon[]> {
return this.pokemonModel.find().exec();
}En main.ts se configura lo siguiente:
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
}),
);
app.enableCors();
await app.listen(3000);
}
bootstrap();El archivo pokemon.service.ts contiene la lógica de negocio relacionada con la gestión de Pokémon.
- createPokemon: Crea un nuevo Pokémon en la base de datos.
- findAll: Recupera todos los Pokémon almacenados.
- findOne: Busca un Pokémon específico por su ID.
- update: Actualiza un Pokémon existente.
- remove: Elimina un Pokémon de la base de datos.
async create(createPokemonDto: CreatePokemonDto) {
const pokemon = this.pokemonRepository.create(createPokemonDto);
await this.pokemonRepository.save(pokemon);
return pokemon;
}El archivo pokemon.controller.ts define las rutas HTTP para la gestión de Pokémon.
- POST /pokemon: Crea un nuevo Pokémon.
- GET /pokemon: Recupera todos los Pokémon.
- GET /pokemon/:id: Recupera un Pokémon por ID.
- PATCH /pokemon/:id: Actualiza un Pokémon existente.
- DELETE /pokemon/:id: Elimina un Pokémon por ID.
@Post()
create(@Body() createPokemonDto: CreatePokemonDto) {
return this.pokemonService.create(createPokemonDto);
}Este pipe personalizado valida y transforma IDs de MongoDB recibidos en las rutas.
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { isValidObjectId } from 'mongoose';
@Injectable()
export class ParseMongoIdPipe implements PipeTransform<string> {
transform(value: string) {
if (!isValidObjectId(value)) {
throw new BadRequestException(`${value} is not a valid MongoDB ID`);
}
return value;
}
}@Get(':id')
findOne(@Param('id', ParseMongoIdPipe) id: string) {
return this.pokemonService.findOne(id);
}Para este punto se creó un módulo de seed
seed/
├── interfaces
│ └── poke-response.interface.ts
├── seed.controller.ts
├── seed.module.ts
└── seed.service.ts
Seed solo necesita un solo método y manjear la lógica del mismo en un servicio
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { SeedService } from './seed.service';
@Controller('seed')
export class SeedController {
constructor(private readonly seedService: SeedService) {}
@Get()
executeSeed() {
return this.seedService.executeSeed();
}
}El método executeSeed llama a la api https://pokeapi.co/api/v2/pokemon?limit=20 con los parámetros especificados y utilizaremos esa respuesta para popular la DB. A continuación mostraré tres enfoques distintos para popular la DB.
- En este approach importamos el
createdepokemonServicepara manejar la inserción dentro del bucle de data
async executeSeed() {
await this.pokemonModel.deleteMany({});
try {
const { data } = await lastValueFrom(
this.httpService.get<PokeResponse>('https://pokeapi.co/api/v2/pokemon?limit=20'),
);
const createPromises = data.results.map(async ({ name, url }) => {
const segments = url.split('/');
//[ 'https:', '', 'pokeapi.co', 'api', 'v2', 'pokemon', '5', '' ]
const no = +segments[segments.length - 2];
// el id viene en la penúltima posición de segments
try {
await this.pokemonService.create({ name, no });
} catch (error) {
//Se muestra el error de item duplicado sin cortar la ejecución
console.warn(`Pokemon with name "${name}" or no "${no}" already exists`);
}
});
await Promise.all(createPromises); //Promise.all(createPromises): Ejecuta todas las inserciones en paralelo.
return { message: 'Seed executed successfully' };
} catch (error) {
throw new Error(`Failed to fetch data: ${error.message}`);
}
}- En este approach se maneja la inserción de todos lo items en simultaneo sin importar el servicio y basándonos unicamente en los métodos de inserción que acepta pokemonModel como insertMany en este caso, permitiéndonos tener una mejor perfomance.
async executeSeed() {
//Borramos todos de antemano para evitar duplicados
await this.pokemonModel.deleteMany({});
try {
const { data } = await lastValueFrom(
this.httpService.get<PokeResponse>('https://pokeapi.co/api/v2/pokemon?limit=8'),
);
//Generamos un array de CreatePokemonDto
const pokemonToInsert: CreatePokemonDto[] = [];
data.results.forEach(({ name, url }) => {
const segments = url.split('/');
//[ 'https:', '', 'pokeapi.co', 'api', 'v2', 'pokemon', '5', '' ]
const no = +segments[segments.length - 2];
// el id viene en la penúltima posición de segments
pokemonToInsert.push({name, no});
});
//Insertamos el array en una sola consulta
this.pokemonModel.insertMany(pokemonToInsert);
return { message: 'Seed executed successfully' };
} catch (error) {
throw new Error(`Failed to fetch data: ${error.message}`);
}
}- Este approach es similar al anterior pero con la diferencia de que se maneja el get a través de un custom http adapter, permitiendo tener una aplicación escalable y fácil de mantener cuando se use una librería de terceros, el adapter nos permite wrappear esa implementación para modificarla o actualizarla en un módulo compartido y reutilizable.
async executeSeed() {
//Borramos todos de antemano para evitar duplicados
await this.pokemonModel.deleteMany({});
try {
const data = await this.httpAdapter.get<PokeResponse>('https://pokeapi.co/api/v2/pokemon?limit=400');
//Generamos un array de CreatePokemonDto
const pokemonToInsert: CreatePokemonDto[] = [];
data.results.forEach(({ name, url }) => {
const segments = url.split('/');
//[ 'https:', '', 'pokeapi.co', 'api', 'v2', 'pokemon', '5', '' ]
const no = +segments[segments.length - 2];
// el id viene en la penúltima posición de segments
pokemonToInsert.push({name, no});
});
//Insertamos el array en una sola consulta
this.pokemonModel.insertMany(pokemonToInsert);
return { message: 'Seed executed successfully' };
} catch (error) {
throw new Error(`Failed to fetch data: ${error.message}`);
}
}El seed, como notaran, no require DTO ni entity pero si una interface que ayudará a modelar la respuesta obtenida y tener todo el intellisense para manipularla.
export interface PokeResponse {
count: number;
next: string;
previous: null;
results: Result[];
}
export interface Result {
name: string;
url: string;
}Este sería el seed.service al momento:
import { Injectable } from '@nestjs/common';
import { PokeResponse } from './interfaces/poke-response.interface';
import { CreatePokemonDto } from 'src/pokemon/dto';
import { InjectModel } from '@nestjs/mongoose';
import { Pokemon } from 'src/pokemon/entities/pokemon.entity';
import { Model } from 'mongoose';
import { AxiosAdapter } from 'src/shared/adapters/axios.adapter';
@Injectable()
export class SeedService {
constructor(
@InjectModel(Pokemon.name)
private readonly pokemonModel: Model<Pokemon>,
private readonly httpAdapter: AxiosAdapter
){}
async executeSeed() {
//Borramos todos de antemano para evitar duplicados
await this.pokemonModel.deleteMany({});
try {
const data = await this.httpAdapter.get<PokeResponse>('https://pokeapi.co/api/v2/pokemon?limit=400');
//Generamos un array de CreatePokemonDto
const pokemonToInsert: CreatePokemonDto[] = [];
data.results.forEach(({ name, url }) => {
const segments = url.split('/');
//[ 'https:', '', 'pokeapi.co', 'api', 'v2', 'pokemon', '5', '' ]
const no = +segments[segments.length - 2];
// el id viene en la penúltima posición de segments
pokemonToInsert.push({name, no});
});
//Insertamos el array en una sola consulta
this.pokemonModel.insertMany(pokemonToInsert);
return { message: 'Seed executed successfully' };
} catch (error) {
throw new Error(`Failed to fetch data: ${error.message}`);
}
}
}Algo que cabe destacar es el uso de un adapter personalizado httpAdapter, el cual implementa la lógica para realizar solicitudes a la API de PokeAPI y encapsula la lógica de acceso. Este se encuentra en la ruta shared o common en la carpeta adapters
import { HttpService } from "@nestjs/axios";
import { lastValueFrom } from "rxjs";
import { HttpAdapter } from "../interfaces/http-adapter.interface";
import { Injectable } from "@nestjs/common";
@Injectable()
export class AxiosAdapter implements HttpAdapter {
constructor(private readonly httpService: HttpService) {}
async get<T>(url: string): Promise<T> {
try {
const { data } = await lastValueFrom(
this.httpService.get<T>(url),
);
return data;
} catch (error) {
throw new Error('Unexpected error - Check for logs');
}
}
}import { HttpService } from "@nestjs/axios";
import { lastValueFrom } from "rxjs";
import { HttpAdapter } from "../interfaces/http-adapter.interface";
import { Injectable } from "@nestjs/common";-
HttpService: Proporcionado por@nestjs/axios, es un clienteHTTPbasado enAxiosque permite realizar solicitudesHTTPenNestJS. -
lastValueFrom: Función deRxJSutilizada para convertir unObservableen unaPromesa, ya queHttpServiceretorna unObservable. -
HttpAdapter: Una interfaz personalizada que define las operaciones básicas de un adapterHTTP, asegurando consistencia y reusabilidad en la implementación. -
Injectable: Marca la clase como unproveedoroproviderque puede ser inyectado en otros componentes dentro del sistema deNestJS.
export interface HttpAdapter {
get<T>(url: string): Promise<T>;
}Define un método genérico get que toma una URL como parámetro y devuelve una Promesa con el tipo de dato especificado.
@Injectable()
export class AxiosAdapter implements HttpAdapter {
constructor(private readonly httpService: HttpService) {}
async get<T>(url: string): Promise<T> {
try {
const { data } = await lastValueFrom(
this.httpService.get<T>(url),
);
return data;
} catch (error) {
throw new Error('Unexpected error - Check for logs');
}
}
}-
@Injectable(): Permite que la clase sea inyectada como dependencia en otros servicios o módulos. -
Constructor: Inyecta HttpService, lo que permite que la clase use Axios a través del cliente HTTP de NestJS. -
Método
get<T>(url: string): Promise<T>: Implementa el método definido en la interfaz HttpAdapter.- Flujo del método: Realiza una solicitud
GETutilizandoHttpService.- Convierte el
Observableretornado porHttpService.geten unaPromesaconlastValueFrom. - Extrae la propiedad
datadel objeto de respuesta deAxios. - Maneja errores con un bloque
try-catchy lanza unaexcepcióncon un mensaje personalizado en caso de fallo.
- Convierte el
- Flujo del método: Realiza una solicitud
-
Manejo de Errores:
- Si ocurre algún error en la solicitud
HTTP, el bloquecatchcaptura la excepción y lanza un error genérico con el mensaje'Unexpected error - Check for logs'.
- Si ocurre algún error en la solicitud
-
Entrada: Una URL para realizar una solicitud GET.
-
Proceso: Usa
HttpService.getpara realizar la solicitud. -
Convierte el Observable en una Promesa para simplificar su uso.
-
Extrae y retorna los datos del cuerpo de la respuesta
data. -
Salida: Un objeto del tipo genérico
<T>con los datos obtenidos de la respuesta.
Puedes inyectar este adapter en un servicio para realizar solicitudes HTTP de manera modular y desacoplada
import { Injectable } from "@nestjs/common";
import { AxiosAdapter } from "./adapters/axios.adapter";
@Injectable()
export class SomeService {
constructor(private readonly httpAdapter: AxiosAdapter) {}
async fetchData() {
const data = await this.httpAdapter.get('https://api.example.com/resource');
console.log(data);
return data;
}
}- Desacoplamiento: La lógica de interacción con APIs externas se encapsula en el adapter, manteniendo el servicio limpio y enfocado en la lógica de negocio.
- Reusabilidad: Otros servicios pueden reutilizar el adapter para realizar solicitudes HTTP sin replicar lógica.
- Facilidad de Pruebas: Puedes simular la funcionalidad del adapter en pruebas unitarias para servicios que lo consumen.
- Interoperabilidad: Si decides cambiar la implementación de Axios por otra librería (por ejemplo, fetch o got), solo necesitas modificar el adapter, sin afectar los servicios que lo consumen.
Para agregar la funcionalidad de paginado tomaremos el método getAll de pokemon.controller y le pasaremos los parámetros limit y offset. que a su vez recibirá findAll de pokemon.service
@Get()
findAll(@Query() paginationDto: PaginationDto) {
return this.pokemonService.findAll(paginationDto);
}async findAll(paginationDto: PaginationDto): Promise<Pokemon[]> {
const { limit = 10, offset = 0 } = paginationDto;
const pokemon = await this.pokemonModel.find()
.limit(limit)
.skip(offset);
return pokemon;
}Para ello creamos un DTO al cual llamamos PaginationDto de forma de que podamos agregar las validaciones necesarias para cada parámetro.
import { IsNumber, IsOptional, IsPositive, Min } from "class-validator";
export class PaginationDto {
@IsOptional()
@IsPositive()
@IsNumber()
@Min(1)
limit?: number;
@IsOptional()
@IsPositive()
@IsNumber()
offset?: number;
}Acá va a pasar algo muy interesante, en este momento todo parece estar bien pero olvidamos que los query parameters son recibidos como un string por ende, es necesario transformar esos valores al formato deseado. Para ello iremos al main.ts donde agregamos nuestras configuraciones y agregaremos lo siguiente:
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
}
}));La opción transform en el ValidationPipe de NestJS activa la transformación automática de los datos entrantes al tipo especificado en los DTO (Data Transfer Objects). Esto significa que NestJS intentará convertir los datos recibidos en el formato o tipo de datos definido en los decoradores de tu clase.
Cuando transform no está habilitado, los datos enviados al controlador (generalmente en el cuerpo de la solicitud) se reciben como objetos literales de tipo any. Esto significa que si tienes un DTO como este:
export class CreatePokemonDto {
@IsString()
name: string;
@IsInt()
no: number;
}Y envías un cuerpo de solicitud como esta:
{
"name": "Pikachu",
"no": "25"
}El valor de no se recibirá como una cadena (string), ya que NestJS no realiza automáticamente la conversión a número (number).
Cuando habilitas transform, NestJS convierte automáticamente los datos en el tipo definido en el DTO. Usando el mismo ejemplo, si envías:
{
"name": "Pikachu",
"no": "25"
}NestJS transforma el valor de no de string a number utilizando las reglas definidas en tu DTO (@IsInt() en este caso).
Al habilitar esta opción, NestJS realiza conversiones implícitas basándose en el tipo de las propiedades del DTO, incluso si no usas decoradores como @Type() de la librería class-transformer.
export class CreatePokemonDto {
name: string;
no: number; // Implicitamente espera un número
}Si envías:
{
"name": "Bulbasaur",
"no": "1"
}NestJS transformará automáticamente "1" en el número 1 porque el tipo de no en el DTO es number.
Sin esta opción, necesitarías usar el decorador @Type() de la librería class-transformer para definir explícitamente cómo transformar los datos.
import { Type } from 'class-transformer';
export class CreatePokemonDto {
name: string;
@Type(() => Number) // Convierte explícitamente el valor a un número
no: number;
}Este es el ejemplo que tienes con una descripción de cada opción:
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Elimina las propiedades que no están definidas en el DTO
forbidNonWhitelisted: true, // Rechaza solicitudes con propiedades no definidas en el DTO
transform: true, // Activa la transformación de datos a los tipos definidos en el DTO
transformOptions: {
enableImplicitConversion: true, // Permite conversiones basadas en los tipos del DTO
}
}));- Simplifica el Código: Ya no necesitas convertir manualmente los tipos de datos en tu controlador o servicio.
- Validación Más Eficiente: Al transformar los datos, la validación (@IsInt(), @IsString(), etc.) funciona correctamente, ya que los datos ya están en el tipo esperado.
- Reduce Errores: Minimiza errores relacionados con tipos de datos inconsistentes, especialmente al interactuar con servicios o bases de datos.
- Compatibilidad con Librerías: La integración con class-transformer facilita la manipulación avanzada de datos si necesitas algo más allá de la conversión básica.
Crearemos un .env file en el root del proyecto. Una buena práctica es crear un .env.template con la estructura que tendrá el original y agregar a los archivos ignore el .env para no exponer claves o puertos.
MONGODB=mongodb://localhost:[DATABASE_PORT_NUMBER]/your-database
PORT=1515$ npm i @nestjs/configIr al app.module.ts y agregar
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule.forRoot()],})
export class AppModule {}En el siguiente ejemplo si agregó el ConfigModule al principio, garantizando la carga de las variables de entorno (process.env.MONGODB) antes de su lectura.
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { PokemonModule } from './pokemon/pokemon.module';
import { MongooseModule } from '@nestjs/mongoose';
import { SharedModule } from './shared/shared.module';
import { SeedModule } from './seed/seed.module';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot(), // Carga
ServeStaticModule.forRoot({
rootPath: join(__dirname,'..','public'),
}),
MongooseModule.forRoot(process.env.MONGODB), // Lectura
PokemonModule,
SharedModule,
SeedModule
],
controllers: [],
providers: [],
})
export class AppModule {}En teoría podemos accesar nuestras variables de entorno en toda nuestra app por medio de process.env pero @nestjs/config nos ofrece un servicio para manejar de nuestras variables de entorno propiamente y asegurarnos de que esten cargadas o asignar un valor por defecto.
lo primero es crear un archivo en la carpeta shared o common un archivo llamado env.config.ts o app.config.ts queda a su discresión.
shared/
└── config
└── env.config.ts
Este exporta una función con la configuración de nuestras variables de entorno, los valores son leídos de las variables de entorno (process.env), o se asigna un valor por defecto si estas no están definidas.
export const EnvConfiguration = () => ({
environment: process.env.NODE_ENV || 'dev',
mongodb: process.env.MONGO_DB,
port: process.env.PORT || 3002,
defaultLimit: process.env.DEFAULT_LIMIT || 7
});environment: Indica el entorno en el que se ejecuta la aplicación, comodev,prod,test, etc. Valor por defecto:dev.mongodb: Dirección o URI de conexión a la base de datos MongoDB. Nota: Debes asegurarte de que la variableMONGO_DBesté configurada en las variables de entorno.port: Especifica el puerto en el que la aplicación escucha. Valor por defecto:3002.defaultLimit: Define un límite por defecto, que podría ser utilizado para paginación o restricciones de API. Valor por defecto: 7.
Para poder utilizarla necesitaremos importar esta función dentro del ConfigurationModule del app.module
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { PokemonModule } from './pokemon/pokemon.module';
import { MongooseModule } from '@nestjs/mongoose';
import { SharedModule } from './shared/shared.module';
import { SeedModule } from './seed/seed.module';
import { ConfigModule } from '@nestjs/config';
import { EnvConfiguration } from './shared/config/env.config';
@Module({
imports: [
ConfigModule.forRoot({
load: [EnvConfiguration]
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname,'..','public'),
}),
MongooseModule.forRoot(process.env.MONGODB),
PokemonModule,
SharedModule,
SeedModule
],
controllers: [],
providers: [],
})
export class AppModule {}Importamos ConfigModule en el módulo donde está ubicado el servicio:
@Module({
imports: [
ConfigModule,
MongooseModule.forFeature([
{
name: Pokemon.name,
schema: PokemonSchema,
}])
],
controllers: [PokemonController],
providers: [PokemonService],
exports: [PokemonService, MongooseModule],
})
export class PokemonModule {}Para luego para usarla en cualquier servicio debemos inyectar el servicio en su constructor
// Imports
@Injectable()
export class PokemonService {
private readonly defaultLimit!: number;
constructor(private readonly configService:ConfigService){
this.defaultLimit = configService.get<number>('defaultLimit');
}
async findAll(paginationDto: PaginationDto): Promise<Pokemon[]> {
const { limit = this.defaultLimit, offset = 0 } = paginationDto;
const pokemon = await this.pokemonModel.find()
.limit(limit)
.skip(offset);
return pokemon;
}
//... Resto del código
}Especificando el tipo en nuesta variable y en el servicio nos aseguramos de que lo transforme en el tipo correcto al momento de recibir su valor.
export const EnvConfiguration = () => ({
environment: process.env.NODE_ENV || 'dev',
mongodb: process.env.MONGO_DB,
port: process.env.PORT || 3002,
defaultLimit: process.env.DEFAULT_LIMIT || 7
});cabe destacar que EnvConfiguration solo funciona en los módulos de nest, tanto en app.module como en main.ts debemos acceder a las variables de entorno globales mediante el uso de proces.env.VARIABLE
#MONGODB=mongodb://localhost:[DATABASE_PORT_NUMBER]/your-database
PORT=1515
@Module({
imports: [
ConfigModule.forRoot({
load: [EnvConfiguration]
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname,'..','public'),
}),
MongooseModule.forRoot(process.env.MONGODB),
PokemonModule,
SharedModule,
SeedModule
],
controllers: [],
providers: [],
})
export class AppModule {}Actualmente tenemos valores por defecto si nos olvidamos de asignar o crear nuestras variables de entorno, este no es el caso de mongodb, ya que si no la asignamos tendremos el siguiente error por defecto:
ERROR [MongooseModule] Unable to connect to the database. Retrying (1)...
MongooseError: The `uri` parameter to `openUri()` must be a string, got "undefined". Make sure the first parameter to `mongoose.connect()` or `mongoose.createConnection()` is a string.Podemos asignar una valor por defecto pero no sería la idea, en este caso tendremos que manejar ese error de otra forma para que la persona que levante el proyecto sepa que le faltó agregar la variable de entorno con la conexión a la DB se una forma más intuitiva.
Ahora haremos una validación utilizando el paquete joi mediante:
$ yarn add joi
$ npm i joiEl paquete joi es una biblioteca de validación para JavaScript que permite definir esquemas para validar objetos, cadenas, números, fechas, matrices y otros tipos de datos. Es ampliamente utilizado en aplicaciones Node.js para asegurarse de que los datos cumplen con ciertos requisitos antes de procesarlos.
- Definición de Esquemas: Permite crear esquemas que describen cómo deben lucir los datos válidos.
- Validación de Datos: Valida datos como objetos JSON, formularios de entrada, variables de entorno, etc.
- Mensajes Personalizados: Proporciona mensajes de error claros y personalizables cuando los datos no cumplen con los requisitos.
- Extensibilidad: Soporta la creación de reglas personalizadas para casos específicos.
Después de instalarlo, en la ruta donde agregamos nuesto env.config agregamos un archivo llamado: joi.valdiation.ts:
shared/
└── config
├── env.config.ts
└── joi.valdiation.ts <--
import * as Joi from 'joi';
export const JoiValidationSchema = Joi.object({
MONGODB: Joi.required(),
PORT: Joi.number().default(3000),
DEFAULT_LIMIT: Joi.number().default(5),
});Luego lo importamos en el app.module:
//...Resto de los imports
import { JoiValidationSchema } from './shared/config/joi.validation';
@Module({
imports: [
ConfigModule.forRoot({
load: [EnvConfiguration],
validationSchema: JoiValidationSchema
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname,'..','public'),
}),
MongooseModule.forRoot(process.env.MONGODB),
PokemonModule,
SharedModule,
SeedModule
],
controllers: [],
providers: [],
})
export class AppModule {}En este caso, el uso de EnvConfiguration y JoiValidationSchema puede parecer redundante, sin embargo, es bueno saber que pueden trabajar en conjunto, ya que JoiValidationSchema si asignamos un valor por defecto a una variable de entorno, y esta no existe, joi la va a crear por nosotros y le asignará dicho valor. Ej:
Supongamos que nuestros archivos lucen así:
// EnvConfiguration:
export const EnvConfiguration = () => ({
environment: process.env.NODE_ENV || 'dev',
mongodb: process.env.MONGO_DB,
port: process.env.PORT || 3002,
defaultLimit: process.env.DEFAULT_LIMIT || 7
});
// JoiValidationSchema:
export const JoiValidationSchema = Joi.object({
MONGODB: Joi.required(),
PORT: Joi.number().default(3000),
DEFAULT_LIMIT: Joi.number().default(6),
});
// .env:
MONGODB=mongodb://localhost:[DATABASE_PORT_NUMBER]/your-database
PORT=1515Como notarán no existe DEFAULT_LIMIT. En este caso el defaultLimit será 6 ¿Por qué?
Al no existir DEFAULT_LIMIT, Joi lo crea y le asigna el valor por defecto, de manera en que al momento en que la propiedad defaultLimit de EnvConfiguration busque process.env.DEFAULT_LIMIT este ya habrá sido creado por Joi pero como string, ya que las variables de entorno siempre se crean por defecto como string y el Joi.number() es una validación, no un mapeo. si queremos mapear ese valor, entonces debemos hacerlo en el EnvConfiguration:
export const EnvConfiguration = () => ({
environment: process.env.NODE_ENV || 'dev',
mongodb: process.env.MONGO_DB,
port: process.env.PORT || 3002,
defaultLimit: +process.env.DEFAULT_LIMIT || 7
});Railway es una plataforma que permite configurar servicios como MongoDB de forma gratuita (hasta cierto límite). Sigue estos pasos para configurar tu base de datos y conectarla a tu aplicación NestJS.
- Ve a Railway.app y regístrate con tu cuenta de GitHub o correo electrónico.
- Una vez dentro, haz clic en el botón "Start a New Project".
- En tu nuevo proyecto, selecciona la opción "Add Plugin".
- Busca y selecciona MongoDB.
- Railway configurará automáticamente una instancia de MongoDB para ti. Una vez lista, verás el mensaje "Plugin added successfully".
- Haz clic en el plugin MongoDB que acabas de crear.
- En la sección "Variables", copia la URI de conexión, que se encuentra en
MONGO_PUBLIC_URL
- Agrega la URI de conexión en tu archivo
.env:
MONGODB=mongodb://mongo:[email protected]:55555- Configura el módulo de Mongoose en tu archivo
AppModule:
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { PokemonModule } from './pokemon/pokemon.module';
import { MongooseModule } from '@nestjs/mongoose';
import { SharedModule } from './shared/shared.module';
import { SeedModule } from './seed/seed.module';
import { ConfigModule } from '@nestjs/config';
import { EnvConfiguration } from './shared/config/env.config';
import { JoiValidationSchema } from './shared/config/joi.validation';
@Module({
imports: [
ConfigModule.forRoot({
load: [EnvConfiguration],
validationSchema: JoiValidationSchema
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname,'..','public'),
}),
MongooseModule.forRoot(process.env.MONGODB, {
dbName: process.env.DBNAME
}),
PokemonModule,
SharedModule,
SeedModule
],
controllers: [],
providers: [],
})
export class AppModule {}dbName: process.env.DBNAME Apunta a DBNAME=yourdatabasename y agrega ese nombre a la db en creada en railway
- Levantar la aplicación de nuevo con
yarn start:dev - Correr el seed
http://localhost:3000/api/v2/seed/
Asegúrate de que tu aplicación puede conectarse a la base de datos correctamente:
- Corre tu aplicación y revisa los logs para confirmar que se ha conectado a MongoDB sin problemas.
- También puedes usar herramientas como
MongoDB CompassoTablePluspara conectarte a la base de datos y verificar que los datos se hayan insertado correctamente.
De la misma manera en que creamos una conexión con nuestra db en railway, vamos a desplegar nuestra aplicación, en el proyecto de nuestro dashboard donde generamos la conexión vamos a hacer click en el botón create y seleccionaremos la opción GitHub Repo, para ello debemos darle acceso a railway a uno o todos los repos, lo seleccionamos y posteriormente le asignaremos valores a las variables de entorno. Si el deploy no se hizo automáticamente, siempre podemos hacerlo manualmente.
Haciendo click en el deploy de nuestro repo podremos acceder a la pestaña Deployments y veremos la URL de nuestro proyecto desplegado. https://nest-pokedex-production-ce33.up.railway.app/
- crear un archivo
docker-compose.prod.yml. Podemos usar como template el original y finalmente obtendríamos ek siguiente resultado:
version: '3'
services:
pokedexapp:
depends_on:
- db
build:
context: .
dockerfile: Dockerfile
image: pokedex-docker
container_name: pokedexapp
restart: always # reiniciar el contenedor si se detiene
ports:
- "${PORT}:${PORT}"
environment:
MONGODB: ${MONGODB}
PORT: ${PORT}
DEFAULT_LIMIT: ${DEFAULT_LIMIT}
db:
image: mongo:5
container_name: mongo-poke
restart: always
ports:
- 27017:27017
environment:
MONGODB_DATABASE: nest-pokemon
volumes:
- ./mongo:/data/dbSe crean 2 servicios, pokedex y db, el segundo crea una base de datos a partir de una imagen de mongoDB:5 mapeando el puerto de nuestro filesystem con el de la imagen y le asigna un valor a la variable de entorno MONGODB_DATABASE y le asignamos un volumen para que los cambios persistan. El primer servicio no se va a iniciar hasta que db esté funcionando, ya que depends_on se encarga de ello, el contexto de nuestro build especificado por un punto context: . indica que está en la ubicación del docker-compose y seguidamente especificamos el nombre del Dockerfile a cargo de generar el build. Cómo esta es la imagen de la app, vamos a especificar las variables de entorno en la cración de este contenedor también, y tomará los valores que se encuentren en el archivo .env especificado.
- Generar el Dockerfile
# Instalar dependencias solo cuando sea necesario
FROM node:21-alpine3.18 AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Construir la aplicación reutilizando las dependencias en caché
FROM node:21-alpine3.18 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn build
# Imagen de producción, copia todos los archivos y ejecuta la aplicación
FROM node:21-alpine3.18 AS runner
# Establecer el directorio de trabajo
WORKDIR /usr/src/app
# Copiar archivos de configuración de dependencias
COPY package.json yarn.lock ./
# Instalar solo las dependencias necesarias para producción
RUN yarn install --prod
# Copiar los archivos compilados desde la etapa de construcción
COPY --from=builder /app/dist ./dist
# Comando por defecto para ejecutar la aplicación
CMD [ "node","dist/main" ]-
Primera etapa:
deps- Usa una imagen base ligera de Node.js.
- Instala dependencias del sistema necesarias (
libc6-compat). - Copia los archivos
package.jsonyyarn.lock. - Ejecuta
yarn install --frozen-lockfilepara instalar las dependencias garantizando versiones consistentes.
-
Segunda etapa:
builder- Prepara el entorno para construir la aplicación.
- Reutiliza las dependencias de la etapa
deps(caché). - Copia todo el código fuente de la aplicación.
- Construye la aplicación con
yarn build, generando los archivos finales en el directoriodist.
-
Tercera etapa:
runner- Prepara una imagen de producción ligera.
- Establece el directorio de trabajo en
/usr/src/app. - Instala solo las dependencias necesarias para producción (
yarn install --prod). - Copia los archivos compilados desde la etapa
builder. - Define el comando por defecto para ejecutar la aplicación (
node dist/main).
- Uso eficiente de caché: Las dependencias se instalan una sola vez y se reutilizan.
- Optimización: La imagen final solo incluye los artefactos necesarios para producción, reduciendo el tamaño de la imagen.
- Separación de etapas: Facilita el mantenimiento, la organización y la depuración del Dockerfile.
docker-compose -f docker-compose.prod.yml --env-file .env.prod up --build
docker-compose -f docker-compose.prod.yml --env-file .env.prod up
docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d
- La opción
-fen el comando dedocker-composese utiliza para especificar un archivo de configuración diferente al predeterminado(docker-compose.yml). --env-fileespecifiva un.envdistinto, en este caso.env.prod.