Prérequis :

Installer Nest CLI :

npm install -g @nestjs/cli

Vérifier le bon fonctionnement :

nest -v

Récupérer le code du projet :

git clone https://github.com/chawax/nestjs-codelab.git

Installer les dépendances npm :

cd nestjs-codelab
npm i

Pour commencer, nous allons créer une première API très simple et exposer sa documentation via Swagger.

La première route de notre API consiste à donner l'état de santé de notre application.

Créons le module health :

nest g module health

Créons le controller health :

nest g controller health

Dans le fichier src\health\health.controller.ts du controller, rajoutons la méthode check qui répondra aux requêtes de type GET :

@Get()
check(): string {
    return 'Everything is OK';
}

La complétion dans l'IDE nous aide à rajouter les imports de librairies nécessaires, mais au cas où voici les imports à ajouter :

import { Controller, Get } from '@nestjs/common';

Vérifions que tout fonctionne en lançant l'application en mode développement (live reload) :

npm run start:dev

La route health est accessible dans un navigateur via http://localhost:3000/health.

Nous allons maitenant activer le versionning d'API et la documentation Swagger.

Dans src\main.ts, rajoutons la configuration nécessaire dans la fonction bootstrap. Attention : l'instruction app.listen doit rester la dernière instruction de la fonction.

  // VERSIONNING
  app.enableVersioning({
    type: VersioningType.URI,
  });

  //-------- SWAGGER
  const config = new DocumentBuilder()
    .setTitle('Form Earth to Moon API')
    .setDescription('A codelab to discover NestJs and more')
    .setVersion('1.0')
    .build();

  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);

Les imports à utiliser sont les suivants :

import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

Modifions HealthController pour rajouter un numéro de version sur son API :

@Controller({ path: '/health', version: '1' })

L'URL de la route health est maintenant http://localhost:3000/v1/health.

L'interface Swagger est accessible via http://localhost:3000/api. Nous pouvons y tester notre API.

Nous allons maintenant créer une API pour la gestion de planètes. Pour ce faire, nous allons créer des classes controller, service, DTO et entity et placer le tout dans un module dédié.

Tout cela peut être préparé avec la commande suivante :

nest g resource planet

Choisissons REST API dans la liste proposée de couches de transport. Nous activons également la génération des points d'entrée CRUD.

Dans la classe entity src\planet\entities\planet.entity.ts, ajoutons les propriétés d'une planète :

name: string;

distanceToEarth: number;

Dans la class service src\planet\planet.service.ts, modifions la méthode findAll() pour renvoyer des données en dur :

findAll(): Planet[] {
    const planetsJSON = [
      {
        name: 'Lune',
        distanceToEarth: 384400,
      },
      {
        name: 'Venus',
        distanceToEarth: 41400000,
      },
      {
        name: 'Mars',
        distanceToEarth: 78340000,
      },
      {
        name: 'Mercure',
        distanceToEarth: 91690000,
      },
      {
        name: 'Jupiter',
        distanceToEarth: 628730000,
      },
      {
        name: 'Saturne',
        distanceToEarth: 1275000000,
      },
      {
        name: 'Uranus',
        distanceToEarth: 2723950000,
      },
      {
        name: 'Neptune',
        distanceToEarth: 4351400000,
      },
    ];

    const planets: Planet[] = Object.assign(new Array<Planet>(), planetsJSON);

    return planets;
  }

Nous pouvons tester la route qui liste toutes les planètes via Swagger ou via son URL : http://localhost:3000/planet.

Faisons maintenant de même pour la ressource starship :

nest g resource starship

Ajoutons les proprietés d'un starship :

name: string;

speed: number;

kilometerPrice: number;

Modifions la méthode findAll() de l'entité starship pour renvoyer des données en dur :

findAll(): Starship[] {
    const starshipsJSON = [
      {
        name: 'Apollo',
        speed: 39000,
        kilometerPrice: 10000,
      },
      {
        name: 'SpaceX Starship',
        speed: 27000,
        kilometerPrice: 250000,
      },
      {
        name: 'Sonde Parker',
        speed: 532000,
        kilometerPrice: 50000,
      },
    ];

    const starships: Starship[] = Object.assign(new Array<Starship>(), starshipsJSON);

    return starships;
  }

De même que pour planet, la route qui liste tous les vaisseaux peut être testée via Swagger ou via son URL : http://localhost:3000/starship.

Nous allons maintenant activer l'ORM TypeORM pour lire des données dans une base de données SQLite. Une base SQLite contenant déjà des données est incluse dans le repository cloné.

Sur les classes entity, nous rajoutons l'annotation @Entity() pour indiquer à TypeORM de faire le mapping avec une table de la base de données.

Pour planet :

@Entity({ name: 'planet' })

Pour starship :

@Entity({ name: 'starship' })

Puis sur chaque propriété de ces 2 classes, nous rajoutons l'annotation @Column() pour faire le mapping avec les colonnes des tables concernées. Ex :

@Column()
name: string;

Les imports dans ces entités sont les suivants :

import { Column, Entity } from 'typeorm';

Créons ensuite, dans src\utils\default-entity.ts, la classe DefaultEntity qui contient les propriétés communes à toutes les entités de notre application, à savoir :

Le code de la classe DefaultEntity est le suivant :

