Blog Zenika

#CodeTheWorld

Autres

Créer une application Rest Node.js avec Ts.ED et Prisma 2

Introduction

J’ai travaillé récemment sur l’intégration de Prisma 2 avec le framework Ts.ED et j’ai été vraiment impressionné par le travail réalisé par l’équipe de Prisma sur l’ORM et les outils qui sont autour.

Dans cet article je vais donc, vous l’aurez compris, vous présenter Prisma et Ts.ED et comment développer notre application Node.js Rest ! Voici les éléments qui seront concrètement abordés :

  • Initialiser une application avec Ts.ED CLI
  • Générer le fichier de configuration Prisma
  • Exposer notre API Rest . Comment la documenter pour que nos clients puissent la consommer.

Prisma.io

Prisma est un projet open-source orienté Node.js. Il supporte aussi bien JavaScript que TypeScript. Comme tout ORM, l’idée est de ne pas écrire de requête SQL et de supporter plusieurs types de base de données comme c’est le cas avec ses concurrents Knex.js, TypeORM ou Sequelize à ceci près qu’il va générer du code spécifique à la base de données configurée là où ses concurrents font de l’abstraction. Nous y reviendrons plus en détail par la suite 😉 

Actuellement, Prisma supporte PostgreSQL, MySQL, SQL Server et SQLite. Le support de Mongo est déjà planifié!

Ts.ED

Ts.ED est également un projet open-source orienté Node.js / TypeScript basé sur Express.js / Koa.js. Le framework va vous aider à réaliser une application côté serveur REST facilement et rapidement, en restant centré métier. Si vous souhaitiez un projet prêt à l’emploi ou « from-scratch », Ts.ED vous guidera.

Actuellement, Ts.ED supporte uniquement Express.js et Koa.js mais permet de supporter d’autres frameworks back-end. Le support de Fastify est d’ores et déjà planifié.

Étape 1 : Création de l’application avec le CLI de Ts.ED

Nous allons utiliser le CLI de Ts.ED pour créer notre application. Commençons par lancer les lignes de commande suivantes :

npm install -g @tsed/cli
tsed init tsed-prisma

Pour cet exercice, nous allons utiliser les éléments suivants:

  • Express
  • Database : Prisma (évidemment)
  • Swagger

En complément, vous pouvez ajouter Jest / EsLint et Prettier.

Note : Au moment de sélectionner Prisma, le CLI vous demandera d’entrer un Token Github. Laissez le champ à vide, nous n’utiliserons pas le package premium.

Vous devriez avoir les éléments suivants dans le CLI à la fin du prompt:

Note : Vous pouvez choisir votre package manager préféré! 

Si tout est correct, vous pouvez ouvrir le projet généré dans votre IDE favori.

Le CLI aura déjà généré le projet et en bonus, il aura également lancé les commandes d’initialisation de Prisma (pour info npx prisma init). Vous devriez donc avoir le fichier schema.prisma comme suit:

Ce fichier est très important ^^. Il va être votre source de vérité concernant les éléments suivants:

  • La connexion à votre base de données,
  • L’ajout de plug-in pour générer un tas d’autres choses, par exemple, le client JavaScript ou TypeScript,
  • La modélisation de vos tables donc autrement dit vos modèles. Prisma en déduira les règles de migration à appliquer à vos tables quand c’est possible!

On commence à percevoir un peu la puissance de l’outil non ?

Étape 2 : Configuration de la connexion Prisma

Pour notre exercice, nous allons utiliser une base de données SQLite. C’est assez simple à l’usage et ne nécessite pas d’installer une base de données ou de mettre en place un docker. Pour ça, il nous faut éditer le fichier schema.prisma et indiquer le bon provider (sqlite) et l’url suivante “file:./dev.db”. Vous devriez avoir cette configuration:

datasource db {
  provider = "sqlite"
  url      =  "file:./dev.db"
}

Étape 3 : Création d’une table

