Serverpod, la solution pour développer un backend avec Dart - Partie 1

Introduction

Serverpod propose une multitude de fonctionnalités visant à simplifier le développement serveur :

  • ORM (Object-Relational Mapping) : Serverpod permet de définir des modèles en Dart, utilisés ensuite pour générer automatiquement les structures SQLcorrespondantes.
  • Génération des modèles SQL : En fonction des modèles définis, Serverpod génère le SQL nécessaire pour créer et gérer les tables dans la base de données.Opérations CRUD générées  Toutes les opérations CRUD Insert, Find, Update, Delete) sont générées automatiquement, facilitant la gestion des données.
  • Endpoints configurables : Les endpoints, permettant dʼinteragir avec la base de données, sont entièrement configurables en Dart en utilisant les opérationsCRUD générées, offrant une flexibilité maximale.

Dans cet article divisé en plusieurs parites, nous explorerons lʼimplémentation de Serverpod à travers un projet dʼexemple, en mettant en lumière ses forces etfaiblesses, notamment lʼauthentification, la définition du modèle de données, le développement des endpoints, et la liaison avec une application Flutter.

Lʼobjectif de se projet sera donc de créer une application où lʼutilisateur devra sʼidentifier afin de pouvoir de pouvoir interagir avec la base de données que nousaurons définis. Pour lʼexemple lʼapplication sera un registre de Jedi et Sith issus de Star Wars et sera nommée “Star Wars Podˮ.

L’authentification

Serverpod offre plusieurs possibilités pour l’implémentation de l’authentification au sein d’un projet :

  1. l’authentification email et mot de passe
  2. l’authentification avec Google
  3. l’authentification avec Apple
  4. l’authentification avec Firebase

Pour ce projet exemple nous implémenterons celle par email et mot de passe. Serverpod propose d’utiliser des widgets prédéfinis pour cette méthode d’authentification nommée SignInWithEmailButton(). Néanmoins, comme la plupart des applications intègrent leur propre design pour la partie d’authentification, nous coderons la version custom qui nous permettra également d’appréhender tous les aspects offerts Serverpod sur ce concept.

Pour démarrer, nous partons du principe que votre projet Serverpod est créé. Pour ce faire, vous pouvez suivre ce tutoriel.

1. Implémentation des dépendances

En ayant créé votre projet Serverpod, vous obtenez trois répertoires, un pour le client, un pour le serveur, et un autre pour notre application Flutter.

Il y a donc un fichier pubspec.yaml par répertoire, ajouter les dépendances comme suit :

# Pubspec du répertoire Server
dependencies:
serverpod: 2.0.1
serverpod_auth_server: 2.0.1 # authentification server
# Pubspec du répertoire Client
dependencies:
serverpod_client: 2.0.1
serverpod_auth_client: 2.0.1 # authentification client
# Pubspec du répertoire Flutter
dependencies:
serverpod_auth_shared_flutter: 2.0.1 # implémente l'authentification global
serverpod_auth_email_flutter: 2.0.1 # donne accès aux méthodes d'authentification
serverpod_auth_client: 2.0.1 # donne accès à un modèle de données UserInfo pour gérer les donnée de l'utilisateur connecté

Puis effectuez un flutter pub get sur chacun des répertoires.

2. Configuration côté Server

Ajoutez authenticationHandler à votre objet Serverpod comme suit :

final pod = Serverpod( args,
Protocol(),
Endpoints(),
// ajouter cette ligne afin d'initialiser la partie authentification // de votre server
authenticationHandler: auth.authenticationHandler,
);

Puis configurez votre objet AuthConfig avant le démarrage de votre serveur afin de gérer dans notre cas les validations de création de comptes et de réinitialisationde mots de passe :

auth.AuthConfig.set(auth.AuthConfig(
minPasswordLength: 12,
sendValidationEmail: (session, email, validationCode) async {
print(
'Validation code (account creation): $validationCode',
);
return true; },
sendPasswordResetEmail: (session, userInfo, validationCode) async { print(
'Validation code (change password): $validationCode', );
return true; },
));
// Start the server. await pod.start();