// src/utils/default-entity.ts

import { Exclude } from "class-transformer";
import { Column, Generated, PrimaryGeneratedColumn } from "typeorm";

export class DefaultEntity {
    @Exclude()
    @PrimaryGeneratedColumn('identity')
    id: number;
    
    @Column()
    active: boolean;

    @Column({ unique: true })
    @Generated("uuid")
    uuid: string;    
}

Enfin, faisons hériter Planet et Starship de DefaultEntity :

export class Planet extends DefaultEntity {
export class Starship extends DefaultEntity {

Maintenant, créons un fichier .env à la racine du projet et ajoutons y la référence à la base de données à laquelle nous souhaitons accéder :

SQL_MEMORY_DB_SHARED=./db/planet-starship.sqlite

Dans src\app.module.ts, dans la section imports, ajoutons le chargement du module TypeORM et de la base de données indiquée dans le fichier de configuration :

  imports: [
    HealthModule,
    ConfigModule.forRoot(),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => {
        return {
          type: 'sqlite',
          database: configService.get('SQL_MEMORY_DB_SHARED'),
          entities: [__dirname + '/**/*.entity{.ts,.js}'],
          synchronize: true,
        } as TypeOrmModuleOptions;
      },
    }),
    PlanetModule,
    StarshipModule,
  ],

Avec les imports suivants :

import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';

Dans src\planet\planet.module.ts, ajoutons l'import de TypeORM pour l'entité Planet et l'export du service PlanetService :

  imports: [TypeOrmModule.forFeature([Planet])],
  exports: [PlanetService],

Faisons de même pour le module starship dans src\starship\starship.module.ts :

  imports: [TypeOrmModule.forFeature([Starship])],
  exports: [StarshipService],

Dans le service PlanetService, ajoutons un constructeur qui injecte le repository Planet :

  constructor(
    @InjectRepository(Planet)
    private readonly planetRepository: Repository<Planet>,
  ) {}

Avec les imports suivants :

import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm/repository/Repository';

Puis modifions la méthode findAll() pour utiliser le repository qui va exécuter la requête de récupération des objets Planet sur la base de données. La signature de la méthode est modifiée pour renvoyer un objet Promise :

  findAll(): Promise<Planet[]> {
    return this.planetRepository.find();
  }

Faisons de même pour le service StarshipService :

  constructor(
    @InjectRepository(Starship)
    private readonly starshipRepository: Repository<Starship>,
  ) {}
  findAll(): Promise<Starship[]> {
    return this.starshipRepository.find();
  }

Les données planet et starship sont maintenant récupérées depuis la base de données. On peut le tester avec http://localhost:3000/planet et http://localhost:3000/starship.

Nous allons maintenant rajouter les opérations de récupération unitaire et d'écriture en base de données.

Modifions src\planet\dto\create-planet.dto.ts pour rajouter les propriétés utiles à la création d'une planète. On y ajoute des annotations utiles à l'exposition Swagger et à la validation des données :

  @ApiProperty()
  @Expose()
  @IsString()
  name: string;

  @ApiProperty()
  @Expose()
  @IsNumber()
  distanceToEarth: number;

  @ApiProperty()
  @Expose()
  @IsBoolean()
  active: boolean;

Avec les imports suivants :

import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
import { IsBoolean, IsNumber, IsString } from 'class-validator';

UpdatePlanetDto hérite partiellement de CreatePlanetDto en rajoutant les propriétés nécessaires à la mise à jour :

  @ApiProperty()
  @Expose()
  @IsNotEmpty()
  @IsUUID()
  @IsOptional()
  uuid: string;

Avec les imports suivants :

import { ApiProperty, PartialType } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
import { IsNotEmpty, IsUUID, IsOptional } from 'class-validator';

Procédons de même pour CreateStarshipDto :

  @ApiProperty()
  @Expose()
  @IsString()
  name: string;

  @ApiProperty()
  @Expose()
  @IsNumber()
  speed: number;
  
  @ApiProperty()
  @Expose()
  @IsNumber()
  kilometerPrice: number;

  @ApiProperty()
  @Expose()
  @IsBoolean()
  active: boolean;

Et pour UpdateStarshipDto :

  @ApiProperty()
  @Expose()
  @IsNotEmpty()
  @IsUUID()
  @IsOptional()
  uuid: string;

Pour que les annotations soient actives, nous devons ajouter la configuration suivante dans la méthode bootstrap de src\main.ts :

  //------- IN & OUT
  // Enables global behaviors on incoming DTO
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // Only exposed attributes will be accepted on incoming DTO
      transform: true, // Automatically converts attributes from incoming DTO when possible
      transformOptions: { enableImplicitConversion: true },
    }),
  );

  // Enables global behaviors on outgoing entities
  // For examples, @Exclude decorators will be processed
  app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

Avec les imports suivants :

import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';

Modifions PlanetService pour utiliser les DTO et le repository.

Commençons par la méthode create(). Le type de retour est modifié pour correspondre au type de retour de la méthode save() du repository :

  create(createPlanetDto: CreatePlanetDto): Promise<Planet> {
    return this.planetRepository.save(createPlanetDto);
  }