Je vous l’avais dit, le fichier `schema.prisma` est notre source de vérité. C’est donc dans ce même fichier que nous allons rajouter à la suite les lignes suivantes :

model User {
  id    Int     @default(autoincrement()) @id
  email String  @unique
  name  String?
}

Ici, nous allons déclarer une table User avec 3 champs (id, email, name). Je trouve que là aussi Prisma a très bien travaillé. La syntaxe est simple et suffisamment compréhensible pour ne pas avoir à se référer systématiquement dans la documentation! 

Enfin pour les curieux ça se passe ici: https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference

Il nous faut maintenant générer les instructions SQL qui vont refléter notre nouvelle configuration. Lancez donc la commande suivante:

npx prisma migrate dev --name init

Cette commande va générer les fichiers de migrations comme suit:

$ tree prisma
prisma
├── dev.db
├── migrations
│   └── 20210507100915_init
│       └── migration.sql
└── schema.prisma

Étape 4 : Générer le client Prisma

Le Prisma Client est notre package généré par le CLI en fonction du fichier schema.prisma. Il génère que le strict minimum, c’est-à-dire l’utilitaire de connexion à la base de données, les classes et les fichiers de définition pour TypeScript.

Le package @prisma/client étant déjà installé par Ts.ED CLI, vous n’avez plus qu’à lancer la commande suivante:

npx prisma generate

La commande que nous venons d’exécuter lit notre schema.prisma et génère tout ce qu’il nous faut pour interagir avec la base de données dans le dossier suivant node_modules/@prisma/client. Voici ce qui vous devez obtenir:

Le cycle est le suivant: Chaque fois que vous modifiez la structure des tables, vous devez lancer la commande précédente pour prendre en compte vos modifications.

Une fois que vous êtes satisfait du résultat, vous devez exécuter  npx prisma migrate afin de générer les scripts SQL de migration de votre base de données. Simple ^^.

Étape 5 : Création d’un Repository

Ts.ED possède un injecteur de dépendance. Cette mécanique est très pratique dans le cadre de développement d’application et surtout dans la rédaction d’un test unitaire (on peut mocker les dépendances, tout ça, tout ça…). 

Ici, nous allons utiliser le PrismaService déjà généré par Ts.ED CLI et l’injecter dans notre UsersRepository. L’idée derrière le repository est de découpler la couche d’accès à notre table User du reste de notre application. 😉

Commençons par utiliser la commande suivante:

tsed g repository Users

Ensuite éditez le fichier src/services/UsersRepository.ts, dont voici son contenu:

import {Injectable} from "@tsed/di";
@Injectable()
export class UsersRepository {}

Complétez le fichier pour qu’il ressemble à ça:

import { User } from "@prisma/client";
import {Inject, Injectable} from "@tsed/di";
import {PrismaService} from "./PrismaService";

@Injectable()
export class UsersRepository {
  @Inject()
  protected prisma: PrismaService;

  get collection() {
    return this.prisma.user;
  }

  async getAll(): Promise<User[]> {
    return this.collection.findMany();
  }
}

Explication: La méthode findMany nous permet de récupérer l’intégralité des utilisateurs contenu dans notre table. Nous utilisons les décorateurs comme @Inject pour injecter le PrismaService (notre utilitaire de connexion) et @Injectable pour indiquer que notre UsersRepository peut être injecté dans d’autres classes.

Étape 6 : Création du Controller

Maintenant, nous allons injecter notre UsersRepository dans un nouveau controller et exposer la liste des utilisateurs à notre front-end.

Lancez à nouveau une commande pour générer cette fois-ci le UsersController:

tsed g controller users

Voici le résultat attendu:

Et ensuite, complétez le fichier avec :

import {Inject} from "@tsed/di";
import {UsersRepository} from "../services/UsersRepository";
import {Returns, Summary} from "@tsed/schema";