Ces callbacks nous permettront de vérifier lʼemail de notre utilisateur avec un code de vérification qui leur sera envoyé sur leur boite mails lors dʼune création decompte ou dʼune réinitialisation de leur mot de passe. Afin de ne pas se compliquer la tâche ici, nous allons juste print() ces derniers. Pour ceux qui souhaitent allerplus loin, vous pouvez implémenter un envoi de mail en ajoutant et utilisant le package mailer sur votre serveur par exemple.

De plus, dans le fichier config/generator.yaml, ajoutez ceci :

modules: serverpod_auth:
nickname: auth

Cette manipulation nous permet de renommer la module serverpod_auth que nous devrons importer plus tard dans notre modèle de données.
Maintenant que notre serveur et que les bonnes dépendances ont été correctement ajoutées dans les autres répertoires, effectuons les lignes de commandes suivantes à la racine de notre projet serveur :

  1. serverpod generate pour générer le SQL relatif à un compte utilisateur
  2. serverpod create-migration ou serverpod create-migration --force si une erreur se produit afin créer une migration de notre base de données avec les donnéesutilisateurs
  3. docker-compose up --build --detach pour démarrer notre docker Serverpod
  4. dart run bin/main.dart --role maintenance --apply-migrations afin de démarrer le notre serveur en appliquant notre migration précédente

Si vous parvenez à exécuter tout ceci avec succès cʼest que votre serveur est correctement configuré !

3. Configuration côté application Flutter

a. Interfaçage avec la base de données

Commençons par implémenter notre Client et notre SessionManager pour respectivement avoir accès à notre API (et plus particulièrement à nos endpoints que nous intègrerons dans la suite) et à lʼauthentification dans notre application.

Dans notre main.dart, ajoutons donc un singleton pour chacun de ces deux objets et initialisons tout ceci comme suit :

late Client client;
late SessionManager sessionManager;
void main() async { client = Client(
'http://$localhost:8080/',
authenticationKeyManager: FlutterAuthenticationKeyManager(), )..connectivityMonitor = FlutterConnectivityMonitor();
sessionManager = SessionManager( caller: client.modules.auth,
);
await sessionManager.initialize();
runApp(const MyApp()); }

b. Création du widget dʼauthentification

Maintenant, développons une interface simpliste pour se connecter ou créer un compte, je le nomme AuthWidget :

import 'package:flutter/material.dart';
class AuthWidget extends StatelessWidget { const AuthWidget({
super.key,
required this.emailController, required this.usernameController, required this.passwordController, required this.loginAction, required this.createAccountAction,
});
final TextEditingController emailController; final TextEditingController usernameController; final TextEditingController passwordController; final VoidCallback loginAction;
final VoidCallback createAccountAction;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [
TextField(
controller: emailController,
decoration: const InputDecoration(hintText: 'Email'),
), TextField(
controller: usernameController,
decoration: const InputDecoration(hintText: 'Email'), ),
TextField(
controller: passwordController,
decoration: const InputDecoration(hintText: 'Password'),
),
const SizedBox(height: 8), Row(
children: [ Expanded(
child: ElevatedButton( onPressed: loginAction, child: const Text("Login"),
), ),
const SizedBox(width: 8), Expanded(
child: ElevatedButton(
onPressed: createAccountAction, child: const Text("Create account"),
), ),
], ),
], );
} }<</pre>

c. Ajout des actions dʼauthentification

Dorénavant, afin dʼexécuter les méthodes dʼauthentification, nous allons avoir besoin dʼun objet EmailAuthcontroller à déclarer comme suit :

final EmailAuthController _authController 
= EmailAuthController(client.modules.auth);

Ce controller nous donne accès à plusieurs fonctions, nous utiliserons donc les suivantes :

  1. _authController.createAccountRequest(userName, email, password)
  2. _authController.validateAccount(email, verificationCode)
  3. _authController.signIn(email, password)

Notre AuthWidget prendra donc en arguments de ces deux callbacks loginAction et createAccountAction, respectivement ces deux fonctions :

loginAction: () async { setState(() {
_isLoading = true; });
final UserInfo? res = await _authController.signIn( _emailController.text,
_passwordController.text,
);
setState(() { _isLoading = false;
});
if (res == null) { ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Login failed, retry'),
), );
return; }
setState(() { _authenticatedUser = res;
}); }
createAccountAction: () async { setState(() {
_isLoading = true; });
final bool res = await _authController.createAccountRequest( _usernameController.text,
_emailController.text,
_passwordController.text,
);
setState(() { _isLoading = false;
});
if (res) { showDialog(
context: context,
builder: (_) => ValidateAccountDialog(
validationCodeController: _validationCodeController, accountValidateAction: () async =>
await _authController.validateAccount( _emailController.text, _validationCodeController.text,
), ),
); return;
}
ScaffoldMessenger.of(context).showSnackBar( const SnackBar(
content: Text('Error while creating account'), ),
); }