Remplaçons la méthode findOne() pour une méthode findOneByUuid() qui recherche une planète en fonction de son UUID :

  findOneByUuid(uuid: string): Promise<Planet | null> {
    return this.planetRepository.findOneBy({ uuid });
  }

La méthode update() est modifiée pour prendre en entrée un UUID plutôt qu'un id. Elle renvoie un objet de type Promise :

  async update(uuid: string, updatePlanetDto: UpdatePlanetDto): Promise<Planet> {
    const planet = await this.findOneByUuid(uuid);

    if (!planet) {
      throw new NotFoundException();
    }

    await this.planetRepository.save({ id: planet.id, ...updatePlanetDto });

    return this.findOneByUuid(uuid);
  }

Idem pour la méthode remove() :

  async remove(uuid: string): Promise<DeleteResult> {
    const planet = await this.findOneByUuid(uuid);

    if (!planet) {
      throw new NotFoundException();
    }

    return this.planetRepository.delete({ uuid });
  }

Avec les imports suivants :

import { NotFoundException } from '@nestjs/common';
import { DeleteResult } from 'typeorm';

La classe PlanetController doit être modifiée pour prendre en compte nos modifications sur PlanetService et pour utiliser des UUID :

  @Get(':uuid')
  async findOne(@Param('uuid', new ParseUUIDPipe()) uuid: string): Promise<Planet> {
    const planet = await this.planetService.findOneByUuid(uuid);

    if (planet) {
      return planet;
    }

    throw new NotFoundException();
  }
  @Patch(':uuid')
  update(@Param('uuid', new ParseUUIDPipe()) uuid: string, @Body() updatePlanetDto: UpdatePlanetDto): Promise<Planet> {
    return this.planetService.update(uuid, updatePlanetDto);
  }
  @Delete(':uuid')
  remove(@Param('uuid', new ParseUUIDPipe()) uuid: string): Promise<DeleteResult> {
    return this.planetService.remove(uuid);
  }

