Création d'une API REST avec NestJS et MySQL (TypeScript)

Création d'une API REST avec NestJS et MySQL (TypeScript)
Categories:
Posté le

Nest, framework Node.js gagnant grandement en popularité ces derniers temps est un framework que j'apprécie tout particulièrement.

Il apporte pour moi des éléments qui manquaient dans un bon nombre de frameworks (Express.js, Koa, ...) notamment en prenant un parti pris fort au niveau de l'architecture des applications avec une structure MVC prête à l'emploi.

Architecture bien connue par de nombreux développeurs et que retrouve globalement dans une majorité de frameworks dans d'autres environnements techniques.

Cela permet de se retrouver rapidement dans un projet développé en Nest à contrario d'autres frameworks comme Express.js où l'architecture est beaucoup plus libre et dépend principalement des choix du développeur.

Nest se démarque également par une grande utilisation des annotations (en raison également  de l'utilisation de TypeScript par défaut), ce qui lui donne des similitudes avec le framework Spring et ressemble beaucoup à Spring Boot dans son approche prêt à l'emploi. Pour les développeurs ayant déjà faits de l'Angular 2+ il y a également une grande similitude dans l'approche.

Afin de donner un exemple d'une partie des avantages de Nest, l'article d'aujourd'hui portera sur l'implémentation d'un API REST simple basée sur Nest avec comme orm TypeORM et MySQL. L'api sera un CRUD des projets réalisés par un développeur freelance par exemple.

Initialisation de l’environnement de développement :

Afin de pouvoir effectuer les différentes étapes de cet article, il vous faudra Node.js et npm d'installer sur votre machine.

Puis installer la CLI (Interface en ligne de commande) de Nest :

npm i -g @nestjs/cli

Nous allons ensuite utiliser la cli afin d'initialiser notre projet :

nest new api_freelance_project

Choisissez ensuite npm lors d'un processus d'installation.

Une fois le processus d'installation terminé, vous avez maintenant une structure de base d'un projet Nest.

Nous allons dès maintenant apporter une modification au fichier src/main.ts :

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT || 3000);
}
bootstrap();

La modification se situe au niveau du PORT de lancement de l'application que nous allons maintenant pouvoir changer facilement via une variable d’environnement.

Afin de nous assurer que votre installation est fonctionnelle, nous allons maintenant lancer le projet en mode développement ce qui va activer l'auto-reload :

PORT=5152 npm run start:dev

Si tout s'est bien déroulé, vous pouvez effectuer le test suivant :

curl -X GET 'http://localhost:5152'          
> Hello World!

Connexion avec MySQL :

Afin de pouvoir interagir avec MySQL, il va vous falloir installer un certain nombre d’éléments :

npm install --save @nestjs/typeorm typeorm mysql2

Vous allez ensuite créer le fichier ormconfig.js afin de configurer la connexion à MySQL :

{
    "type": "mysql",
    "host": "localhost",
    "port": 3306,
    "username": "nestapi",
    "password": "nestapi",
    "database": "nestapi",
    "entities": ["dist/**/*.entity{.ts,.js}"],
    "synchronize": true
}

Attention à ne pas utiliser "synchonize" à "true" en production, cela met automatiquement à jour les schémas SQL en fonction des entités et peut donc vous faire perdre des données.

Configuration MySQL à adapter en fonction de votre environnement évidemment.

Il faut ensuite importer les configurations de la connexion MySQL comme ceci dans le ficher src/app.module.ts :

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ProjectModule } from './project/project.module';
import { PhotoModule } from './photo/photo.module';

@Module({
  imports: [TypeOrmModule.forRoot()],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule { }

Création du module project :

Les applications Nest se découpent en modules, définis généralement par un contrôleur, un service et une entité.

Afin de créer ces différents éléments, exécutez les commandes suivantes :

nest generate module project
nest generate controller project
nest generate service project

Création du module photo :

Même procédé que précédemment, à l’exception que nous n'avons pas besoin d'un contrôleur :

nest generate module photo
nest generate service photo

Création des entités :

Créez le fichier src/project/project.entity.ts :

import { Photo } from "src/photo/photo.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class Project {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @Column('longtext')
    description: string;

    @OneToMany(() => Photo, photo => photo.project, { eager: true, cascade: true })
    photos: Photo[];

}

Puis le fichier src/photo/photo.entity.ts :

import { Project } from "src/project/project.entity";
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class Photo {

    @PrimaryGeneratedColumn()
    id: number;

    @Column('longtext')
    path: string;

    @ManyToOne(() => Project, project => project.photos)
    project: Project;

}

Rien de bien compliqué ici dans le mapping des objets pour ceux ayant déjà utilisé un ORM, à noter une relation en projet et photo.

Eager à true permet de récupérer les photos en même temps que le projet.

Lancez maintenant l'application Nest afin de créer les tables :

PORT=5152 npm run start:dev

Nous allons ensuite apporter quelques modifications au fichier src/main.ts :

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());
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
    }),
  );
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
    }),
  );

  await app.listen(process.env.PORT || 3000);
}
bootstrap();

