Node for API: Express et Hapi en pratique

le 17/06/2015 par Adrien Becchis
Tags: Software Engineering

Après avoir vu a vu les principes sous-jacents aux deux cadriciels hapi et express, leur histoire et environnement, regardons maintenant comment réaliser avec eux une belle API REST.

Pour ce faire on s'appuiera sur un use case réel, et une API REST développée pour comparer les deux frameworks. Le code source est disponible ici, et l'API est en ligne là.

Description de l'API REST exemple

L'API qui va nous servir d'use case et que nous allons utiliser pour illustrer le propos sera Pending-Link. L'API REST va nous permettre de stocker (bookmarker) de manière temporaire un lien et de le retrouver ultérieurement. Une sorte de pocket ou bitly like. Il s'agit d'une simple api "mono-ressource" supportant l'ensemble des opérations CRUD et de recherche basique.

# Création d'un nouveau bookmark:
$ curl -X POST http://pending-link.herokuapp.com/api/v1/links \
-H 'Content-Type:application/json' \
-d '{"url":"https://blog.octo.com/node-for-api-express-and-hapi-architecture-and-ecosystem/"}'
< 'Location':'/v1/links/0' 
# Récupérer les bookmarks :
$ curl -X GET http://pending-link.herokuapp.com/api/v1/links \
      -h 'Accept:application/json' 
[{"url":"https://blog.octo.com/node-for-api-express-and-hapi-architecture-and-ecosystem/"}]

Pour plus de détails, consulter la documentation de l'API ou la spécification RAML.

Son design peut être qualifié de RESTful. Pour rappel REST (Representational State Transfer, échange de représentation d'état), est un style architectural d'API reposant pleinement sur le protocole HTTP. Parmi les concepts clefs, les ressources et leur représentation, l'absence d'état coté serveur, la possibilité de cacher les référence et une interface uniforme en utilisant les méthodes HTTP sur une ressource particulière identifiée par une URL. Pour un bon rafraîchissement approfondi de ces principes, une lecture de la refcard OCTO à ses cotés peut-être utile. :)

I need some REST, give me some Code

Voilà à quoi ressemble l'implémentation d'un hello API basique avec nos deux cadriciels. Nous nous basons sur la version 4.12+ d'express, et 8.4+ d'Hapi.

Hello Hapi

var Hapi = require('hapi');

var server = new Hapi.Server();
server.connection({ port: 3000 });

server.route({
    method: 'GET',
    path: '/',
    handler: function (request, reply) {
        reply('Hello, world!');
    }
});

server.start(function () {
    console.log('Server running at:', server.info.uri);
});

On constate que plusieurs objets de configuration sont passés en argument.

Hello Express

var express = require('express');
var app = express();

// respond with "hello world" when a GET request is made to the homepage
app.get('/', function(req, res) {
    res.send('hello world');
});

var server = app.listen(3000, function () {
    var host = server.address().address;
    var port = server.address().port;
    console.log('Example app listening at http://%s:%s', host, port);
});

Le match

Les principaux composants de nos applications

Décrivons un peu ce que nous venons de voir dans ces deux exemples précédents.

Au coeur, le serveur

Une légère différence apparaît lors de la création des serveurs: En express l'entité principale est l'application (généralement appelée app par convention), que l'on va configurer, agrémenter de routes ou middleware avant d'écouter un port particulier. C'est par cette opération listen qu'on obtient le serveur, que l'on pourra arrêter avec la méthode close prennant aussi une fonction de rappel.

En hapi il n'y a qu'une seule entité, le serveur, que l'on va configurer, sur lequel on pourra définir plusieurs connexions (avec la méthode connexion), qui seront instantiées lors du démarrage du serveur avec la fonction start (qui précédera un arrêt du server avec la méthode stop)

Ces deux entités sont configurables. En hapi cela se fait de préférence avec un objet de config passé au constructeur new Hapi.Server();. En express l'application met à disposition des fonctions comme disable ou enable pour activer ou désactiver un certain nombre de paramètres.

Définition des routes