Enfin modifions les annotations de la classe pour versionner l'API et améliorer la lisibilité dans Swagger :

  @ApiTags('planets')
  @Controller({ path: '/planets', version: '1' })
  export class PlanetController {

Les imports à utiliser sont les suivants :

import { Body, ParseUUIDPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';

Procédons de même pour StarshipService :

  create(createStarshipDto: CreateStarshipDto): Promise<Starship> {
    return this.starshipRepository.save(createStarshipDto);
  }
  findOneByUuid(uuid: string): Promise<Starship | null> {
    return this.starshipRepository.findOneBy({ uuid });
  }
  async update(uuid: string, updateStarshipDto: UpdateStarshipDto): Promise<Starship> {
    const starship = await this.findOneByUuid(uuid);

    if (!starship) {
      throw new NotFoundException();
    }

    await this.starshipRepository.save({ id: starship.id, ...updateStarshipDto });

    return this.findOneByUuid(uuid);
  }
  async remove(uuid: string): Promise<DeleteResult> {
    const starship = await this.findOneByUuid(uuid);

    if (!starship) {
      throw new NotFoundException();
    }

    return this.starshipRepository.delete({ uuid });
  }

Et pour StarshipController :

  @Get(':uuid')
  findOne(@Param('uuid', new ParseUUIDPipe()) uuid: string): Promise<Starship> {
    return this.starshipService.findOneByUuid(uuid);
  }
  @Patch(':uuid')
  update(
    @Param('uuid', new ParseUUIDPipe()) uuid: string,
    @Body() updateStarshipDto: UpdateStarshipDto,
  ): Promise<Starship> {
    return this.starshipService.update(uuid, updateStarshipDto);
  }
  @Delete(':uuid')
  remove(@Param('uuid', new ParseUUIDPipe()) uuid: string): Promise<DeleteResult> {
    return this.starshipService.remove(uuid);
  }
@ApiTags('starships')
@Controller({ path: '/starships', version: '1' })
export class StarshipController {

Nous pouvons maintenant utiliser toutes les opérations CRUD sur Planet et Starship via Swagger.

Créons des planètes via la route POST /v1/planets :

{
  "name": "Mercure",
  "distanceToEarth": 91690000,
  "active": true
}
{
  "name": "Jupiter",
  "distanceToEarth": 628730000,
  "active": true
}
{
  "name": "Saturne",
  "distanceToEarth": 1275000000,
  "active": true
}
{
  "name": "Uranus",
  "distanceToEarth": 2723950000,
  "active": true
}
{
  "name": "Neptune",
  "distanceToEarth": 4351400000,
  "active": true
}

Créons un starship via la route POST /v1/starships :

{
  "name": "SpaceX Starship",
  "speed": 27000,
  "kilometerPrice": 250000,
  "active": true
}

Dans cette étape nous allons créer une nouvelle ressource : booking :

nest g resource booking

Dans BookingModule, ajoutons l'import des modules nécessaires :

imports: [PlanetModule, StarshipModule, TypeOrmModule.forFeature([Booking])],

Modifions l'entité Booking pour qu'elle hérite de DefaultEntity et déclarons la comme une entité avec mapping vers la table booking :

@Entity({ name: 'booking' })
export class Booking extends DefaultEntity {

Et ajoutons le mapping pour les propriétés suivantes :

@ManyToOne(() => Planet)
destination: Planet;

@ManyToOne(() => Starship)
starship: Starship;

@Column()
traveller: string;

@Column()
departureDate: Date;

arrivalDate: Date;

price: number;

Le décorateur @ManyToOne permet de créer un lien entre l'entité Booking et les entités Planet et Starship. Notons que les propriétés arrivalDate et price ne correspondent pas à des colonnes mais seront calculées (voir plus bas).

Ajoutons également les méthodes processTravelTime() et processPrice(). Elles sont appelées après le chargement d'une entité Booking et permettent d'alimenter les propriétés arrivalDate et price.

  @AfterLoad()
  processTravelTime() {
    if (this.destination?.distanceToEarth && this.starship?.speed) {
      const travelTime = this.destination.distanceToEarth / (this.starship.speed * 24);
      this.arrivalDate = new Date(dayjs(this.departureDate).add(travelTime, 'day').toISOString());
    }
  }

  @AfterLoad()
  processPrice() {
    if (this.destination?.distanceToEarth && this.starship?.kilometerPrice) {
      this.price = this.destination.distanceToEarth * this.starship.kilometerPrice;
    }
  }

Attention à l'import de dayjs :

import * as dayjs from 'dayjs';

Modifions la classe CreateBookingDto en rajoutant les propriétés ci-après. Elle sera utilisée pour décrire l'entrée nécessaire au service de création d'une réservation.

@ApiProperty()
@Expose()
@IsBoolean()
active: boolean;

@ApiProperty()
@Expose()
@IsUUID()
destinationUuid: string;

@ApiProperty()
@Expose()
@IsUUID()
starshipUuid: string;

@ApiProperty()
@Expose()
@IsString()
traveller: string;

@ApiProperty()
@Expose()
@IsDate()
departureDate: Date;

Modifions également la classe UpdateBookingDto qui définira l'entrée nécessaire pour modifier une réservation. L'utilisation de PartialType, importé de @nestjs/swagger permet d'indiquer qu'on a les mêmes propriétés que CreateBookingDto mais qu'elles sont optionnelles.

export class UpdateBookingDto extends PartialType(CreateBookingDto) {
    @ApiProperty()
    @Expose()
    @IsNotEmpty()
    @IsUUID()
    @IsOptional()
    uuid: string;
}

Enrichir le service BookingService. Injectons d'abord les dépendances nécessaires dans le constructeur.

constructor(
    @InjectRepository(Booking) private readonly bookingRepository: Repository<Booking>,
    private readonly planetService: PlanetService,
    private readonly starshipService: StarshipService,
) { }

Modifions la méthode de création d'une réservation. Elle crée une instance de l'entité Booking, la complète avec les données issues du DTO et appelle le repository pour sauvegarder l'entité. On fait également appel aux services planetService et starshipService pour récupérer les entités correspondant aux uuids fournis dans le DTO.

async create(createBookingDto: CreateBookingDto): Promise<Booking> {
    const destination = await this.planetService.findOneByUuid(createBookingDto.destinationUuid);
    const starship = await this.starshipService.findOneByUuid(createBookingDto.starshipUuid);

    if (!destination || !starship) {
        throw new UnprocessableEntityException('Both destination and starship should contains existing uuids');
    }

    const booking: Booking = new Booking();
    booking.active = createBookingDto.active;
    booking.departureDate = createBookingDto.departureDate;
    booking.traveller = createBookingDto.traveller;
    booking.destination = destination;
    booking.starship = starship;

    return this.bookingRepository.save(booking);
}

Remplaçons la méthode findOne par une méthode findOneByUuid pour prendre en compte un UUID. La propriété relations permet de charger le vaisseau et la planète de destination quand on charge une réservation.

  findOneByUuid(uuid: string): Promise<Booking> {
    return this.bookingRepository.findOne({ where: { uuid }, relations: ['starship', 'destination'] });
  }

De même que pour create(), la méthode update est modifiée :

async update(uuid: string, updateBookingDto: UpdateBookingDto): Promise<Booking> {
    const booking = await this.findOneByUuid(uuid);

    if (!booking) {
      throw new NotFoundException();
    }

    if (updateBookingDto.destinationUuid) {
      const destination = await this.planetService.findOneByUuid(updateBookingDto.destinationUuid);
      if (!destination) {
        throw new UnprocessableEntityException('The provided destination UUID doesn\'t map to an existing destination');
      }
      booking.destination = destination;
    }

    if (updateBookingDto.starshipUuid) {
      const starship = await this.starshipService.findOneByUuid(updateBookingDto.starshipUuid);
      if (!starship) {
        throw new UnprocessableEntityException('The provided starship UUID doesn\'t map to an existing starship');
      }
      booking.starship = starship;
    }

    booking.active = updateBookingDto.active;
    booking.departureDate = updateBookingDto.departureDate;
    booking.traveller = updateBookingDto.traveller;

    return this.bookingRepository.save(booking);
}

Compléter les méthodes remove et findAll :

remove(uuid: string): Promise<DeleteResult> {
    return this.bookingRepository.delete({ uuid });
}
findAll(): Promise<Booking[]> {
    return this.bookingRepository.find({ relations: ['starship', 'destination'] });
}

Et enfin compléter le contrôleur BookingController :

@Post()
create(@Body() createBookingDto: CreateBookingDto): Promise<Booking> {
    return this.bookingService.create(createBookingDto);
}

@Patch(':uuid')
update(
    @Param('uuid', new ParseUUIDPipe()) uuid: string,
    @Body() updateBookingDto: UpdateBookingDto,
): Promise<Booking> {
    return this.bookingService.update(uuid, updateBookingDto);
}

@Delete(':uuid')
remove(@Param('uuid', new ParseUUIDPipe()) uuid: string): Promise<DeleteResult> {
    return this.bookingService.remove(uuid);
}

@Get()
findAll(): Promise<Booking[]> {
    return this.bookingService.findAll();
}

@Get(':uuid')
findOne(@Param('uuid', new ParseUUIDPipe()) uuid: string): Promise<Booking> {
    return this.bookingService.findOneByUuid(uuid);
}
@ApiTags('bookings')
@Controller({ path: '/bookings', version: '1' })

Il est maintenant possible de manipuler les bookings avec leur API dans Swagger.

Nous allons sécuriser l'accès à nos endoints en exigeant la fourniture d'un bearer d'authentification dans les headers des requêtes. Nous allons créer un "guard", un composant NestJS qui permet de contrôler l'accès à des routes.

Dans le fichier .env ajouter la variable d'environnement API_BEARER qui définit la valeur attendue pour le bearer :

API_BEARER=MyBearer

Créer un nouveau module security :

nest g module security

Créer un guard dans ce module :

nest g guard security/bearer

Compléter le code du guard comme ci-dessous :

import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Observable } from 'rxjs';

@Injectable()
export class BearerGuard implements CanActivate {
  private readonly logger = new Logger(BearerGuard.name);

  constructor(private configService: ConfigService) {}

  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();

    if (request?.headers?.authorization === `Bearer ${this.configService.get<string>('API_BEARER')}`) {
      return true;
    }

    this.logger.warn(`An unauthorized call has been made on a API endpoint`);
    return false;
  }
}

Quelques explications :

Activons maintenant ce guard pour l'ensemble des routes de l'application en ajoutant les lignes suivantes dans le fichier main.ts :

  const configService = app.get<ConfigService>(ConfigService);

  // SECURITY
  app.useGlobalGuards(new BearerGuard(configService));

Nous devons maintenant configurer Swagger pour activer la saisie du header authorization dans Swagger UI.

Toujours dans le fichier main.ts modifier la configuration de Swagger en faisant appel à la méthode addBearerAuth.

  const config = new DocumentBuilder()
    .setTitle('Form Earth to Moon API')
    .setDescription('A codelab to discover NestJs and more')
    .setVersion('1.0')
    .addBearerAuth()
    .build();

Enfin sur chacun des contrôleurs ajouter le décorateur @ApiBearerAuth importé de Swagger. Par exemple dans BookingController :

@ApiTags('bookings')
@ApiBearerAuth()
@Controller({ path: '/bookings', version: '1' })
export class BookingController {

Il ne reste plus qu'à lancer Swagger UI. Tester les services sans bearer : on a une erreur 403. Puis cliquer sur le bouton Authenticate, saisir la valeur du bearer (juste la valeur de la clé) et tester les services, ils doivent désormais passer.

Nous allons maintenant améliorer la gestion de configuration en bloquant le démarrage de l'application si des variables d'environnement ne sont pas correctes et en évitant l'accès direct aux variables d'environnement depuis les composants de l'application.

Créons le fichier config/configuration.ts qui va définir le mapping entre nos clés de configuration et des variables d'environnement :

export default () => ({
  port: parseInt(process.env.PORT, 10) || 3000,
  database: {
    path: process.env.SQL_MEMORY_DB_SHARED,
  },
  security: {
    apiBearer: process.env.API_BEARER,
  },
});

Créons un schéma de validation pour cette configuration dans le fichier config/schema.ts en utilisant la librairie Joi :

import * as Joi from 'joi';

export default Joi.object({
  NODE_ENV: Joi.string().valid('development', 'production', 'test', 'provision').default('development'),
  PORT: Joi.number().default(3000),
  SQL_MEMORY_DB_SHARED: Joi.string().required(),
  API_BEARER: Joi.string().required(),
});

Modifions la configuration du module ConfigModule dans app.module.ts

  ConfigModule.forRoot({
    load: [configuration],
    validationSchema: configurationSchema,
    validationOptions: {
      abortEarly: true,
    },
  }),

Avec les imports suivants :

import configuration from './config/configuration';
import configurationSchema from './config/schema';

Modifions le paramétrage du port d'écoute de l'application dans main.ts:

await app.listen(configService.get('port'));

Ainsi que la clé permettant de récupérer path de la base de données, toujours dans app.module.ts :

database: configService.get('database.path'),

Enfin modifions la clé de récupération du bearer d'authentification dans bearer.guard.ts :

if (request?.headers?.authorization === `Bearer ${this.configService.get<string>('security.apiBearer')}`) {

Mettons en commentaire la variable d'environnement API_BEARER dans le fichier .env et démarrons le serveur avec npm run start:dev : on doit avoir un message d'erreur indiquant le nom de la variable manquante.

Décommentons la variable et relançons npm run start:dev : l'application démarre bien sur le port 3000 et les services fonctionnent correctement.

Ajoutons maintenant un paramètre PORT avec la valeur 8080 dans le fichier .env et relançons npm run start:dev : l'application démarre désormais sur le port 8080.

Nous allons maintenant rajouter des tests au projet.

Lors de la création des artefacts via la CLI, Nest crée des fichiers de tests. Ceux-ci ont une extension .spec.ts.

Lançons les tests qui ont été générés de manière automatique :

npm run test

On peut constater que les tests échouent.

En effet, dans les tests créés par défaut, il faut compléter l'injection de dépendance en paramétrant les providers nécessaires à la bonne exécution des tests. Par exemple dans booking.controller.spec.ts, la configuration générée de base est la suivante :

beforeEach(async () => {
  const module: TestingModule = await Test.createTestingModule({
    controllers: [BookingController],
    providers: [BookingService],
  }).compile();

  controller = module.get<BookingController>(BookingController);
});

Il est nécessaire de modifier le code de la manière suivante :

beforeEach(async () => {
  const module: TestingModule = await Test.createTestingModule({
    controllers: [BookingController],
    providers: [
      BookingService,
      PlanetService,
      StarshipService,
      {
        provide: getRepositoryToken(Booking),
        useClass: Repository,
      },
      {
        provide: getRepositoryToken(Planet),
        useClass: Repository,
      },
      {
        provide: getRepositoryToken(Starship),
        useClass: Repository,
      },
    ],
  }).compile();

  controller = module.get<BookingController>(BookingController);
});

De même configurons l'injection de dépendances pour booking.service.spec.ts :

beforeEach(async () => {
  const module: TestingModule = await Test.createTestingModule({
    providers: [
      BookingService,
      PlanetService,
      StarshipService,
      {
        provide: getRepositoryToken(Booking),
        useClass: Repository,
      },
      {
        provide: getRepositoryToken(Planet),
        useClass: Repository,
      },
      {
        provide: getRepositoryToken(Starship),
        useClass: Repository,
      },
    ],
  }).compile();

  service = module.get<BookingService>(BookingService);
});

Pour planet.controller.spec.ts :

beforeEach(async () => {
  const module: TestingModule = await Test.createTestingModule({
    controllers: [PlanetController],
    providers: [
      PlanetService,
      {
        provide: getRepositoryToken(Planet),
        useClass: Repository,
      },
    ],
  }).compile();

  controller = module.get<PlanetController>(PlanetController);
});

Pour planet.service.spec.ts :

beforeEach(async () => {
  const module: TestingModule = await Test.createTestingModule({
    providers: [
      PlanetService,
      {
        provide: getRepositoryToken(Planet),
        useClass: Repository,
      },
    ],
  }).compile();

  service = module.get<PlanetService>(PlanetService);
});

Pour starship.controller.spec.ts :

beforeEach(async () => {
  const module: TestingModule = await Test.createTestingModule({
    controllers: [StarshipController],
    providers: [
      StarshipService,
      {
        provide: getRepositoryToken(Starship),
        useClass: Repository,
      },
    ],
  }).compile();

  controller = module.get<StarshipController>(StarshipController);
});

Pour starship.service.spec.ts :

beforeEach(async () => {
  const module: TestingModule = await Test.createTestingModule({
    providers: [
      StarshipService,
      {
        provide: getRepositoryToken(Starship),
        useClass: Repository,
      },
    ],
  }).compile();

  service = module.get<StarshipService>(StarshipService);
});

Dans bearer.guard.spec.ts, pour l'instant mettons en commentaire le test :

it('should be defined', () => {
  //expect(new BearerGuard()).toBeDefined();
});

A ce stade, tous les tests passent :

npm run test

Nous allons pouvoir tester les fonctionnalités de l'application en modifiant les tests existants ou en rajoutant de nouveaux tests.

Commençons par un test simple en créant le fichier src\booking\entities\booking.entity.spec.ts avec le contenu suivant :

import { Planet } from 'src/planet/entities/planet.entity';
import { Booking } from './booking.entity';
import { Starship } from 'src/starship/entities/starship.entity';
import * as dayjs from 'dayjs';

describe('BookingEntity', () => {
  let destination: Planet;
  let starship: Starship;

  beforeAll(async () => {
    destination = {
      id: 1,
      uuid: 'uuid',
      active: true,
      name: 'Saturn',
      distanceToEarth: 1427000000,
    };
    starship = {
      id: 1,
      uuid: 'uuid',
      active: true,
      name: 'Enterprise',
      speed: 100000,
      kilometerPrice: 2500,
    };
  });

  it('Should calculate arrival date', () => {
    // --- ARRANGE
    const booking = new Booking();
    booking.destination = destination;
    booking.starship = starship;
    booking.departureDate = new Date('2023-04-04');

    // --- ACT
    booking.processTravelTime();

    // --- ASSERT
    expect(dayjs(booking.arrivalDate).format('YYYY-MM-DD')).toEqual('2024-11-19');
  });

  it('Should calculate travel price', () => {
    // --- ARRANGE
    const booking = new Booking();
    booking.destination = destination;
    booking.starship = starship;

    // --- ACT
    booking.processPrice();

    // --- ASSERT
    expect(booking.price).toEqual(3567500000000);
  });
});

Nous allons maintenant tester les fonctionnalités de booking en modifiant booking.controller.spec.ts :

import { UnprocessableEntityException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Planet } from 'src/planet/entities/planet.entity';
import { PlanetService } from 'src/planet/planet.service';
import { Starship } from 'src/starship/entities/starship.entity';
import { StarshipService } from 'src/starship/starship.service';
import { Repository } from 'typeorm';
import { BookingController } from './booking.controller';
import { BookingService } from './booking.service';
import { CreateBookingDto } from './dto/create-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { Booking } from './entities/booking.entity';

const destination = {
  id: 1,
  uuid: 'destination-uuid',
  active: true,
  name: 'Saturn',
  distanceToEarth: 1427000000,
};
const starship = {
  id: 1,
  uuid: 'starship-uuid',
  active: true,
  name: 'Enterprise',
  speed: 100000,
  kilometerPrice: 2500,
};

describe('BookingController', () => {
  let bookingController: BookingController;
  let bookingCreateDto: CreateBookingDto;
  let bookingUpdateDto: UpdateBookingDto;
  let bookingEntity: Booking;
  let planetRepository: Repository<Planet>;
  let starshipRepository: Repository<Starship>;
  let bookingRepository: Repository<Booking>;

  beforeEach(() => {
    bookingCreateDto = new CreateBookingDto();
    bookingCreateDto.destinationUuid = 'destination-uuid';
    bookingCreateDto.starshipUuid = 'starship-uuid';
    bookingCreateDto.departureDate = new Date('2023-04-04');
    bookingCreateDto.traveller = 'Michael Collins';

    bookingUpdateDto = new UpdateBookingDto();
    bookingUpdateDto.destinationUuid = 'destination-uuid';
    bookingUpdateDto.starshipUuid = 'starship-uuid';
    bookingUpdateDto.departureDate = new Date('2023-04-04');
    bookingUpdateDto.traveller = 'Michael Collins';

    bookingEntity = new Booking();
    bookingEntity.destination = destination;
    bookingEntity.starship = starship;
    bookingEntity.departureDate = new Date('2023-04-04');
    bookingEntity.traveller = 'Michael Collins';
  });

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [BookingController],
      providers: [
        BookingService,
        PlanetService,
        StarshipService,
        {
          provide: getRepositoryToken(Booking),
          useClass: Repository,
        },
        {
          provide: getRepositoryToken(Planet),
          useClass: Repository,
        },
        {
          provide: getRepositoryToken(Starship),
          useClass: Repository,
        },
      ],
    }).compile();

    bookingController = module.get<BookingController>(BookingController);
    planetRepository = module.get<Repository<Planet>>(getRepositoryToken(Planet));
    starshipRepository = module.get<Repository<Starship>>(getRepositoryToken(Starship));
    bookingRepository = module.get<Repository<Booking>>(getRepositoryToken(Booking));
  });

  it('should be defined', () => {
    expect(bookingController).toBeDefined();
  });

  describe('create', () => {
    it('should create a booking', async () => {
      // --- ARRANGE
      const planetRepoFindOneByUuidSpy = jest.spyOn(planetRepository, 'findOneBy').mockResolvedValueOnce(destination);
      const starshipRepoFindOneByUuidSpy = jest.spyOn(starshipRepository, 'findOneBy').mockResolvedValueOnce(starship);
      const bookingRepoSaveSpy = jest.spyOn(bookingRepository, 'save').mockResolvedValueOnce(bookingEntity);

      // --- ACT
      const booking = await bookingController.create(bookingCreateDto);

      // --- ASSERT
      expect(planetRepoFindOneByUuidSpy).toHaveBeenCalledWith({ uuid: destination.uuid });
      expect(starshipRepoFindOneByUuidSpy).toHaveBeenCalledWith({ uuid: starship.uuid });
      expect(bookingRepoSaveSpy).toHaveBeenCalledWith(bookingEntity);
      expect(booking).toEqual(bookingEntity);
    });

    it('should throw an unprocessable entity exception', async () => {
      // --- ARRANGE
      jest.spyOn(planetRepository, 'findOneBy').mockResolvedValueOnce(null);
      jest.spyOn(starshipRepository, 'findOneBy').mockResolvedValueOnce(null);

      try {
        // --- ACT
        await bookingController.create(bookingCreateDto);
      } catch (exception) {
        // --- ASSERT
        expect(exception).toBeInstanceOf(UnprocessableEntityException);
        expect(exception.message).toEqual('Both destination and starship should contains existing uuids');
      }
    });
  });

  describe('update', () => {
    it('should update a booking', async () => {
      // --- ARRANGE
      const bookingRepoFindOneSpy = jest.spyOn(bookingRepository, 'findOne').mockResolvedValueOnce(bookingEntity);
      const planetRepoFindOneByUuidSpy = jest.spyOn(planetRepository, 'findOneBy').mockResolvedValueOnce(destination);
      const starshipRepoFindOneByUuidSpy = jest.spyOn(starshipRepository, 'findOneBy').mockResolvedValueOnce(starship);
      const bookingRepoSaveSpy = jest.spyOn(bookingRepository, 'save').mockResolvedValueOnce(bookingEntity);

      // --- ACT
      const booking = await bookingController.update(bookingEntity.uuid, bookingUpdateDto);

      // --- ASSERT
      expect(bookingRepoFindOneSpy).toHaveBeenCalledWith({
        where: { uuid: booking.uuid },
        relations: ['starship', 'destination'],
      });
      expect(planetRepoFindOneByUuidSpy).toHaveBeenCalledWith({ uuid: destination.uuid });
      expect(starshipRepoFindOneByUuidSpy).toHaveBeenCalledWith({ uuid: starship.uuid });
      expect(bookingRepoSaveSpy).toHaveBeenCalledWith(bookingEntity);
      expect(booking).toEqual(bookingEntity);
    });

    it.each`
      destination    | starship    | errorMessage
      ${null}        | ${starship} | ${"The provided destination UUID doesn't map to an existing destination"}
      ${destination} | ${null}     | ${"The provided starship UUID doesn't map to an existing starship"}
    `('should throw unprocessables entities exceptions', async ({ destination, starship, errorMessage }) => {
      // --- ARRANGE
      jest.spyOn(bookingRepository, 'findOne').mockResolvedValueOnce(bookingEntity);
      jest.spyOn(planetRepository, 'findOneBy').mockResolvedValueOnce(destination);
      jest.spyOn(starshipRepository, 'findOneBy').mockResolvedValueOnce(starship);
      jest.spyOn(bookingRepository, 'save').mockResolvedValueOnce(bookingEntity);

      try {
        // --- ACT
        await bookingController.update(bookingEntity.uuid, bookingUpdateDto);
      } catch (exception) {
        // --- ASSERT
        expect(exception).toBeInstanceOf(UnprocessableEntityException);
        expect(exception.message).toEqual(errorMessage);
      }
    });
  });
});

