Bootstraper une API avec NestJS

par Hugo
Hugo Foyart

13 minutes de lecture

Bootstraper une API avec NestJS

À l’heure où il ne fait aucun doute pour un développeur JavaScript que créer une API à l’aide d’express est la meilleure solution, de part la rapidité et la simplicité qui en découlent, un framework commence petit à petit à faire son nid dans le monde du back-end en JavaScript : NestJS. Ce framework plaira notamment aux amoureux de TypeScript et d’Angular, car Nest est en tout point ressemblant au fameux framework front-end : un CLI qui propose une génération structurée de nos fichiers, on retrouve aussi le principe de controllers, services et modules. Bien qu’il soit recommandé d’utiliser TypeScript, Nest permet tout de même aux développeurs qui ne sont pas à l’aise avec ce langage de rester sur du pur JavaScript.

Nous allons réaliser une petite API REST à l’aide de NestJS impliquant des articles de blog et leurs auteurs, communiquant avec une base de données avec du CRUD basique. Nous verrons aussi comment simplement mettre en place de la documentation pour votre API. Nous utiliserons aussi TypeScript, ainsi que Yarn en tant que gestionnaire de paquet. L’intégralité du code final que nous allons réaliser est disponible sur CodeSandbox

Génération du projet

Tout d’abord, installons le CLI de NestJS.

yarn global add @nestjs/cli

Puis exécuter la commande

nest new tuto-nest

Cela va créer un dossier tuto-test dans le répertoire où a été lancée la commande. Ce dossier contient un projet NestJS prêt à être lancé, et contenant déjà une route. Lançons le projet.

yarn start --watch

Le paramètre —watch permet de relancer automatiquement le serveur après des changements au sein des fichiers du projet. Lorsque le message [NestApplication] Nest application successfully started est visible dans votre terminal, rendez-vous sur n’importe quel logiciel capable d’exécuter une requête (Postman, Insomnia ou bien tout simplement votre navigateur favori), puis exécutez une requête sur http://localhost:3000. Vous devriez voir apparaître Hello world!. Alors que s’est-il passé pour que nous voyons ce message ? Regardons le contenu de nos fichiers dans le dossier src.

  • main.ts : ce fichier contient le point d’entrée de notre application, où l’on va récupérer notre module principal, puis lancer notre serveur sur un port particulier (3000 par défaut).
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  await app.listen(3000);
}
bootstrap();
  • app.controller.ts : un contrôleur contient la définition de nos routes pour un module en particulier. Ici le contrôleur ne contient qu’une route, et cette route effectue un appel vers un service et en retourne son résultat. La classe est décorée à l’aide du décorateur @Controller, qui prend en paramètre optionnel une chaîne de caractère, indiquant un préfixe à utiliser pour les routes de notre contrôleur. Ainsi, si l’on remplace par @Controller('foo'), on pourra effectuer une requête vers http://localhost:3000/foo.
import { Controller, Get, Param } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}
  • app.service.ts : un service contient la logique de récupération et de traitement de donnée, par exemple depuis une base de données.
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello world!';
  }
}
  • app.module.ts : un module exporte à la fois les contrôleurs utilisés (controllers), les services utilisés au sein de ce dernier (providers), les services à fournir pour une utilisation dans d’autres modules (exports) et les modules utilisés au sein des contrôleurs et des services (imports).
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

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

Création d’une route

Récupération d’un paramètre

Modifions notre app.controller.ts ainsi que notre app.service.ts

// app.controller.ts
@Get(':name')
getHello(@Param('name') name: string): string {
  return this.appService.getHello(name);
}
// app.service.ts
getHello(name: string): string {
  return `Hello ${name}!`;
}

En exécutant une requête http://localhost:3000/hugo, on obtient Hello hugo! en retour. Que s’est-il passé ?

  • Nous avons créé une route /:name, ce qui veut dire que :name peut avoir n’importe quelle valeur.
  • Nous récupérons cette valeur dans les arguments de notre fonction à l’aide du décorateur @Param. Nest fournit de nombreux décorateurs afin d’extraire les informations d’une requête.
  • On passe ensuite le nom à la fonction getHello de notre service, qui va ensuite se charger d’afficher la chaîne de caractère finale.