Une fois le serveur défini, la prochaine étape consiste à lui ajouter les routes, les urls que l'on souhaite prendre en charge. La différence d'approche décrite dans la première partie se retrouve au niveau de l'API offerte par les deux frameworks.

Pour définir une route en express on invoquera une des méthodes get, post avec l'url matchée et le handleur spécifié. On a une méthode app.METHOD pour chaque verbe HTTP.

Le premier argument est l'url de la route, celle-ci pouvant contenir des variables dynamiques, un identifiant précédé par un :. On peut aussi avoir recours à des expressions régulières.

app.get('/links/:id', LinkController.get);

Il existe une méthode all qui sera appliquée pour l'ensemble des routes, quelque soit la méthode. Elle est notamment utilisée pour charger les middlewares.  A noter la possibilité d'invoquer ces méthodes sur un routeur que l'on viendra ensuite monter sur l'application. Un routeur est un middleware spécifique doté de son propre système de routage.

var router = express.Router();
router.get('/', function(req, res) {
  res.send('Hellooooo');
});
// elsewhere with a different name
app.use('/some-url', router);

On préfèrera cette approche pour plus de modularité et réutilisabilité en découpant l'application en plusieurs routeurs.

A l'opposé, en hapi il y a une seule méthode du server au nom peu surprenant, route. Celle-ci prend un objet de configuration (ou un tableau de tels objets), devant contenir à minima les clefs method pour le verbe HTTP, path pour l'url gérée, et handler pour la fonction en charge de la gestion des requêtes.

server.route({
    method: 'GET', path: '/hello',
    handler: function (request, reply) {
        reply("Hello Links!");
    }
})

On va donc choisir le verbe HTTP en le mettant comme valeur de la clé method qui accepte l'ensemble de verbes (à l'exception d'HEAD). Au cas où l'on veuille définir une route agnostique de la méthode on utilisera *, et si l'on veut juste un sous ensemble, on passera un tableau de méthodes.

Hapi accepte aussi des variables dans les urls, avec une légère différence de syntaxe. Plutôt que de les préfixer avec un :, celles-ci sont en-capsulées dans des accolades: path: '/links/{id}'. Dans les cas plus complexes on passera un objet de config contenant les différents paramètres. Ainsi on pourra configurer un grand nombre des aspect comme la validation des objets, d'authentification des utilisateurs, la mise en cache, les pre request et tout autre paramètre introduit par les différents plugins.

Notre route ressemblera ainsi à cela:

{
    method: 'POST', path: '/links',
    config: {
        handler: LinkController.create,
        validate: { payload: { url: Joi.string().required() }}
        /* autres paramètres de configurations  */
    }
}

Pour plus de détails on se référera à la documentation.

Les handleurs de requêtes

Pour les deux frameworks les handleurs de requêtes sont des fonctions qui prend en argument deux objets représentant respectivement la réponse et la requête. C'est ici que l'on va implanter notre logique, le traitement que l'on veut associer aux requêtes.

Comme on peut s'y attendre, le premier objet va nous permettre d'accéder à tous les détails de la requête, ses headers, ses paramètres, et plus encore. Si c'est le même concept, ce n'est ni exactement la même abstraction, ni la même manière de la peupler. Express étend l'objet natif du serveur http node, alors que Hapi offre sa propre abstraction d'une requête. Il en est d'ailleurs de même pour l'interface permettant de manipuler la réponse. Le nom n'est pas si important, mais par convention en express on va plutôt utiliser res et req, alors que request et reply sont utilisé pour hapi. Cette différence fait sens, puisque pour la réponse vu que reply est une fonction dont l'appel va déclencher le début de création de la réponse. En express l'envoi est déclenché par l'appel de la méthode end, send ou un des méthodes helper similaires. (comme json())

Si dans hapi toute la requête est traitée par un seul handler, en express via le système de middleware, une requête peut être traitée petit à petit par plusieurs handlers. Dans ce cas le handler aura trois arguments, le dernier étant une callback next() pour passer la main au suivant.

Voici un exemple d'handler final réalisé en Hapi et en Express: Il s'agit de la méthode pour accéder à un lien particulier. En cas de succès on renvoi une représentation json du lien, en cas d'absence ou de suppression on renverra respectivement le code 404 et 410.