@Controller("/users")
export class UsersController {
  @Inject()
  protected repository: UsersRepository;

  @Get("/")
  @Summary("Return all users")
  @Returns(200)
  getAll() {
     return this.repository.getAll();
  }
}

C’est le moment de vérité! Nous allons enfin pouvoir lancer le serveur et voir le résultat dans le Swagger. Lancez la commande `npm run start` et ensuite ouvrez la page swagger (http://localhost:8083/v3/docs.):

Voici notre route fraîchement créée:

N’hésitez pas à la tester. Bien entendu il n’y a aucune donnée 😉 mais la connexion à la base est opérationnelle !

Pour remplir la table nous pouvons rajouter la méthode d’insertion à notre repository comme suit:

import {User} from "@prisma/client";
import {Inject, Injectable} from "@tsed/di";
import {PrismaService} from "./PrismaService";

@Injectable()
export class UsersRepository {
  @Inject()
  protected prisma: PrismaService;

  get collection() {
     return this.prisma.user;
  }

  async create(user: Omit<User, "id">): Promise<User> {
     return this.collection.create({data: user});
  }

  async getAll(): Promise<User[]> {
     return this.collection.findMany();
  }
}

Et vous l’aurez compris, vous devez également ajouter la méthode côté Controller :

import {BodyParams, Controller, Get, Post} from "@tsed/common";
import {Inject} from "@tsed/di";
import {UsersRepository} from "../services/UsersRepository";
import {Returns, Summary} from "@tsed/schema";
import {User} from "@prisma/client";

@Controller("/users")
export class UsersController {
  @Inject()
  protected repository: UsersRepository;

  @Post("/")
  @Summary("Add a new user")
  @Returns(201).Description("User created")
  create(@BodyParams() user: Omit<User, "id">) {
     return this.repository.create(user);
  }
  // ….
}

Ce qui nous donne le résultat suivant dans Swagger :

Testez l’ajout d’un utilisateur à la base de données et rejouez la route GET /rest/users. Vous devriez avoir un résultat comme suit: 

POST /rest/usersGET /rest/users

Les plus perspicaces remarqueront que Swagger n’expose pas notre modèle User en “example value”. C’est là où nous atteignons la limite (en l’état) du Prisma Client. Ce dernier génère uniquement des interfaces TypeScript qui ne permettent pas à Ts.ED (ou n’importe quel autre framework) d’inférer une valeur d’exemple depuis cette dernière. 

Dans la mécanique TypeScript une interface n’a pas de consistance après compilation, il est donc difficile d’en tirer quoi que ce soit ;). Dans l’immédiat, nous devons déclarer manuellement une classe UserModel (ou un JsonSchema) pour fournir notre documentation Swagger. 

Et hop, une petite commande pour générer notre classe :

tsed generate model user

Ce qui nous donne le contenu suivant:

import {Property} from "@tsed/schema";

export class UserModel {
  @Property()
  id: string;
}

Complétons maintenant la description de notre classe:

import {User} from "@prisma/client";
import {Nullable, Email, Groups, MinLength, Required} from "@tsed/schema";

export class UserModel implements User {
  @Groups("!creation")
  id: number;

  @Email()
  @Required()
  email: string;

  @Nullable(String)
  @MinLength(3)
  name: string | null;
}

Vous noterez que nous implémentons l’interface User pour garder une synchronisation entre l’interface générée par Prisma et notre classe intermédiaire. De cette façon, lors de l’ajout d’une nouvelle colonne à notre table User, nous aurons une exception levée par TypeScript à la compilation du projet ;).

Corrigeons maintenant notre controller :

import {BodyParams, Controller, Get, Post} from "@tsed/common";
import {Inject} from "@tsed/di";
import {UsersRepository} from "../services/UsersRepository";
import {Groups, Returns, Summary} from "@tsed/schema";
import {UserModel} from "../models/UserModel";

@Controller("/users")
export class UsersController {
  @Inject()
  protected repository: UsersRepository;