Cela va notamment permettre d'activer la validation des données dans les contrôleurs, nous verrons par la suite la grande utilité des pipes. Plus d'informations ici (notamment sur l'utilité de whitelist) :  https://docs.nestjs.com/techniques/validation#stripping-properties

Afin de pouvoir profiter pleinement de la validation, notamment en exploitant le concept de DTO (Objet de transfert de données), il va nous falloir installer les dépendances suivantes :

npm i --save class-validator class-transformer

Création du contrôleur project :

import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { FindOneDto } from 'src/dto/findone.dto';
import { ProjectDto } from 'src/dto/project.dto';
import { ProjectService } from './project.service';

@Controller('project')
export class ProjectController {

    constructor(
        private readonly projectService: ProjectService
    ) { }

    @Get('/:id')
    findOne(@Param() params: FindOneDto) {
        return this.projectService.findOne(params.id);
    }

    @Get()
    findAll() {
        return this.projectService.findAll();
    }

    @Post()
    @HttpCode(201)
    async create(@Body() projectDto: ProjectDto) {
        return this.projectService.save(projectDto)
    }

    @Put('/:id')
    @HttpCode(202)
    async update(@Param() params: FindOneDto, @Body() projectDto: ProjectDto) {
        return this.projectService.update(params.id, projectDto)
    }

    @Get('/:id')
    deleteOne(@Param() params: FindOneDto) {
        return this.projectService.remove(params.id);
    }

}

Rien de bien transcendant ici, un CRUD avec validation des données par DTO et faisant appel à un service pour la logique métier.

Création des DTO :

Créez le dossier src/dto, puis les éléments suivants :

src/dto/findone.dto.ts :

import { IsNumberString } from 'class-validator';

export class FindOneDto {
  @IsNumberString()
  id: number;
}

src/dto/project.dto.ts :

import { Type } from "class-transformer";
import { ArrayNotEmpty, IsArray, IsNotEmpty, Length, ValidateNested } from "class-validator";
import { PhotoDto } from "./photo.dto";

export class ProjectDto {

    @IsNotEmpty()
    @Length(1, 255)
    name: string;

    @IsNotEmpty()
    @Length(1, 1024)
    description: string;

    @ValidateNested({ each: true })
    @IsArray()
    @ArrayNotEmpty()
    @Type(() => PhotoDto)
    photos: PhotoDto[];

}

src/dto/photo.dto.ts :

import { IsNotEmpty, IsUrl, Length } from "class-validator";

export class PhotoDto {

    @IsNotEmpty()
    @IsUrl()
    @Length(1, 512)
    path: string;

}

Afin de finaliser le CRUD et de pouvoir s'en servir, il ne manque plus que l'implémentation de la couche service.

Création des services :

src/photo/photo.service.ts :

import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Photo } from './photo.entity';

@Injectable()
export class PhotoService {

    constructor(
        @InjectRepository(Photo)
        private photosRepository: Repository<Photo>
    ) { }

    async remove(id: number) {
        try {
            await this.photosRepository.delete(id);
        } catch {
            throw new BadRequestException('Cannot delete this photo');
        }
    }

}

src/project/project.service.ts :

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ProjectDto } from 'src/dto/project.dto';
import { Photo } from 'src/photo/photo.entity';
import { PhotoService } from 'src/photo/photo.service';
import { Repository } from 'typeorm';
import { Project } from './project.entity';

@Injectable()
export class ProjectService {

    constructor(
        @InjectRepository(Project)
        private projectsRepository: Repository<Project>,
        private readonly photoService: PhotoService
    ) { }


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

    async findOne(id: number): Promise<Project> {
        const project = await this.projectsRepository.findOne(id);

        if (project === undefined) {
            throw new NotFoundException('Project cannot be found');
        }

        return project;
    }

    async remove(id: number): Promise<void> {
        try {
            const project = await this.projectsRepository.findOne(id);

            for (let photo of project.photos) {
                await this.photoService.remove(photo.id);
            }

            await this.projectsRepository.delete(id);
        } catch (e) {
            console.log(e);
            throw new BadRequestException('Project cannot be deleted');
        }
    }