On peut voir que la création d’une route se veut très simple: on décore une fonction par un décorateur correspondant à la méthode associée à notre route (Get, Post, Put, Patch, Delete, etc…), on y ajoute un paramètre correspondant au nom de la route, on récupère les informations de la requête dont on a besoin au sein des arguments de notre fonction, puis on effectue notre logique au sein du service.

Intégration de TypeORM

Nous allons utiliser TypeORM pour décrire les différentes entités de notre API, dans notre cas une entité pour les auteurs et une entité pour les articles de blog. Nous allons stocker les données dans une base de données MariaDB.

Installation

Exécutez la commande suivante:

yarn add @nestjs/typeorm typeorm mysql

Puis modifiez votre fichier app.module.ts :

import { Module } from '@nestjs/common';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthorModule } from './author/author.module';
import { ArticleModule } from './article/article.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mariadb',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: '',
      database: 'tuto_nest',
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      synchronize: true,
    }),
    AuthorModule,
    ArticleModule,
  ],
  controllers: [],
  providers: [AppService],
})
export class AppModule {}

Création des entités

Nous allons maintenant générer nos modules pour gérer les auteurs et les articles de blog, puis y créer nos entités.

nest g module author

nest g module article

Ces commandes vont créer deux dossiers author et article contenant chacun un module. Vous remarquerez par la même occasion que le CLI a non seulement géré la création des modules, mais s’est aussi chargé d’importer les modules dans le app.module.ts.

Créez ensuite les fichiers author.entity.ts et article.entity.ts dans leur dossier respectif, et ajoutez-y leur modèle de données.

import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';
import { Author } from '../author/author.entity';

@Entity()
export class Article {
  @PrimaryGeneratedColumn()
  id: number;

  @Column('text')
  content: string;

  @Column({
    type: 'datetime',
    nullable: true,
  })
  publishedDate: string;

  @Column({
    default: 0,
  })
  likes: number;

  @ManyToOne(() => Author, author => author.articles)
  author: Author;
}
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Article } from '../article/article.entity';

@Entity()
export class Author {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column({
    nullable: true,
  })
  avatarURL: string;

  @OneToMany(() => Article, article => article.author)
  articles: Article[];
}

Communication avec la base de données

La communication avec notre base de données se fera à l’aide de services. Créons deux services pour les modules article et author.

nest g service author

nest g service article

Ajoutons maintenant les méthodes pour créer et lister chacune de ces entités.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Article } from './article.entity';
import { Repository } from 'typeorm';

@Injectable()
export class ArticleService {
  constructor(
    @InjectRepository(Article)
    private readonly articleRepository: Repository<Article>,
  ) {}

  create(article: Article): Promise<Article> {
    return this.articleRepository.save(article);
  }

  findAll(): Promise<Article[]> {
    return this.articleRepository.find();
  }
}
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Author } from './author.entity';
import { Repository } from 'typeorm';

@Injectable()
export class AuthorService {
  constructor(
    @InjectRepository(Author)
    private readonly authorRepository: Repository<Author>,
  ) {}

  create(author: Author): Promise<Author> {
    return this.authorRepository.save(author);
  }

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

Créons maintenant nos routes, pour cela nous devons générer des contrôleurs.

nest g controller article

nest g controller author

Créons maintenant dans chaque contrôleurs une route pour créer et lister nos données.

import { Controller, Get, Post, Body } from '@nestjs/common';
import { ArticleService } from './article.service';
import { Article } from './article.entity';

@Controller('articles')
export class ArticleController {
  constructor(private readonly articleService: ArticleService) {}

  @Get()
  getArticles() {
    return this.articleService.findAll();
  }

  @Post()
  createArticle(@Body() body: Article) {
    return this.articleService.create(body);
  }
}
import { Controller, Get, Post, Body } from '@nestjs/common';
import { AuthorService } from './author.service';
import { Author } from './author.entity';

@Controller('authors')
export class AuthorController {
  constructor(private readonly authorService: AuthorService) {}

  @Get()
  getAuthors() {
    return this.authorService.findAll();
  }