  @Post("/")
  @Summary("Add a new user")
  @Returns(201, UserModel).Description("User created")
  create(@BodyParams() @Groups("creation") user: UserModel) {
    return this.repository.create(user);
  }

  @Get("/")
  @Summary("Return all users")
  @Returns(200, UserModel)
  getAll() {
    return this.repository.getAll();
  }
}

A vous de jouer pour le jeu des 7 différences 😉

Voici les résultats attendus :

POST /rest/usersGET /rest/users

Explications:

Le décorateur @Groups permet d’adapter les champs exposés de notre modèle User en fonction du endpoint. C’est pour ça, que la propriété id n’apparaît sur le POST en input, mais apparaît sur l’output du POST et du GET.

Les autres décorateurs appliqués au modèle tels que Email, Required ou MinLength vont ajouter des contraintes de validation sur nos données envoyées à l’appel du POST.

On a donc plusieurs plusieurs raisons de déclarer notre modèle : 

  • La documentation,
  • La validation des données,
  • Le mapping des données. Ts.ED s’assure que chaque donnée est mappée vers le type attendu (Voir la petite fiche ici pour le mapping des données.)

Les deux derniers points apportent une sécurisation de notre API en n’autorisant et ne mappant que les données attendues par nos endpoints 😉

Notre exercice se termine là, mais parlons maintenant de Prisma Client pour conclure tout ça!

À propos de Prisma Client

L’approche de Prisma est vraiment ingénieuse. Effectivement, utiliser un unique fichier de référence pour générer nos modèles et notre client JavaScript / TypeScript permet un gain de temps non négligeable dans l’initialisation de notre projet. 

On regrettera cependant, qu’il faille déclarer une seconde fois les modèles pour Ts.ED afin d’avoir une documentation plus fournie côté Swagger et aussi un meilleur contrôle des données en entrées. Enfin ça, c’est sans prendre en compte le fait que Prisma propose un système de plug-in !

Et oui grâce aux plug-ins fournis par Prisma ou la communauté on peut faire les choses suivantes :

Vous l’aurez compris, Ts.ED propose aussi son propre plug-in pour générer les modèles et repositories automatiquement avec toutes les règles de validation qui vont bien. Cependant à l’heure où je vous écris le plug-in est uniquement en earlier access pour les sponsors (et oui faut bien se subventionner un peu 😉 ).

Voici un exemple :

model User {
  /// @TsED.Groups("!creation")
  /// Comment
  id          Int      @id @default(autoincrement())
  createdAt   DateTime @default(now())
  /// @TsED.Email()
  /// @TsED.Description("User email. This email must be unique!")
  email       String   @unique
  weight      Float?
  is18        Boolean?
  name        String?
  successorId Int?
  successor   User?    @relation("BlogOwnerHistory", fields: [successorId], references: [id])
  predecessor User?    @relation("BlogOwnerHistory")
  role        Role     @default(USER)
  posts       Post[]
  keywords    String[]
  biography   Json
}

Générera le modèle Ts.ED suivant :

import { User } from "@prisma/client";
import { Integer, Required, Property, Groups, Format, Email, Description, Allow, Enum, CollectionOf } from "@tsed/schema";
import { Role, PostModel } from "@tsedio/prisma";

export class UserModel implements User {
  @Property(Number)
  @Integer()
  @Required()
  @Groups("!creation")
  id: number;

  @Property(Date)
  @Format("date-time")
  @Required()
  createdAt: Date;

  @Property(String)
  @Required()
  @Email()
  @Description("User email. This email must be unique!")
  email: string;

  @Property(Number)
  @Allow(null)
  weight: number | null;

  @Property(Boolean)
  @Allow(null)
  is18: boolean | null;

  @Property(String)
  @Allow(null)
  name: string | null;

  @Property(Number)
  @Integer()
  @Allow(null)
  successorId: number | null;