loginAction ne fait que mettre en place un loader durant le temps de réception de la réponse du signIn et rafraîchit la page en assignant _authenticatedUser .

createAccountAction met lui également en place un loader lors de lʼattente de la réponse de createAccountRequest , et si nous recevons un boolean à true, nous affichonsune dialog afin que lʼutilisateur vérifie son mail avec le code reçu (pour rappel, il sera simplement print par notre serveur dans cet exemple). Enfin, cette dialog jouerale validateAccount lors de la validation du code par lʼutilisateur avec un appui bouton. Pour une meilleure visualisation, le code de la dialog :

class ValidateAccountDialog extends StatefulWidget { const ValidateAccountDialog({
required this.validationCodeController, required this.accountValidateAction, super.key,
});
final TextEditingController validationCodeController; final Future Function() accountValidateAction;
@override
State createState() => _ValidateAccountDialogState(); }
class _ValidateAccountDialogState extends State { bool _isLoading = false;
@override
Widget build(BuildContext context) {
return Dialog( child: Padding(
padding: const EdgeInsets.all(16), child: Column(
mainAxisSize: MainAxisSize.min, children: [
TextFormField(
controller: widget.validationCodeController,
decoration: const InputDecoration(hintText: 'Validation code'),
),
const SizedBox(height: 8), ElevatedButton(
onPressed: () async { setState(() {
_isLoading = true; });
final UserInfo? res = await widget.accountValidateAction();
if (res != null) { Navigator.pop(context); return;
}
ScaffoldMessenger.of(context).showSnackBar( const SnackBar(
content: Text('Wrong code'), ),
); },
child: const Text('Validate account'), ),
if (_isLoading) const CircularProgressIndicator() ],
), ),
);
 } }
<</pre>

À ce stade, si à la racine de votre projet serveur vous démarrer votre Docker et votre serveur avec docker-compose up --build --detach et dart run bin/main.dart --apply-migrations vous devez être capable de créer un compte et de vous connecter comme sur cette démo.

Pour information, la page MyHomePage est développée comme suit :

class MyHomePage extends StatefulWidget { const MyHomePage({super.key});
@override
MyHomePageState createState() => MyHomePageState(); }
class MyHomePageState extends State { final EmailAuthController _authController =
EmailAuthController(client.modules.auth);
final TextEditingController _emailController = TextEditingController(); final TextEditingController _usernameController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); final TextEditingController _validationCodeController =
TextEditingController();
bool _isLoading = false; UserInfo? _authenticatedUser;
@override
Widget build(BuildContext context) {
return Scaffold( appBar: AppBar(
title: const Text("Star Wars Pod"), ),
body: SingleChildScrollView( child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16), child: Column(
mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [
if (_authenticatedUser == null) AuthWidget(
emailController: _emailController, usernameController: _usernameController, passwordController: _passwordController, loginAction: () async {
setState(() { _isLoading = true;
});
final UserInfo? res = await _authController.signIn( _emailController.text,
_passwordController.text,
);
setState(() { _isLoading = false;
});
if (res == null) { ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Login failed, retry'),
), );
return; }
setState(() { _authenticatedUser = res;
});
},
createAccountAction: () async {
setState(() { _isLoading = true;
});
final bool res = await _authController.createAccountRequest( _usernameController.text,
_emailController.text,
_passwordController.text,
);
setState(() { _isLoading = false;
});
if (res) { showDialog(
context: context,
builder: (_) => ValidateAccountDialog(
validationCodeController: _validationCodeController, accountValidateAction: () async =>
await _authController.validateAccount( _emailController.text, _validationCodeController.text,
), ),
);
return; }
ScaffoldMessenger.of(context).showSnackBar( const SnackBar(
content: Text('Error while creating account'), ),
); },
),
if (_isLoading) const CircularProgressIndicator(), if (_authenticatedUser != null)
Text(
'Hi ${_authenticatedUser?.userName}!',
style: Theme.of(context).textTheme.titleLarge,
), ],
), ),
), );
} }<</pre>

