Temps de lecture : 9 minutes

Contexte

Avec l’arrivée de Flutter dans le monde du développement mobile, le langage Dart présenté comme un « Javascript Killer » il y a quelques années regagne de la vigueur et de l’engouement depuis la sortie de la version 2 (fortement utilisé chez Google pour leurs outils internes).

Dans ce contexte, le langage Dart se veut multi-plateforme : Web , Mobile et … Server.

C’est là qu’intervient le framework Aqueduct (https://aqueduct.io) que l’on peut comparer à Express pour NodeJS. Dans cet article nous allons voir comment créer une première API qui s’interface avec une base de données PostgreSQL.

Installation

Première étape : l’installation du SDK Dart.

Pour cela rien de plus simple que de suivre la documentation officielle (https://www.dartlang.org/tools/sdk#install).

Seconde étape : l’installation du CLI Aqueduct via la commande :

pub global activate aqueduct

pub est le « package manager » du langage Dart, on retrouve la liste des packages sur le site https://pub.dartlang.org/. Les librairies sont classées en fonction de leur utilisation Mobile avec le tag Flutter ou Web.

Création de l’API avec Aqueduct

Nous allons maintenant créer notre API grâce au CLI Aqueduct avec la commande suivante:

aqueduct create heroes

Aqueduct va créer un répertoire contenant notre API et va récupérer toutes les dépendances nécessaires.
Dans ce dossier nous allons retrouver le fichier pubspec.yaml qui regroupe l’environnement utilisé, la liste des dépendances et des dépendances de développement. Puis nous allons principalement nous intéresser à trois fichiers :

Le fichier bin/main.dart

import 'package:heroes/heroes.dart';



Future main() async {


  final app = Application<HeroesChannel>()

    ..options.configurationFilePath = "config.yaml"

    ..options.port = 8888;



  final count = Platform.numberOfProcessors ~/ 2;

  await app.start(numberOfInstances: count > 0 ? count : 1);


  print("Application started on port: ${app.options.port}.");

  print("Use Ctrl-C (SIGINT) to stop running the application.");

}

On retrouve la configuration du serveur tel que le port sur lequel va tourner l’API (8888 par défaut) mais également le nombre d’instances lancées. Et oui part défaut Aqueduct lance autant d’instances que votre machine possède de CPU ou core dans le cas d’une machine physique.

Le fichier lib/channel.dart

La méthode « prepare » permet d’initialiser les différents éléments nécessaires au démarrage comme par exemple la connexion à la BDD.
Le contrôleur « get entreyPoint » correspond au router de l’API, il va regrouper toutes les routes de notre API CRUD. Par défaut, une route est créée sur le endpoint « /example ».

Le fichier lib/heroes.dart

Ce fichier regroupe les exports des différents fichiers nécessaires au bon fonctionnement de votre future API.

Premier lancement

Pour servir votre API, on utilise la commande suivante:

aqueduct serve

Comme vous pouvez le voir dans le terminal, Aqueduct prépare et build votre API et exécute votre API en fonction de la configuration définie (exemple: PORT: 8888).
On remarque également que Aqueduct lance le nombre d’instances déterminées dans la configuration (par défaut autant d’instances que de core d’un CPU).

Testons notre API, dans un navigateur web, ouvrez la page http://localhost:8888/example et on devrait avoir la réponse suivante :

Dans le fichier lib/channel.dart, modifier la route « /example » pour afficher le message suivant : { « message » : « Bienvenue sur l’api héro »}.

router

  .route("/example")

  .linkFunction((request) async {

    return Response.ok({"message": "Bienvenue sur l'api héro"});

  });

Ma première route

Créer un nouveau répertoire « lib/controller » dans lequel on créé un fichier heroes_controller.dart.

Dans ce fichier :

  • Importer les packages aqueduct et heroes.
  • Créer la classe HeroesController qui étend la classe ResourceController

Dans la classe HeroesController nous allons créer une variable _heroes de type Array regroupant les héros.

final _heroes = [

  {'id': 1, 'name': 'Hulk', 'real_name': 'Robert Bruce Banner'},

  {'id': 2, 'name': 'IronMan', 'real_name': 'Anthony Edward \"Tony\" Stark'},

  {'id': 3, 'name': 'Wolverine', 'real_name': 'James Howlett'}

];

Puis une Future de type RequestOrResponse qui va retourner la liste des héros :

@override
Future<RequestOrResponse> handle(Request request) async {

  return Response.ok(_heroes);

}

Le fichier final ressemble à :

import 'package:aqueduct/aqueduct.dart';

import 'package:heroes/heroes.dart';



class HeroesController extends ResourceController {

  
final _heroes = [

    {'id': 1, 'name': 'Hulk', 'real_name': 'Robert Bruce Banner'},

    {'id': 2, 'name': 'IronMan', 'real_name': 'Anthony Edward \"Tony\" Stark'},

    {'id': 3, 'name': 'Wolverine', 'real_name': 'James Howlett'}
 
 ];



  @override
  Future<RequestOrResponse> handle(Request request) async {

    return Response.ok(_heroes);

  }


}

Nous allons maintenant implémenter la nouvelle route au niveau du fichier channel.dart (juste en dessous de la route ‘/example‘), pour cela nous allons déclarer une nouvelle route ‘/heroes‘ que nous allons relier au HeroesController via la fonction link(). Il est possible de lier plusieurs contrôleurs à une route, ce qui permet via un contrôleur d’authentification de sécuriser la route…

router

  .route("/heroes")

  .link(() => HeroesController());

Attention: Si vous n’êtes pas sur IntelliJ, veuillez à bien relancer votre serveur avec la commande aqueduct serve car non, il n’y a pour le moment pas d’implémentation d’un système de « Hot Reload ».

On doit maintenant obtenir le résultat suivant :

Routing

Le framework Aqueduct permet de créer autant de routes que souhaité, mais il permet également d’utiliser une route et de la rendre plus générique (nous parlerons alors de « ressources »). Nous allons prendre par exemple notre route ‘/heroes‘ qui nous sert a récupérer la liste des héros et nous allons utiliser cette même ressource pour récupérer un héros en particulier via la route ‘/heroes/:id

Pour cela nous allons modifier la déclaration de la route dans le fichier channel.dart.

router

  .route("/heroes/[:id]")

  .link(() => HeroesController());

le fait de mettre en [] notre second argument indique au framework que celui-ci est optionnel. Si il n’est pas présent alors on reverra la liste des héros, sinon on reverra le héros portant l’id indiqué dans la route.

Afin d’interpréter cet argument nous allons modifier la fonction handle du fichier heroes_controller.dart. Nous allons vérifier si un id est présent dans la route et en fonction de chaque cas de figure, nous allons renvoyer la bonne réponse.

@override
Future<RequestOrResponse> handle(Request request) async {

  if (request.path.variables.containsKey('id')) {

    final id = int.parse(request.path.variables['id']);

    final hero = _heroes.firstWhere((hero) => hero['id'] == id, orElse: () => null);

    
    if (hero == null) {

      return Response.notFound();

    }


    return Response.ok(hero);

  }

  return Response.ok(_heroes);
}

Si nous testons notre route nous obtenons les résultats suivants:

  • La liste des héros via la route ‘/heroes
  • Un héros via la route ‘/heroes/2
  • Un retour en erreur 404 si le héros n’existe pas via la route ‘/heroes/4

Bon notre code va devenir très compliqué à maintenir si nous avons une succession de conditions pour savoir quel élément doit être retourné en fonction des paramètres de la route. Pour éviter ça, le framework fourni le décorateur @Operation qui va nous permettre de découper notre contrôleur en fonction des paramètres présents au niveau de la route.

Nous allons donc utiliser le décorateur @Operation.get() pour la liste des héros et @Operation.get(‘id’) pour retourner un héros en fonction de son ID. Ce qui va nous donner le code suivant:

La fonction getAllHeroes()

@Operation.get()
Future<Response> getAllHeroes() async {

  return Response.ok(_heroes);

}

La fonction getHeroByID()

@Operation.get('id')
Future<Response> getHeroByID() async {

  final id = int.parse(request.path.variables['id']);

  final hero = _heroes.firstWhere((hero) => hero['id'] == id, orElse: () => null);


  
  if (hero == null) {

    return Response.notFound();

  }


  return Response.ok(hero);

}

A ce stade, notre API fonctionne toujours mais notre code est bien plus lisible et maintenable.

Au niveau de la fonction getHeroById(), le fait de récupérer l’id en parsant le path de la requête n’est pas très élégant et c’est là qu’intervient le Request Binding du framework via le décorateur @Bind .

@Operation.get('id')
Future<Response> getHeroByID(@Bind.path('id') int id) async {

  final hero = _heroes.firstWhere((hero) => hero['id'] == id, orElse: () => null);



  if (hero == null) {

    return Response.notFound();

  }


  return Response.ok(hero);

}

Interaction avec PostgreSQL

Aqueduct est développé pour interagir facilement avec une base PostgreSQL à condition d’utiliser les mêmes modèles entre la BDD et le modèle de votre API.

En pré-requis, il faut avoir accès à une base PostgreSQL. Dans mon cas j’utilise Docker pour lancer un serveur PostgreSQL avec persistance des données sur le disque de votre ordinateur :

docker run -p 5432:5432 -v /VOTRE_PATH_LOCAL/pgheroes/:/var/lib/postgresql/data --name pgheroes -e POSTGRES_PASSWORD=azerty -e POSTGRES_USER=heroes -d postgres

Une fois votre base PostgreSQL lancée, il faut l’initialiser au niveau de votre API.

Le framework Aqueduct possède un ORM intégré au framework qui permet de gérer les Bases de données relationnelles telles que MySQL ou PostgreSQL. Si on détermine un modèle qui se base sur la structure de la donnée en BDD, le framework va mapper la donnée très facilement.

Création du modèle

Créer un nouveau répertoire « lib/model » dans lequel on crée un fichier hero.dart.

import 'package:heroes/heroes.dart';



class Hero extends ManagedObject<_Hero> implements _Hero {}



class _Hero {

  @primaryKey
  int id;


  @Column(unique: true)
  String name;

  
@Column()
  String real_name;

  
@Column()
  String description;

  
@Column()
  String thumb;

  
@Column()
  String img;  

}

Ici la classe Hero correspond à la table dans la base PostgreSQL, tous les champs sont définis via le nom de la colonne dans la table grâce au décorateur @Column():

  • name
  • real_name
  • description
  • thumb
  • img

Seul l’index de chaque élément est défini via le décorateur @primaryKey.

Connexion à la base PostgreSQL

Pour pouvoir effectuer des requêtes via l’ORM, il faut d’abord définir la connexion à la BDD. Pour cela nous allons éditer le fichier channel.dart au niveau de la fonction prepare() dans laquelle nous allons définir un context pour accéder facilement à notre BDD.

En dessous de la fonction de log, nous allons ajouter le code suivant:

ManagedContext context;
 
  

@override
Future prepare() async {

  logger.onRecord.listen((rec) => print("$rec ${rec.error ?? ""} ${rec.stackTrace ?? ""}"));


  final dataModel = ManagedDataModel.fromCurrentMirrorSystem();
  final persistentStore = PostgreSQLPersistentStore.fromConnectionInfo(

    username,

    password,
    host,

    port,
 
    databaseName

  );


  context = ManagedContext(dataModel, persistentStore);

}

Il faut maintenant passer le context au niveau de la déclaration des routes:

router

  .route("/heroes/[:id]")

  .link(() => HeroesController(context));

Et il faut également récupérer le context au niveau du contrôleur dans le fichier heroes_controller.dart:

class HeroesController extends ResourceController {


  HeroesController(this.context);


  final ManagedContext context;


...

Alimenter la base PostgreSQL avec de la donnée

Avant de faire nos premières requêtes en BDD, connectez vous à votre serveur PostgreSQL et créez la table hero.

CREATE TABLE _HERO (

  ID BIGSERIAL PRIMARY KEY,

  NAME TEXT NOT NULL,

  REAL_NAME TEXT,

  DESCRIPTION TEXT,

  THUMB TEXT,

  IMG TEXT

);

Puis créez un héros dans cette table :

INSERT INTO _HERO (
  NAME,

  REAL_NAME,

  DESCRIPTION,

  THUMB,

  IMG) 
VALUES (

  'Hulk',

  'Robert Bruce Banner',

  'Caught in a gamma bomb explosion while trying to save the life of a teenager, Dr. Bruce Banner was transformed into the incredibly powerful creature called the Hulk. An all too often misunderstood hero, the angrier the Hulk gets, the stronger the Hulk gets.',

  'https://i.annihil.us/u/prod/marvel/i/mg/5/a0/538615ca33ab0/standard_xlarge.jpg',

  'https://i.annihil.us/u/prod/marvel/i/mg/e/e0/537bafa34baa9.jpg'

)

Voilà, nous allons vérifié que notre table contient bien un héro en exécutant la requête suivante :

SELECT * FROM _HERO ORDER BY id ASC;

Nous devons récupérer une liste contenant le héros « Hulk » que nous venons de créer.

Premières requêtes en BDD

Nous allons maintenant récupérer la liste des héros via notre BDD. Dans le fichier heroes_controller.dart, supprimer la liste _heroes. et modifier la fonction getAllHeroes():

@Operation.get()
Future<Response> getAllHeroes() async {

  final heroQuery = Query<Hero>(context);

  final heroes = await heroQuery.fetch();


  return Response.ok(heroes);

}

Modifier également la fonction getHeroByID():

@Operation.get('id')
Future<Response> getHeroByID(@Bind.path('id') int id) async {

  final heroQuery = Query<Hero>(context)

    ..where((hero) => hero.id).equalTo(id);

  final hero = await heroQuery.fetchOne();



  if (hero == null) {

    return Response.notFound();

  }


  return Response.ok(hero);

}

Si nous testons notre API nous récupérons bien les données en provenance de la BDD.

  • sur la route /heroes
  • sur la route /heroes/1

Les query parameters

Sur la route ‘/heroes‘ on va utiliser des query params pour filtrer nos résultats.

De la même manière que nous avons récupéré l’id du héros dans la fonction getHeroByID() nous allons récupérer les query params via le décorateur @Bind:

@Operation.get()
Future<Response> getAllHeroes({@Bind.query('name') String name}) async {

  final heroQuery = Query<Hero>(context);

  if(name != null) {

    heroQuery.where((hero) => hero.name).contains(name, caseSensitive: false);

  }
  final heroes = await heroQuery.fetch();


  return Response.ok(heroes);

}

Résultats:

  • sur la route /heroes?name=hu
  • sur la route /heroes?name=ha

Autres routes

Pour créer ou supprimer un héros nous allons toujours utiliser la même ressource au niveau de la route http appelée ‘/heroes‘.

Créer un nouveau héros

Dans le fichier heroes_controller.dart, nous allons ajouter une nouvelle méthode createHero.

@Operation.post()
Future<Response> createHero(@Bind.body() Hero newHero) async {

  final heroQuery = Query<Hero>(context)

    ..values = newHero;

  final insertedHero = await heroQuery.insert();

    
  
  if(insertedHero == null) {

    return Response.forbidden();

  }

  return Response.ok(insertedHero);

}

Nous pouvons tester notre route via un client Rest (Insomnia/Postman/HTTPie/…):

Supprimer un héros

Dans le fichier heroes_controller.dart, nous allons ajouter une nouvelle méthode deleteHero.

@Operation.delete('id')
Future<Response> deleteHero(@Bind.path('id') int id) async {

  final heroQuery = Query<Hero>(context)

    ..where((hero) => hero.id).equalTo(id);


  await heroQuery.delete();


  return Response.accepted();

}

Si nous testons notre route avec un client Rest :

Voilà vous avez maintenant une petite base pour développer une API en utilisant le langage Dart.
Si vous avez la moindre question, n’hésitez pas à nous contacter ou nous laisser un commentaire.