    async save(projectDto: ProjectDto): Promise<Project> {
        try {
            return await this.projectsRepository.save(projectDto);
        } catch {
            throw new BadRequestException('Project cannot be created');
        }
    }

    async update(id: number, projectDto: ProjectDto): Promise<Project> {
        try {
            const project = await this.projectsRepository.findOne(id);

            project.name = projectDto.name;
            project.description = projectDto.description;

            for (let photo of project.photos) {
                await this.photoService.remove(photo.id);
            }

            project.photos = [];

            for (let photo of projectDto.photos) {
                const p = new Photo();
                p.path = photo.path;

                project.photos.push(p)
            }

            return await this.projectsRepository.save(project);
        } catch (e) {
            console.log(e);
            throw new BadRequestException('Project cannot be updated');
        }
    }
}

Afin de rendre le tout fonctionnel, assurez-vous bien d'avoir les fichiers de modules suivants (servant à l'injection de dépendances des repositories mais également inter-services) :

src/photo/photo.module.ts :

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Photo } from './photo.entity';
import { PhotoService } from './photo.service';

@Module({
    imports: [TypeOrmModule.forFeature([Photo])],
    providers: [PhotoService],
    exports: [PhotoService]
})
export class PhotoModule { }

src/project/project.module.ts :

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PhotoModule } from 'src/photo/photo.module';
import { ProjectController } from './project.controller';
import { Project } from './project.entity';
import { ProjectService } from './project.service';

@Module({
  imports: [TypeOrmModule.forFeature([Project]), PhotoModule],
  controllers: [ProjectController],
  providers: [ProjectService]
})
export class ProjectModule { }

Tests de l'API :

Nous allons dès à présent pouvoir tester le CRUD :

Création d'un projet :

curl "http://localhost:5152/project" -X POST -H "Content-Type: application/json" -d '{"name": "Création site e-commerce", "description": "Site e-commerce de vente de formations", "photos": [{ "path": "http://127.0.0.1:5153/img/media/logo.png" }, {"path": "http://127.0.0.1:5153/img/media/banner-bg.jpg"}] }'

> {"name":"Création site e-commerce","description":"Site e-commerce de vente de formations","photos":[{"path":"http://127.0.0.1:5153/img/media/logo.png"},{"path":"http://127.0.0.1:5153/img/media/banner-bg.jpg"}]}

Testons également la validation :

curl "http://localhost:5152/project" -X POST -H "Content-Type: application/json" -d '{"message": "Yann" }'          

> {"statusCode":400,"message":["name must be longer than or equal to 1 characters","name should not be empty","description must be longer than or equal to 1 characters","description should not be empty","photos should not be empty","photos must be an array"],"error":"Bad Request"}

Récupération d'un projet :

curl -X GET 'http://localhost:5152/project/1'

> {"id":1,"name":"Création site e-commerce","description":"Site e-commerce de vente de formations","photos":[{"id":1,"path":"http://127.0.0.1:5153/img/media/logo.png"},{"id":2,"path":"http://127.0.0.1:5153/img/media/banner-bg.jpg"}]}

Modification d'un projet :

curl "http://localhost:5152/project/1" -X PUT -H "Content-Type: application/json" -d '{"name": "Création site e-commerce", "description": "Site e-commerce de vente de coaching", "photos": [{ "path": "http://127.0.0.1:5152/img/media/logo.png" }, { "path": "http://127.0.0.1:5152/img/media/logo.png" }] }'         

> {"id":1,"name":"Création site e-commerce","description":"Site e-commerce de vente de coaching","photos":[{"path":"http://127.0.0.1:5152/img/media/logo.png","id":3},{"path":"http://127.0.0.1:5152/img/media/logo.png","id":4}]}

Suppression d'un projet :

curl -X DELETE "http://localhost:5152/project/1"

curl -X GET "http://localhost:5152/project/1"   

> {"statusCode":404,"message":"Project cannot be found","error":"Not Found"}

Conclusion :

Voici pour cet article d'introduction à Nest, framework que j'apprécie tout particulièrement, notamment pour le gain de productivité qu'il procure et l'architecture qu'il impose.

Les sources de cet article se trouvent ici : https://github.com/Yann-Carlen/nest-api

Si vous cherchez un développeur Node.js / NestJS afin de réaliser votre site ou de vous accompagner sur une autre problématique n'hésitez pas à me contacter !

Le sujet d'un prochain article sera de voir comment mettre en place un système d'authentification et d'autorisation en utilisant des JWT avec Nest et un SSO.

Les ressources qui m'ont aidé dans la rédaction de cet article et dont je remercie grandement les auteurs :