var expressHandler = function (req, res) {
    LinkDAO.get(req.params.id, function (link) {
        if (!link) {
            res.status(404).end();
        } else {
            if (link.archived)
                res.status(410).end();
            else res.json(link)
        }
    });
};
var hapiHandler = function (request, reply) {
    LinkDAO.get(request.params.id, function (link) {
        if (!link) {
            reply().code(404);
        } else {
            if (link.archived)
                reply().code(410);
            else reply(link);
        }
    });
};

On notera qu'en express la méthode va finaliser le traitement de la requête alors qu'il va l'initier en hapi.

Interprétation de la requête

La requête contient nombre de données à différents endroits. Selon les cas on aura besoin d'aller chercher les informations dans le corps, les headers, les cookies, etc. Si les deux frameworks offrent les mêmes fonctionnalités, l'approche de collecte est assez différente.

Diversité des données à récupérer

Pour donner accès à ces informations, les serveurs offrent des objets pour les récupérer. Ceux-ci sont stockés dans diverses propriétés de l'objet représentant la réponse dans les handler.

Ainsi en Hapi on pourra accéder à la requête via query, entêtes via headers, paramètres de l'url via params, et information d'authentification via auth. il est aussi possible d'avoir accès au serveur via la propriété server. Il s'agit d'objets, donc on pourra accéder aux différentes valeurs en utilisant au choix un accès statique (dot notation) ou dynamique (bracket notation). Par exemple, pour récupérer l'identifiant de la ressource demandée on utilisera request.params.id ou request.params['id']. Bien d'autres existent et on pourra consulter la liste ici.

var id = request.params.id;
var query = request.query.q;
var userAgent = request.headers['user-agent'];

Pour Express c'est assez similaire, avec les diverses propriétés de l'objet req dont path, params, query et bien d'autres.

var id = req.params.id;
var query = req.query.q;
var userAgent = req.headers['user-agent'];

L'ensemble des objets et méthodes est consultable dans la documentation.

Processus de collecte des corps

Si certaines parties de la requête sont fournies out of the box comme les headers, il faut, pour d'autres, spécifier au serveur ce que l'on souhaite. Ceci est notamment le cas des cookies et du body.

En effet le parsage du corps n'est pas automatique. Si on ne le précise pas, le json est récupéré sous la forme d'une string, conservé à l'état de buffer. Il peut être nécessaire de configurer le serveur afin qu'il parse les corps de messages et nous mette à disposition des objets.

Chez hapi, ceci se fait via une des propriétés de l'objet de config de la route, payload. Pour express, il faut intercaler en amont de nos routes un middleware, bodyParser qui se chargera de lire le corps, de créer un objet javascript, et de l'insérer dans l'objet request.