d. Récupération de session

Nous pouvons également avec Serverpod récupérer la session du dernier utilisateur connecté. Pour ce faire, ajoutons ces éléments à MyHomePage :

@override
void initState() {
super.initState(); setState(() {
_authenticatedUser = getLastSession(); });
sessionManager.addListener(() { setState(() {
_authenticatedUser = getLastSession(); });
}); }
UserInfo? getLastSession() => sessionManager.signedInUser;<</pre>

Nous en avons aussi profité pour ajouter un listener sur notre SessionManager afin de nous éviter dʼeffectuer des setState() dès lors que lʼon souhaite mettre à jour notre _authenticatedUser (vous pouvez donc supprimer ceux déjà utilisés à cet effet dans le code) ce qui nous sera fortement utile pour la gestion de sa déconnexion.

Si vous inspectez le code derrière sessionManager.signInUser , vous remarquerez quʼune clé dʼauthentification est sauvegardée dans les shared preferences de lʼapplication. Lors dʼune déconnexion de lʼutilisateur, cette clé sera naturellement effacée.

e. Déconnexion de lʼutilisateur

Afin de réaliser la déconnexion, nous allons simplement ajouter un IconButton dans notre AppBar qui réalisera un sessionManager.signOut() :

appBar: AppBar(
title: const Text("Star Wars Pod"), actions: [
IconButton( onPressed: () {
sessionManager.signOut(); },
icon: const Icon(Icons.logout_outlined), ),
], ),

Nous nous arrêterons ici pour ce projet en ce qui concerne lʼimplémentation de lʼauthentification. Si vous êtes intéressés par la réinitialisation du mot de passe, celaressemble énormément à la création de compte avec pour différence lʼutilisation des méthodes _authController.initiatePasswordReset(email) pour print le code devérification normalement envoyé par mail et _authController.resetPassword(email, verificationCode, password) pour définir le nouveau tout en spécifiant le code précédemment reçu. Vous pouvez également vous rendre sur cette partie de la documentation Serverpod si besoin.

4. Test des requêtes dʼauthentification avec Postman (bonus)

Afin de tester les fonctionnalités de notre serveur et notamment ici lʼauthentification sans avoir à exécuter notre application, nous utilisons régulièrement des outilscomme Postman. Néanmoins, cette utilisation nʼest pas poussée ni mise en avant par les développeurs de Serverpod comme le démontre ce ticket GitHub. Je nʼaiégalement à ce jour, rien trouvé à ce sujet dans la documentation du package. Néanmoins, cela ne veut pas dire que cʼest impossible ! En effet, après inspection ducode du package, jʼai pu déterminer comment tester tout ceci avec Postman.

Pour ce faire, il faut configurer la requête Postman comme suit (votre projet serveur doit bien évidemment être actif) :

Nous remarquons donc que nous sommes sur le endpoint http://localhost:8080/serverpod_auth.email , “serverpod_authˮ correspond au module que nous avons intégrédans notre pubsbec.yaml , et “emailˮ correspond au endpoint consacré à lʼauthentification par email. Nous spécifions aussi en paramètre du body de la requête laméthode “authenticate” ainsi que les arguments “email” et “password” .

De plus, nous recevons comme réponse, les userInfo (id, username, email...) et deux autres attributs : key et keyId . Ces derniers sont les informations stockées dansles shared préférences pour la récupération de la dernière session sous le format “$keyId:$key” . Souvenez-vous en car nous en aurons également besoin plus tardlors du développement de nos endpoints.

Nous pouvons aussi tester nos autres méthodes de la même manière en modifiant le body de notre requête pour une demande de création de compte et unevalidation :

Cela nous mêne donc à la fin de cette partie dʼauthentication avec Serverpod qui nous démontre une bonne maitrise de ce sujet par ce package avec uneimplémentation simple, efficiente et complète, notamment avec le déploiement possible des sign in with Google et Apple qui sont maintenant fortement répandusdans le monde du mobile en plus de lʼauthentification classique par mail.

Ainsi, nous continuerons lʼexploration de Serverpod avec la définition du modèle de données de notre application dans la prochaine partie de lʼarticle.

Quentin Lebreton - Ingénieur Études et Développement Junior