  @Post()
  createAuthor(@Body() body: Author) {
    return this.authorService.create(body);
  }
}

Testons maintenant le bon fonctionnement de ces routes. Créons un auteur grâce à la commande cURL suivante:

curl --request POST --url http://localhost:3000/authors --header 'content-type: application/json' --data '{ "username": "someone" }'

qui devrait renvoyer, si tout a fonctionné correctement

{ "username": "someone", "avatarURL": null, "id": 1 }

Créons maintenant un article associé à notre auteur:

curl --request POST --url http://localhost:3000/articles --header 'content-type: application/json' --data '{ "content": "Hello world", "author": 1 }'

qui devrait renvoyer

{ "content": "Hello world", "author": 1, "publishDate": null, "id": 2, "likes": 0 }

Vous pouvez en plus de cela vérifier la présence de ces données dans la base de données.

Ajout du module CRUD

En l’état, notre API peut être fonctionelle si on y ajoute toutes les routes pour mettre à jour une entité, en récupérer une, et en supprimer. Néanmoins cela peut s’avérer assez fastidieux. NestJS fournit un module qui gère tous ces aspects, rendant le code bien plus léger.

Ajoutons le module en question.

yarn add @nestjsx/crud @nestjsx/crud-typeorm class-transformer class-validator

Modifions maintenant nos services et contrôleurs.

// article.controller.ts
import { Controller } from '@nestjs/common';
import { ArticleService } from './article.service';
import { Article } from './article.entity';
import { Crud } from '@nestjsx/crud';

@Crud({
  model: {
    type: Article,
  },
  query: {
    join: {
      author: {
        eager: true,
      },
    },
  },
})
@Controller('articles')
export class ArticleController {
  constructor(public service: ArticleService) {}
}

// article.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Article } from './article.entity';
import { TypeOrmCrudService } from '@nestjsx/crud-typeorm';

@Injectable()
export class ArticleService extends TypeOrmCrudService<Article> {
  constructor(@InjectRepository(Article) article) {
    super(article);
  }
}
// author.controller.ts
import { Controller } from '@nestjs/common';
import { AuthorService } from './author.service';
import { Author } from './author.entity';
import { Crud } from '@nestjsx/crud';

@Crud({
  model: {
    type: Author,
  },
  query: {
    join: {
      articles: {
        eager: true,
      },
    },
  },
})
@Controller('authors')
export class AuthorController {
  constructor(private readonly service: AuthorService) {}
}

// author.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Author } from './author.entity';
import { TypeOrmCrudService } from '@nestjsx/crud-typeorm';

@Injectable()
export class AuthorService extends TypeOrmCrudService<Author> {
  constructor(@InjectRepository(Author) author) {
    super(author);
  }
}

⚠️ Le nom de la variable service dans le constructeur des contrôleurs est important, un autre nom provoquera une erreur.

Vous pouvez maintenant de nouveau tester les mêmes commandes cURL citées plus haut et vous obtiendrez un résulat identique.

On peut noter la présence de

query: {
  join: {
    articles: {
      eager: true,
    },
  },
},

Grâce à ça, la jointure entre un article et un auteur se fera correctement. En effet, la requête SQL générée applique un LEFT JOIN entre les tables article et author. Il est possible d’ajouter d’autres options, par exemple la propriété exclude ne renverra pas les champs qui y sont listés.

Le module CRUD fournit aussi la possibilité d’effectuer une recherche, du filtrage, du tri, de la pagination au sein de la route de listing grâce aux paramètres dans l’URL. Pour la recherche, il suffit d’ajouter le paramètre s, avec une valeur au format JSON. Ainsi:

http://localhost:3000/authors?s={"username":"some_username"}

va effectuer une recherche sur la table auteur dont le champ username vaut some_username.

On constate donc que ce module effectue tout le travail nécessaire pour une API implémentant du CRUD. La documentation très fournie montre aussi à quel point ce module est complet.

Téléchargez le fichier JSON pour l’application Insomnia pour avoir un espace de travail contenant toutes les routes en cliquant ici.

Documentation avec Swagger

Maintenant que nous avons les routes nécessaires à notre API, il peut être intéressant de mettre en place de la documentation à l’aide de Swagger. Tout d’abord, installons de module Swagger pour NestJS.

yarn add @nestjs/swagger swagger-ui-express

Initialisons maintenant Swagger, rendez-vous dans le fichier main.ts.

import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const options = new DocumentBuilder()    .setTitle('Tuto nest')    .setDescription('API articles et auteurs')    .setVersion('1.0')    .addTag('tuto-nest')    .build();  const document = SwaggerModule.createDocument(app, options);  SwaggerModule.setup('swagger', app, document);  await app.listen(3000);
}
bootstrap();