Une fois chose faite, on pourra accéder au corps du message comme tout objet javascript, respectivement dans req.body pour express, et dans request.payload pour hapi. Pour certains uses cases, potentiellement plus complexes, il existe des modules dédiés à l'extraction de différentes données. On a ainsi des middleware pour parser les cookies, des modules pour supporter les tableaux et objets dans les querystring (plugin qs pour hapi), ou d'autres pour gérer les sessions (yar pour hapi et session pour express.

Quelques exemples

Dans pending link, les principales données nous intéressant sont le corps de requêtes dans le cas de la mise à jour, ou création de lien, les paramètres pour filtrer les collections, et les params pour cibler une sous ressource en particulier.

Plusieurs fichiers de notre prototype permettent d'illustrer plus en détails. Si pour l'accès aux paramètres il faudra dans les deux framework les méthodes "contrôleurs", pour la configuration on regardera respectivement les routes pour hapi, et le chargement des middleware dans le serveur pour express et la création du routeur.

Création de la réponse

Une fois la requête parsée, le traitement effectué, il faut gérer la réponse que l'on va renvoyer à notre client. Et pour cela, spécifier le code de retour, les headers, ainsi que le corps de la réponse HTTP

Codes de Retour HTTP

Tout d'abord, nous rappelons qu'il est crucial d'utiliser les codes HTTP appropriés. Ainsi, on prendra soin de ne pas envoyer un 200 en cas d'erreur mais un 400 Bad request, 404 Not Found, un petit 500 ou plus approprié.

La spécification des codes de retour est assez similaire entre les deux framework, la principale différence étant le nom de la méthode. status pour express, code pour Hapi.

// express
 res.status(404).end()  // or shortcut sendStatus(404)
// hapi
reply().code(200);

Pour les deux, si non précisé par l'appel de ces méthodes, la valeur par default est 200. A noter qu'il existe des méthodes qui se chargeront à la fois de peupler les headers et le code de retour, notation avec la méthode location qui met à la fois le code 201 Created et le header Location.

On pourra observer dans les contrôleurs hapi et express de notre prototypes les appels à ces méthodes ou nous utilisons selon les cas les codes 404, 410, 200, 201

Utilisation des Headers HTTP

Parmi les autres incontournables des api REST, l'utilisation des Header du protocole HTTP pour porter les metadonnnées de la requêtes et réponse, comme le type de contenu demandé ou envoyé, la fraicheur de la ressource disponible, et bien d'autres encore.

Voici comment les headers sont spécifiés respectivement dans nos deux frameworks.

// express
res.set({'Content-type': 'text/plain', 'X-Custom': 'some-value'});
res.header('Lonely-Header', 'some-other-value');
res.type('json');

// hapi
reply().type('text/plain')
       .header('X-Custom', 'some-value')

En pratique la plupart des headers seront gérés semi-automatiquement en étant enrichi respectivement par des middlewares ou plugins.

Dans notre prototype où il n'y a pas de négociation de contenu, la spécification du header apparaît qu'à un seul endroit, lors de la création d'une nouvelle ressources. Dans les deux cas on dispose de plusieurs méthodes pour ne pas à avoir à écrire à la main le nom de l'entête.

// express création
res.location("/api/links/" + newLink._id);
// hapi creation
reply().created("/api/links/" + newLink._id);

Gestion du Corps du message

Si dans certains cas les métadonnées et le status code suffisent, dans d'autres, principalement les requêtes GET, il est nécessaire de retourner un payload qui correspond à la représentation d'une ressource ou bien à une collection de ressources.

Dans la plupart des cas, on se contentera d'envoyer un objet javascript correspondant à notre ressource. Dans les deux framework, celui-ci sera automatiquement sérialisé en document json.

Pour ce faire, on passera notre réponse en argument dans la callback reply pour hapi, ou comme argument de la méthode json pour express. Il est tout aussi possible d'envoyer du texte brut en express en utilisant la méthode send.

Dans les deux framework on peut aussi servir des fichiers. (méthode file) Cependant on préférera utiliser les middleswares ou configuration de route correspondantes pour laisser le serveur servir des fichiers ou dossier entiers.

A noter que tout deux ont aussi des fonctions pour servir des templates, mais ceci n'est pas l'objet de notre API. Si besoin est, on regardera les méthodes reply.view() et res.render()

Conclusion

Après cette vue d'ensemble de comment réaliser une API REST, on a bien constaté que les différences d'approches décrites dans notre précédent article se reflètent au niveau des apis des deux frameworks. On retrouve ces différences à travers le code même si les handlers finaux ont de nombreuses similarités.

Si on devait résumer, en terme de réalisation d'API REST tous les deux "font le job", et ils le font bien. Par contre en raisons des implications de leurs approches respectives en terme de productivité, le contexte dans lequel on va réaliser une API va avoir une grosse incidence sur notre choix

Ainsi, dans le cadre d'un prototype, pour confirmer une idée, express sera bien adapté surtout si on a jamais fait d'Hapi. Express va nous permettre de vite nous lancer, et monter notre API. Par contre plus le temps passe, plus on risque d'avoir des problèmes de scalabilité et maintenabilité.

A contrario, s'il s'agit de réaliser une API industrielle, là pour durer et être étendue avec le temps, Hapi est le bon choix. On aura à payer up front la mise en place du serveur, les premières configurations, mais cet investissement portera ses fruits à terme avec un serveur plus facile à maintenir et à étendre.

Et puis il ne faut pas oublier l'argument final, because I'm hapi…