Nous pouvons modifier les tests du module de sécurité dans bearer.guard.spec.ts :

import { createMock } from '@golevelup/ts-jest';
import { ExecutionContext } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import configuration from 'src/config/configuration';
import configurationSchema from 'src/config/schema';
import { BearerGuard } from './bearer.guard';

describe('BearerGuard', () => {
  let bearerGuard: BearerGuard;
  let configService: ConfigService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true,
          envFilePath: '.env.test',
          load: [configuration],
          validationSchema: configurationSchema,
          validationOptions: {
            abortEarly: true,
          },
        }),
      ],
      providers: [BearerGuard],
    }).compile();

    bearerGuard = module.get<BearerGuard>(BearerGuard);
    configService = await module.get<ConfigService>(ConfigService);
  });

  it('should be defined', () => {
    expect(bearerGuard).toBeDefined();
  });

  it.each`
    bearer          | expected
    ${''}           | ${true}
    ${'bad-bearer'} | ${false}
  `('should authorized request or not', async ({ bearer, expected }) => {
    // --- ARRANGE
    // Twist to use the config because it's unavailable in the each parameters context
    bearer = expected ? configService.get<string>('security.apiBearer') : bearer;

    const mockExecutionContext: ExecutionContext = createMock<ExecutionContext>({
      switchToHttp: () => ({
        getRequest: () => ({
          headers: {
            authorization: `Bearer ${bearer}`,
          },
        }),
      }),
    });

    // --- ACT
    const isAuthorized = await bearerGuard.canActivate(mockExecutionContext);

    // --- ASSERT
    expect(isAuthorized).toEqual(expected);
  });
});