  @Property(() => UserModel)
  @Allow(null)
  predecessor: UserModel | null;

  @Required()
  @Enum(Role)
  role: Role;

  @CollectionOf(() => PostModel)
  @Required()
  posts: PostModel[];

  @CollectionOf(String)
  @Required()
  keywords: string[];

  @Property(Object)
  @Required()
  biography: any;
}

Et le repository suivant:

import { isArray } from "@tsed/core";
import { deserialize } from "@tsed/json-mapper";
import { Injectable, Inject } from "@tsed/di";
import { PrismaService } from "../services/PrismaService";
import { Prisma, User } from "../client";
import { UserModel } from "../models";

@Injectable()
export class UsersRepository {
  @Inject()
  protected prisma: PrismaService;

  get collection() {
    return this.prisma.user
  }

  get groupBy() {
    return this.collection.groupBy.bind(this.collection)
  }

  protected deserialize<T>(obj: null | User | User[]): T {
    return deserialize<T>(obj, { type: UserModel, collectionType: isArray(obj) ? Array : undefined })
  }

  async findMany(args?: Prisma.UserFindManyArgs): Promise<UserModel[]> {
    const obj = await this.collection.findMany(args);
    return this.deserialize<UserModel[]>(obj);
  }

  //…..
}

De fait, on gagne encore en rapidité de développement! 

Le plug-in est totalement opérationnel. Si vous êtes intéressé par le plug-in ça se passe ici : Ts.ED Prisma Client plug-in.

Limitation ?

On vient de voir que Prisma offre une vraie fluidité dans notre workflow de dev. Avec le plugin Ts.ED Prisma, il est possible de gagner encore plus en productivité et on se demande bien si toute cette magie ne cache pas un loup ^^.

Il y a effectivement un point de vigilance à avoir. Prisma est parfait quand il s’agit d’une création de projet, il s’adapte aussi à des projets existants. Le CLI offre une commande qui va lire la base de données et générer le schema.prisma en conséquence (pratique). 

Le point de vigilance est justement sur son utilisation avec un projet existant. Évaluez bien quelles sont les applications qui consomment la base de données. Voici les cas à bien prendre en compte:

  • La base de données est utilisée par un CMS (no GO selon moi)
  • La base de données est utilisée par plusieurs applications (vigilance)

Pourquoi?

Prisma introduit son propre système de migration SQL. C’est vraiment génial dans un nouveau projet, ça devient potentiellement un casse-tête pour une base de données déjà exploitée et consommée par plusieurs applications. Pensez au cycle de vie de chacune de ces applications qui ne comprendront pas forcément les changements de structure opérés par Prisma de façon automatique 😉

Dans le cas d’un CMS c’est évident qu’il faut éviter / limiter fortement / bannir son usage, la structure des tables étant à la main de l’éditeur du CMS.

Et ensuite?

Maintenant, vous pouvez améliorer la base du projet. Ts.ED supporte différents types de features:

Enfin, vous pouvez récupérer le projet depuis ce lien:

https://github.com/Zenika/tsed-prisma-example

Liens utiles

Une réflexion sur “Créer une application Rest Node.js avec Ts.ED et Prisma 2

  • unvieux dev

    Article intéressant, adapté à la technologie du moment.
    Mais je reste songeur. Il y a 20 ans, on faisait ça en quelques clics, sans besoin de connaissances, sans terminal.
    On faisait les applis dans un AGL wysiwyg avec un seul langage de programmation, en concevant les IHM à la souris et en modifiant facilement des propriétés accessibles.
    Est-ce que le niveau de complexité incroyable dans lequel on est tombé valait le coup ?
    Je me demande parfois dans quel sens va le progrès.

    Répondre

Répondre à unvieux devAnnuler la réponse.

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

En savoir plus sur Blog Zenika

Abonnez-vous pour poursuivre la lecture et avoir accès à l’ensemble des archives.

Continue reading