Ouvrez votre navigateur sur http://localhost:3000/swagger, vous pourrez voir toutes les routes accessibles sur notre API. Néanmoins, celles-ci apparaissent sous une catégorie “Défaut”. Pour palier à cela, nous allons décorer nos contrôleurs à l’aide du décorateur ApiTags.

// article.controller.ts
import { Controller } from '@nestjs/common';
import { ArticleService } from './article.service';
import { Article } from './article.entity';
import { Crud } from '@nestjsx/crud';
import { ApiTags } from '@nestjs/swagger';
@Crud({
  model: {
    type: Article,
  },
  query: {
    join: {
      author: {
        eager: true,
      },
    },
  },
})
@ApiTags('articles')@Controller('articles')
export class ArticleController {
  constructor(public service: ArticleService) {}
}
// author.controller.ts
import { Controller } from '@nestjs/common';
import { AuthorService } from './author.service';
import { Author } from './author.entity';
import { Crud } from '@nestjsx/crud';
import { ApiTags } from '@nestjs/swagger';
@Crud({
  model: {
    type: Author,
  },
  query: {
    join: {
      articles: {
        eager: true,
      },
    },
  },
})
@ApiTags('authors')@Controller('authors')
export class AuthorController {
  constructor(private readonly service: AuthorService) {}
}

Rafraîchissez, et vous devriez avoir 2 catégories: authors et articles. A la suite de la définition des routes, on peut retrouver les schémas Article et Author qui sont malheureusement vide. Pour palier à cela, on peut faire en sorte que Swagger génère automatiquement les schémas. Pour cela, rendez-vous dans le fichier nest-cli.json à la racine du projet.

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {    "plugins": ["@nestjs/swagger/plugin"]  }}

Relancez votre serveur, puis la page Swagger. Les schémas sont encore vide. Cela est dû aux relations One to many et Many to one de nos entités. Règlons ce problème.

// article.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';
import { Author } from '../author/author.entity';
import { ApiProperty } from '@nestjs/swagger';
@Entity()
export class Article {
  @PrimaryGeneratedColumn()
  id: number;

  @Column('text')
  content: string;

  @Column({
    type: 'datetime',
    nullable: true,
  })
  publishDate: string;

  @Column({
    default: 0,
  })
  likes: number;

  @ApiProperty({ type: () => Author })  @ManyToOne(() => Author, author => author.articles, {
    onDelete: 'CASCADE',
  })
  author: Author;
}
// author.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Article } from '../article/article.entity';
import { ApiProperty } from '@nestjs/swagger';
@Entity()
export class Author {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column({
    nullable: true,
  })
  avatarURL: string;

  @ApiProperty({ type: () => Article })  @OneToMany(() => Article, article => article.author)
  articles: Article[];
}

De nouveau, relancez le serveur puis retournez sur la page web de Swagger, vos schémas sont maintenant remplis. Comme on peut le voir, malgré la possibilité de laisser Swagger gérer automatiquement les schémas, il est possible de décrire certaines propriétés manuellement.

Swagger

Il est possible, si vous le souhaitez, de récupérer le JSON généré par swagger. Pour cela, il suffit d’ajouter -json à la fin de la route Swagger. Dans notre cas, il suffit d’accéder à http://localhost:3000/swagger-json.

En conclusion

NestJS nous permet de créer une API REST implémentant du CRUD en un temps record, le tout avec un outillage très fourni (TypeScript, scaffolding avec le CLI). Les nombreux modules fourni via des librairies tierces maintenues par Nest nous permet d’implémenter rapidement des systèmes de queues, d’authentification, de cache, etc. Je vous invite fortement à parcourir en long et en large la documentation de NestJS qui est très bien réalisée et couvre de nombreux sujets techniques souvent abordés lors du développement d’API.

Continuer la discussion sur Twitter