Nous allons maintenant créer un test "end to end" en créant le fichier src\starship\starship.controller.e2e.spec.ts :

import { ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { NestApplication } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import configuration from 'src/config/configuration';
import configurationSchema from 'src/config/schema';
import { BearerGuard } from 'src/security/bearer.guard';
import { StarshipModule } from 'src/starship/starship.module';
import * as request from 'supertest';

describe('StarshipController', () => {
  let app: NestApplication;
  let configService: ConfigService;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true,
          envFilePath: '.env.test',
          load: [configuration],
          validationSchema: configurationSchema,
          validationOptions: {
            abortEarly: true,
          },
        }),
        TypeOrmModule.forRootAsync({
          imports: [ConfigModule],
          inject: [ConfigService],
          useFactory: async (configService: ConfigService) => {
            return {
              type: 'sqlite',
              database: configService.get('database.path'),
              entities: [__dirname + '/**/*.entity{.ts,.js}'],
              synchronize: true,
            } as TypeOrmModuleOptions;
          },
        }),
        StarshipModule,
      ],
    }).compile();

    app = moduleRef.createNestApplication<NestApplication>();
    configService = app.get<ConfigService>(ConfigService);

    // SECURITY
    app.useGlobalGuards(new BearerGuard(configService));

    // VERSIONNING
    app.enableVersioning({
      type: VersioningType.URI,
    });

    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true, // Only expose attributes wille be accepted on incoming DTO
        transform: true, // Automatically converts attributes from incoming DTO when possible
        transformOptions: { enableImplicitConversion: true },
      }),
    );

    await app.init();
  });

  describe('create', () => {
    it(`/v1/starships (GET)(SUCCESS)`, async () => {
      const result = await request(app.getHttpServer())
        .get('/v1/starships')
        .set('Authorization', `Bearer ${configService.get<string>('security.apiBearer')}`)
        .expect(200)
        .expect((res) => expect(res.body[0].name).toEqual('Millenium falcon'));
    });
  });
});

Le taux de couverture des tests peut être vérifié avec la commande npm run test:cov : Tests coverage