Écrire un service REST avec NodeJS et Express – partie 1/3: implémentation de départ

Une fois n’est pas coutume, je découperai ce tutorial en plusieurs articles distincts, histoire de voir si j’arrive à écrire des articles plus courts ;)

Nous allons voir en détail comment mettre en place un webservice RESTful avec NodeJS et le framework Express. L’objet de ce webservice sera la gestion d’une base de favoris.

Sommaire

MÀJ Oups, j’avais oublié la partie pour réellement jouer avec notre service ^^

Conception

Présentation des briques logicielles

Commençons par définir les outils dont on va s’armer pour mener notre mission à bien :)

REST

L’architecture « Representational State Transfer » consiste principalement en la mise en place de services basés sur le protocole HTTP pour les opérations standard de « CRUD« . En effet, le protocole HTTP comprend différents « verbes », comme « GET », « POST », etc… qu’on va utiliser pour implémenter un CRUD.

Méthode HTTP Étape CRUD
POST Create
GET Read
PUT Update
DELETE Delete

La notion de service RESTful est très normée, et quelques règles doivent être respectées pour se réclamer RESTful:

  • Le service permet de manipuler une collection d’entités.
  • Les méthodes POST/GET/PUT/DELETE sont implémentées pour manipuler la collection et/ou les entités, sur le modèle du CRUD.
  • Les méthodes PUT et DELETE sont « idempotentes », ce qui signifie qu’effectuer plusieurs fois la même requête aura le même effet que de l’exécuter une seule fois.
  • La méthode GET est « sûre », ce qui signifie qu’elle ne modifie pas l’état du serveur ni les données (à l’inverse de POST/PUT/DELETE).

Ces normes permettent aux utilisateurs des services RESTful de savoir toujours où ils mettent les pieds, et il est donc très important de les respecter. De la même manière qu’il est important de respecter une logique dans la construction des URIs, et dans les codes de statuts qu’on renverra en réponse (200, 400, 404, 406, 500…).

NodeJS et Express

Vous connaissez sûrement déjà NodeJS, et je vous ai déjà parlé d’Express dans cet article. Ce micro-framework permettra de simplifier la gestion des verbes HTTP (méthodes), du parsing de la requête, etc…

REST reposant sur le protocole HTTP/1.1, NodeJS est une plateforme pertinente :)

Redis

Je parle de Redis dans cet article du blog de Clever Age: Redis, une base clé-valeur très riche. Il s’agit d’une base NoSQL de type clé-valeur, c’est à dire pour résumer une espèce de grosse table de hashage.

Elle a de nombreux mérites dont celui d’être très simple, très rapide, très « scalable » (facile à passer en cluster), et j’en suis totalement amoureux :)

JSON

En général, les services REST représentent les données avec le format JSON. Ça tombe bien, NodeJS c’est du Javascript, donc JSON est intégré au langage nativement ;) Là encore, la plateforme se trouve être un choix pertinent.

Dans les requêtes PUT et POST, on passera donc du JSON dans le corps de la requête, et les réponses seront également exprimées en JSON. Express interprète nativement le JSON présent dans le corps d’une requête, donc il n’y aura rien à faire de ce côté. Il y aura évidemment un travail supplémentaire à effectuer pour supporter d’autres types de contenu (comme XML).

Description du service

On va manipuler une collection de favoris. Chaque favori étant une entité avec les attributs suivants:

Attribut Description
id ID unique du favori. Cette donnée sera générée automatiquement, pour références
url URL du favori
title Titre du favori
tags Liste de tags (simples chaines de caractères)

On va proposer dans un premier temps les méthodes suivantes:

  /bookmarks /bookmarks/bookmark/{id}
GET Liste des URI des bookmarks existant (URI = ‘/bookmark/{id}‘) Détails du favori
POST Ajout d’un favori Non
PUT Non Mise à jour du favori
DELETE Réinitialiser la liste Suppression du favori

Plus tard, on pourra évidemment ajouter de nouvelles méthodes intéressantes:

  • GET /bookmarks/tag/{tag} Liste des couples ID/URL ayant ce tag
  • POST /bookmarks/{id}/tags Ajout d’un tag
  • DELETE /bookmarks/{id}/tags/{tag} Suppression d’un tag
  • Etc… L’idée étant toujours de respecter le rôle de la méthode (POST/GET/DELETE/PUT), ainsi qu’une structure logique pour l’URI.

Ce ne sera néanmoins pas l’objet de cet article.

Initialisation de l’environnement

On commence par installer les modules dont on aura besoin:

npm install express ejs redis

Application Express

On initialise l’application « vite fait »:

./node_modules/.bin/express -t ejs

L’application par défaut correspond à nos besoins, entre autres elle charge bien le middleware bodyParser qui s’occupera de parser le JSON dans les requêtes.

14
15
16
...
  app.use(express.bodyParser());
...

Structure des données

Pas de manipulation à faire vu qu’il n’y a pas de notion de schéma dans REDIS, par contre on va définir dès le départ le format de nos clés et de nos données:

Clé Description
bookmarks::sequence valeur toujours accédée par INCR, afin d’obtenir un ID unique pour nos entités
bookmarks:{id} l’objet sérialisé au format JSON (faisons simple)

Il va de soit que si on souhaite plus tard intégrer la recherche par tag, il faudra également définir des index, comme tags:{tag} qui sera la liste des IDs des favoris portant ce tag. On enrichira les clés à mesure des besoins, c’est aussi une des forces du NoSQL :)

Utiliser les bons codes de statut HTTP

Avant de sauter dans le code, un petit rappel des codes de statut HTTP qui nous intéresseront:

Code Message Description
200 OK Le code de statut par défaut en cas de succès. Il sera en général accompagné d’un corps de réponse en JSON
400 Bad Request Le code d’erreur générique dans le cas d’informations invalides fournis au service dans la requête (format de données invalide par exemple
404 Not Found Code d’erreur typiquement retourné dans le cas d’une URI d’entité (en PUT ou GET) qui n’existe pas
405 Method Not Allowed Retourné lorsque l’utilisateur effectue un appel à une URL ne supportant pas la méthode demandée
406 Not Acceptable Ce code sera retourné lorsque la requête contient des entêtes qui nous semblent incompatibles avec le fonctionnement du service, par exemple si dans l’entête « Accept » on ne trouve pas « application/json », ça signifie que la requête déclare explicitement ne pas accepter ce format, et on n’est donc pas en mesure de communiquer avec ce client
500 Server Error Ce sera le code d’erreur par défaut, celui qu’on ne souhaite jamais retourner car il s’agit d’une erreur « non traitée »

Réalisation

Préparer des raccourcis

Pour retourner un code de statut c’est déjà assez simple avec simple avec NodeJS et l’objet Response:

res.writeHead(code, {"Content-Type": contentType});
res.end(contenu);

Il est évidemment trop tôt pour factoriser, on n’a pas encore écrit une ligne ;) Néanmoins cette action sera effectuée très régulièrement, donc autant se simplifier la tâche tout de suite avec un raccourci, inspiré du projet Journey en enrichissant le prototype de Response:

1
res.respond(contenu, code);

L’idée est de fournir des réponses bien uniformisées, et toutes avec le même Content-Type, qu’il s’agisse d’un succès ou d’une erreur. Les erreurs d’ailleurs seront toutes des messages de la forme {code:..., error:...} (en répétant le code de statut dans le corps de la réponse, on permet au logiciel client d’avoir plus de latitude sur sa façon de récupérer les codes d’erreur, ça peut être pratique). Le contenu pouvant donc être un objet, un simple texte, une exception… On s’assurera également de toujours retourner un JSON valide, donc soit un tableau soit un objet anonyme.

Créons le fichier response.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var http = require('http');
 
http.ServerResponse.prototype.respond = function (content, status) {
  if ('undefined' == typeof status) { // only one parameter found
    if ('number' == typeof content || !isNaN(parseInt(content))) { // usage "respond(status)"
      status = parseInt(content);
      content = undefined;
    } else { // usage "respond(content)"
      status = 200;
    }
  }
  if (status != 200) { // error
    content = {
      "code":    status,
      "status":  http.STATUS_CODES[status],
      "message": content && content.toString() || null
    };
  }
  if ('object' != typeof content) { // wrap content if necessary
    content = {"result":content};
  }
  // respond with JSON data
  this.send(JSON.stringify(content)+"\n", status);
};

Puis incluons-le dans l’application principale app.js pour activer ce « monkey patch » au démarrage:

1
2
3
4
5
6
7
/**
 * Module dependencies.
 */
 
require('./response');
 
...

Les tests unitaires

Comme tout bon élève, on va démarrer la réalisation par l’écriture des tests unitaires. On va pour cela utiliser APIEasy, un projet dédié à l’écriture de tests unitaires pour les API REST.

npm install api-easy

Puis on écrit notre fichier de test très simplement dans test/rest-test.js. L’API est tellement claire qu’elle ne nécessite à mon sens pas d’explications:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
const PORT = 3000;
var assert = require('assert')
  , app = require('../app')
  , bookmark = {
      "title": "Google",
      "url":   "http://www.google.com",
      "tags":  [ "google", "search" ]
    }
  , expected_id = 1
 
// Configure REST API host & URL
require('api-easy')
.describe('bookmarks-rest')
.use('localhost', PORT)
.root('/bookmarks')
.setHeader('Content-Type', 'application/json')
.setHeader('Accept', 'application/json')
 
// Initially: start server
.expect('Start server', function () {
  app.db.configure({namespace: 'bookmarks-test-rest'});
  app.listen(PORT);
}).next()
 
// 1. Empty database
.del()
.expect(200)
.next()
 
// 2. Add a new bookmark
.post(bookmark)
.expect('Has ID', function (err, res, body) {
  var obj;
  assert.doesNotThrow(function() { obj = JSON.parse(body) }, SyntaxError);
  assert.isObject(obj);
  assert.include(obj, 'id');
  assert.equal(expected_id, obj.id);
  bookmark.id = obj.id;
})
.undiscuss().next()
 
// 3.1. Check that the freshly created bookmark appears
.get()
.expect('Collection', function (err, res, body) {
  var obj;
  assert.doesNotThrow(function() { obj = JSON.parse(body) }, SyntaxError);
  assert.isArray(obj);
  assert.include(obj, '/bookmarks/bookmark/' + expected_id);
})
 
// 3.2. Get the freshly created bookmark
.get('/bookmark/' + expected_id)
.expect('Found bookmark', function (err, res, body) {
  var obj;
  assert.doesNotThrow(function() { obj = JSON.parse(body) }, SyntaxError);
  assert.deepEqual(obj, bookmark);
})
.next()
 
// 4. Update bookmark
.put('/bookmark/' + expected_id, {"title": "Google.com"})
.expect('Updated bookmark', function (err, res, body) {
  var obj;
  assert.doesNotThrow(function() { obj = JSON.parse(body) }, SyntaxError);
  bookmark.title = "Google.com";
  assert.deepEqual(obj, bookmark);
})
.next()
 
// 5. Delete bookmark
.del('/bookmark/' + expected_id)
.expect(200)
.next()
 
// 6. Check deletion
.get('/bookmark/' + expected_id)
.expect(404)
.next()
 
// 7. Check all bookmarks are gone
.get()
.expect('Empty database', function (err, res, body) {
  var obj;
  assert.doesNotThrow(function() { obj = JSON.parse(body) }, SyntaxError);
  assert.isArray(obj);
  assert.equal(obj.length, 0);
})
 
// 8. Test unallowed methods
.post('/bookmark/' + expected_id).expect(405)
.put().expect(405)
 
// Finally: clean, and stop server
.expect('Clean & exit', function () {
  app.db.deleteAll(function () { app.close() });
})
 
// Export tests for Vows
.export(module)

Notez qu’on fait les choses bien et que c’est le test lui-même qui est chargé de démarrer et d’arrêter le serveur, ce qui nous permet d’avoir une configuration dédiée aux tests (port différent, et plus tard base différente par exemple). Sauf qu’en l’état, notre application démarre immédiatement, du coup quand on appelle listen(), ça foire un peu :(

Pour corriger ça, il faut que notre module app.js ne démarre réellement l’application que lorsqu’on l’exécute directement, pas quand on l’inclut dans un autre script. C’est très simple et il suffit d’ajouter un petit test à la fin du fichier app.js:

38
39
40
41
if (module.parent === null) {
  app.listen(3000);
  console.log("Express server listening on port %d in %s mode", app.address().port, app.settings.env);
}

On exécute les tests avec Vows qui est une dépendance de APIEasy, on trouve donc son binaire dans ./node_modules/api-easy/node_modules/.bin. Évidemment, ils échouent tous pour l’instant:

Dans cette première partie on est un peu fainéants et on teste vraiment le minimum vital. Évidemment plus tard il faudra gérer tous les cas d’erreur, et les intégrer également dans les tests.

Implémentation

Base de données

On initialise notre connexion Redis au démarrage de l’application. Le driver choisi a la faculté de reconnecter automatiquement une connexion qui saute (timeout ou autre), et de mettre en « pool » les requêtes effectuées à la base tant que la connexion n’est pas ouverte. Comportement idéal pour éviter les erreurs 500 à cause d’une BDD temporairement inaccessible.

11
var redis = require('redis').createClient();

C’est tout ? Bon allez, pour simplifier les tests unitaires on va wrapper notre client Redis et ajouter une option pour le format des clés:

11
12
13
14
15
16
17
18
19
20
21
var db = {
  "namespace": "bookmarks",
  "configure": function (options) {
    if ('undefined' != typeof options.namespace) namespace = options.namespace;
  },
  "client": require('redis').createClient(),
  "close": function disconnect (callback) {
    if (this.client.connected) this.client.quit();
    if (callback) this.client.on('close', callback);
  },
};

On a donc un objet « db » exporté publiquement (donc aisément configurable pour les tests unitaires). On l’enrichira au fur et à mesure de méthodes simplifiant l’accès aux données (fetch, delete, etc…), il sera donc plus à son aise dans un module dédié.

Dans db.js, on exporte une fonction qui retourne notre API. Ce mode de construction permet d’éviter les singletons, ce qui simplifie à la fois la configuration et les tests unitaires:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
module.exports = function (options) {
 
/**
 * Module options
 */
var client = require('redis').createClient()
  , namespace = 'bookmarks';
if ('undefined' != typeof options) _set_options_(options)
 
/**
 * Privates
 */
// Get bookmark key name
function _key_ (id) {
  return namespace + ':' + id + ':json';
}
// Get sequence key name
function _seq_ () {
  return namespace + '::sequence';
}
// Update internal options
function _set_options_ (options) {
  if ('undefined' != typeof options.database)  client.select(options.database);
  if ('undefined' != typeof options.namespace) namespace = options.namespace;
  return this;
}
 
return {
 
  /**
   * Update options
   */
  "configure": _set_options_,
 
  /**
   * Allow disconnection
   */
  "close": function disconnect (callback) {
    if (client.connected) client.quit();
    if (callback) client.on('close', callback);
  }
 
}
 
};

Dans app.js:

11
var db = exports.db = require('./db')();
Gérer correctement les processus en attente

À ce stade, on remarque alors une chose quand on lance les tests unitaires: ils ne quittent jamais! Il faut les couper avec Ctrl+C.

C’est parce que même si on a bien pensé à couper le serveur HTTP, il reste encore des processus en mode « idle » dans notre application. Le coupable est vite trouvé puisque c’est la seule connexion ouverte qu’on a ajouté: le client Redis évidemment :) Le plus propre est donc de couper correctement la connexion quand le serveur HTTP se ferme:

12
app.on('close', db.close); // Close open DB connection when server exits

API de manipulation des données

Méthode Description
save(bookmark, callback) Ajoute ou met à jour un bookmark, puis appelle callback(error, bookmark_with_id, created /* true if new bookmark */).
fetchOne(id, callback) Appelle callback(error, bookmark_with_id)
fetchAll(callback) Appelle callback(error, bookmarks)
deleteOne(id, callback) Supprime le callback et appelle callback(error, deleted /* false if id not found */)
deleteAll(callback) Vide toute la collection et appelle callback(error)

On complète db.js pour déclarer les méthodes. Reste à les tester, puis les implémenter.

43
44
45
46
47
"save":      function save (bookmark, callback) { callback('Not implemented yet'); },
"fetchOne":  function fetchOne (id, callback) { callback('Not implemented yet'); },
"fetchAll":  function fetchAll (callback) { callback('Not implemented yet'); },
"deleteOne": function deleteOne (id, callback) { callback('Not implemented yet'); },
"deleteAll": function deleteAll (callback) { callback('Not implemented yet'); }
Tests unitaires

Cette fois on va utiliser directement Vows. On commence donc par l’installer avec npm install vows puis décrire les tests dans test/db-test.js.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
var assert = require('assert')
  , db = require('../db')({namespace:'bookmarks-test-db'})
  , the_bookmark = {}
 
require('vows')
.describe('bookmarks-db')
.addBatch({
  "Initialize": {
    topic: function () {
      db.deleteAll(this.callback);
    },
    "deleteAll": function (err, placeholder /* required for Vows to detect I want the error to be passed */) {
      assert.isNull(err);
    }
  }
}).addBatch({
  "Creation": {
    topic: function () {
      db.save({ "title": "Google", "url":   "http://www.google.com", "tags":  [ "google", "search" ] }, this.callback);
    },
    "save (new)": function (err, bookmark, created) {
      assert.isNull(err);
      assert.isTrue(created);
      assert.include(bookmark, 'id');
      assert.equal(bookmark.title, 'Google');
      assert.equal(bookmark.url, 'http://www.google.com');
      assert.deepEqual(bookmark.tags, ['google', 'search']);
      the_bookmark = bookmark;
    }
  }
}).addBatch({
  "Fetch": {
    topic: function () {
      db.fetchOne(the_bookmark.id, this.callback)
    },
    "check existing": function (err, bookmark) {
      assert.isNull(err);
      assert.isObject(bookmark);
      assert.deepEqual(bookmark, the_bookmark);
    }
  }
}).addBatch({
  "Update": {
    topic: function () {
      the_bookmark.title = 'Google.com';
      db.save(the_bookmark, this.callback);
    },
    "save (update)": function (err, bookmark, created) {
      assert.isNull(err);
      assert.isFalse(created);
      assert.equal(bookmark.title, the_bookmark.title);
      assert.equal(bookmark.url, the_bookmark.url);
      assert.deepEqual(bookmark.tags, the_bookmark.tags);
    }
  }
}).addBatch({
  "Delete": {
    topic: function () {
      db.deleteOne(the_bookmark.id, this.callback);
    },
    "Deleted": function (err, deleted) {
      assert.isNull(err);
      assert.isTrue(deleted);
    }
  }
}).addBatch({
  "Finalize": {
    topic: db,
    "Clean": function (db) {
      db.deleteAll();
    },
    "Close connection": function (db) {
      db.close();
    }
  }
}).export(module)
Implémentation de l’API de la couche d’accès aux données
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
/**
 * Save a bookmark
 * if bookmark has no attribute "id", it's an insertion, else it's an update
 * callback is called with (err, bookmark, created)
 */
"save": function save (bookmark, callback) {
  var created = ('undefined' == typeof bookmark.id);
  var self = this;
  var onIdReady = function () {
    client.set(_key_(bookmark.id), JSON.stringify(bookmark), function (err) {
      callback(err, bookmark, created);
    });
  }
  if (created) { // No ID: generate one
    client.incr(_seq_(), function (err, id) {
      if (err) return callback(err);
      bookmark.id = id;
      onIdReady();
    });
  } else { // ID already defined: it's an update
    this.fetchOne(bookmark.id, function (err, old) {
      if (err) return callback(err);
      for (var attr in bookmark) {
        old[attr] = bookmark[attr];
      }
      bookmark = old;
      onIdReady();
    });
  }
},
 
/**
 * Retrieve a bookmark
 * callback is called with (err, bookmark)
 * if no bookmark is found, an error is raised with type=ENOTFOUND
 */
"fetchOne": function fetchOne (id, callback) {
  client.get(_key_(id), function (err, value) {
    if (!err && !value) err = {"message": "Bookmark not found", "type":"ENOTFOUND"};
    if (err) return callback(err);
    var bookmark = null;
    try {
      bookmark = JSON.parse(value);
    } catch (e) {
      return callback(e);
    }
    return callback(undefined, bookmark);
  });
},
 
/**
 * Retrieve all IDs
 * callback is called with (err, bookmarks)
 */
"fetchAll": function fetchAll (callback) {
  client.keys(_key_('*'), function (err, keys) {
    if (err) return callback(err);
    callback(undefined, keys.map(function (key) {
      return parseInt(key.substring(namespace.length+1));
    }));
  });
},
 
/**
 * Delete a bookmark
 * callback is called with (err, deleted)
 */
"deleteOne": function deleteOne (id, callback) {
  client.del(_key_(id), function (err, deleted) {
    if (!err && deleted == 0) err = {"message": "Bookmark not found", "type":"ENOTFOUND"};
    callback(err, deleted > 0);
  });
},
 
/**
 * Flush the whole bookmarks database
 * Note that it doesn't call "flushAll", so only "bookmarks" entries will be removed
 * callback is called with (err, deleted)
 */
"deleteAll": function deleteAll (callback) {
  var self = this;
  client.keys(_key_('*'), function (err, keys) {
    if (err) return callback(err);
    var deleteSequence = function deleteSequence (err, deleted) {
      if (err) return callback(err);
      client.del(_seq_(), function (err, seq_deleted) {
        callback(err, deleted > 0 || seq_deleted > 0);
      });
    }
    if (keys.length) {
      client.del(keys, deleteSequence);
    } else {
      deleteSequence(undefined, 0);
    }
  });
}

Une fois db.js complété, on peut vérifier que cette fois nos tests passent avec vows test/db-test.js:

En revanche, un simple vows va exécuter tous les tests, et toute la partie concernant l’API REST échoue évidemment toujours :)

Implémentation des méthodes REST

Maintenant qu’on a notre couche d’accès aux données, cette partie devient triviale :)

POST: Création

L’ajout d’un bookmark est un simple appel à save() après avoir validé qu’on avait bien une demande de création et pas de mise à jour.

41
42
43
44
45
46
47
48
49
app.post('/bookmarks', function (req, res) {
  if ('undefined' != typeof req.body.id) {
    res.respond(new Error('Bookmark ID must not be defined'), 400);
  } else {
    db.save(req.body, function (err, bookmark, created) {
      res.respond(err || bookmark, err ? 500 : 200);
    });
  }
});
GET: Lecture

Récupération de la collection: on récupère la liste des ID, qu’on préfixe avec « /bookmarks/bookmark/ » pour les transformer en URI.

51
52
53
54
55
56
57
app.get('/bookmarks', function (req, res) {
  db.fetchAll(function (err, ids) {
    res.respond(err || ids.map(function (id) {
      return '/bookmarks/bookmark/' + id;
    }), err ? 500 : 200);
  });
});

Récupération d’un élément: c’est un simple appel à fetchOne() dont il faut transformer l’erreur ENOTFOUND en erreur 404.

59
60
61
62
63
64
65
66
67
68
69
app.get('/bookmarks/bookmark/:id', function (req, res) {
  if (isNaN(parseInt(req.param('id')))) {
    res.respond(new Error('ID must be a valid integer'), 400);
  }
  db.fetchOne(req.param('id'), function (err, bookmark) {
    if (err) {
      if (err.type == 'ENOTFOUND') res.respond(err, 404);
      else res.respond(err, 500);
    } else res.respond(bookmark, 200);
  });
});
PUT: Mise à jour

Le fonctionnement de la mise à jour est évidemment un mix entre POST et GET, à la fois dans sa conception et donc son implémentation:

71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
app.put('/bookmarks/bookmark/:id', function (req, res) {
  var id = req.param('id');
  if (isNaN(parseInt(req.param('id')))) {
    res.respond(new Error('ID must be a valid integer'), 400);
  } else if ('undefined' != typeof req.body.id && req.body.id != id) {
    res.respond(new Error('Invalid bookmark ID'), 400);
  } else {
    req.body.id = id;
    db.save(req.body, function (err, bookmark, created) {
      if (err) {
        if (err.type == 'ENOTFOUND') res.respond(err, 404);
        else res.respond(err, 500);
      } else res.respond(bookmark, 200);
    })
  }
});
DELETE: Suppression

La suppression de la base est sans doute la méthode la plus simple:

88
89
90
91
92
app.del('/bookmarks', function (req, res) {
  db.deleteAll(function (err, deleted) {
    res.respond(err || deleted, err ? 500 : 200);
  });
});

La suppression d’un favori effectue les vérifications d’usage et fonctionne ensuite de manière très similaire à

94
95
96
97
98
99
100
101
102
103
104
105
106
app.del('/bookmarks/bookmark/:id', function (req, res) {
  var id = req.param('id');
  if (isNaN(parseInt(req.param('id')))) {
    res.respond(new Error('ID must be a valid integer'), 400);
  } else {
    db.deleteOne(id, function (err, deleted) {
      if (err) {
        if (err.type == 'ENOTFOUND') res.respond(err, 404);
        else res.respond(err, 500);
      } else res.respond(deleted, 200);
    });
  }
});
Méthodes non implémentées

Dans tous les autres cas, il faut retourner le code 405:

49
50
51
app.all('/bookmarks/?*', function (req, res) {
  res.respond(405);
});

Auto-congratulation

On exécute à nouveau nos tests avec vows:

Tout est bon, notre service est prêt :)

Curl pour jouer avec les services REST

On a créé notre service, il tourne, on a pu y accéder via nos tests unitaires, mais maintenant on aimerait bien jouer avec pour de vrai :) Pour les services REST, Curl est l’outil de choix.

D’abord, démarrons notre serveur dans un terminal avec node app. Ensuite, quelques tests! Notez qu’on définit un alias en début de tests pour ne pas respécifier les headers à chaque fois :)

alias API_BOOKMARKS="curl -H 'Content-Type: application/json' -H 'Accept: application/json'"
 
# Ajout d'un bookmark
API_BOOKMARKS -X POST -d '{"title":"naholyr.fr","url":"http://naholyr.fr","tags":["nodejs","javascript","web"]}' 'http://localhost:3000/bookmarks'
# Réponse: {"title":"naholyr.fr","url":"http://naholyr.fr","tags":["nodejs","javascript","web"],"id":1}
 
# Listing de tous les bookmarks
API_BOOKMARKS -X GET 'http://localhost:3000/bookmarks'
# Réponse: ["/bookmarks/bookmark/1"]
 
# Récupération de notre bookmark
API_BOOKMARKS -X GET 'http://localhost:3000/bookmarks/bookmark/1'
# Réponse: {"title":"naholyr.fr","url":"http://naholyr.fr","tags":["nodejs","javascript","web"],"id":1}
 
# Suppression de notre bookmark
API_BOOKMARKS -X DELETE 'http://localhost:3000/bookmarks/bookmark/1'
# Réponse: {"result":true}
 
# Lecture du bookmark
API_BOOKMARKS -X GET 'http://localhost:3000/bookmarks/bookmark/1'
# Réponse: {"code":404,"status":"Not Found","message":"[object Object]"} (code de statut 404 visible avec "curl -v")
 
# On remet notre bookmark
API_BOOKMARKS -X POST -d '{"title":"naholyr.fr","url":"http://naholyr.fr","tags":["nodejs","javascript","web"]}' 'http://localhost:3000/bookmarks'
# Réponse: {"title":"naholyr.fr","url":"http://naholyr.fr","tags":["nodejs","javascript","web"],"id":2}
 
# On le met à jour (changement de title)
API_BOOKMARKS -X PUT -d '{"title":"naholyr.fr, péregrinations webistiques"}' 'http://localhost:3000/bookmarks'
# Réponse: {"code":405,"status":"Method Not Allowed","message":null} (code de statut 405 visible avec "curl -v")
# Ah mais oui, on a essayé de faire un PUT sur /bookmarks ;)
 
# On la refait :P
API_BOOKMARKS -X PUT -d '{"title":"naholyr.fr, péregrinations webistiques"}' 'http://localhost:3000/bookmarks/bookmark/2'
# Réponse: {"title":"naholyr.fr, péregrinations webistiques","url":"http://naholyr.fr","tags":["nodejs","javascript","web"],"id":"2"}
 
# On vide la base
API_BOOKMARKS -X DELETE 'http://localhost:3000/bookmarks'
# Réponse: {"result":true}

Aller plus loin

Dans la prochaine partie, nous allons améliorer notre application avec:

  • Des vérifications en amont, factorisées à l’aide des middlewares Express, pour vérifier les headers par exemple et anticiper des erreurs communes.
  • Une validation des données reçues. Pour l’instant nous ne faisons aucune vérification et un POST avec {"toto":"tata"} sera accepté sans rechigner alors qu’il devrait logiquement lever une erreur 400.
  • Du refactoring. On voit notamment qu’on a une bonne partie de code en commun entre toutes les méthodes portant sur un objet identifié.
  • Le support d’autres format que le JSON, notamment le XML, ce qui nous permettra de reprendre la main sur l’analyse du corps de la requête, et ainsi ajouter…
  • Une meilleure gestion des codes de statut. Actuellement la réception d’un JSON syntaxiquement invalide provoque une erreur 500 non interceptée, c’est une erreur qui devrait lever un statut 400.
  • Une doc d’API un peu sexy :)
  • Et enfin l’export de notre service en tant que module réutilisable dans n’importe quelle application Express!

Enfin, pour aller plus loin avec Node.JS et les services REST, n’hésitez pas à consulter ces ressources:

  • Swagger: un générateur de service REST + jolie documentation
  • Wordnick: le compte github des développeurs de Swagger, avec des ressources intéressantes.
  • Journey: un routeur JSON-only, qui permet d’aller encore plus vite si on décide de se limiter définitivement au support exclusif du JSON (pas forcément une bonne idée à mon avis).

13 réflexions au sujet de « Écrire un service REST avec NodeJS et Express – partie 1/3: implémentation de départ »

  1. karl

    C’est un beau tutoriel pour une HTTP API. C’est déjà très bien, peu de gens font des APIs HTTP correctes. Pour que ce soit REST, il faut que l’API te permette d’accéder à l’ensemble des resources sans avoir à construire les requêtes « à la main. » Typiquement la première doit te permettre de découvrir les autres fonctionnalités de ton API en suivant des liens.

    L’enjeu bien sûr si tu utilises JSON est qu’il y a pas encore de définition de ce qu’est un lien. Les deux formats avec une contrainte hypertexte sont HTML et Atom. Pour JSON, il doit être possible de trouver un intermédiaire en utilisant le header HTTP « Link: » avec une combinaison de valeur rel.

    Je recommande la lecture du billet de Roy Fielding et surtout de tous les commentaires : REST APIs must be hypertext driven.

    Répondre
    1. naholyr Auteur de l’article

      J’avais effectivement lu cet article, mais je trouve qu’à ce stade on va « trop loin » dans l’abstraction. De toute façon ça dépasserait sans doute le cadre de cet article déjà fleuve ;) mais même dans l’absolu, je trouve que les API REST telles qu’elles existent sont plus proches d’une réalité pragmatique et utile que la théorie exposée dans cet article.

      Bien qu’il ait raison dans la théorie, dans la pratique il vaudra mieux un service simple avec une documentation claire, plutôt qu’un service censé fonctionner par découverte, ce qui ne fonctionne concrètement que rarement.

      Mais je ne demande qu’à être convaincu par un cas pratique ;)

      Répondre
  2. Ping : Introduction à Express.js – Partie 1: La base | Atinux

  3. Thibault

    Comme je suis tatillon, je ne peux pas m’empêcher de préciser que la méthode PUT n’est pas automatiquement liée à une action de modification (update).

    PUT permet de définir l’état d’une ressource. C’est une requête idempotente (qui modifie l’état du serveur, mais qu’on peut exécuter plusieurs fois sans effet de bord), mais qui peut trés bien être utilisée pour créer une nouvelle ressource si elle n’existe pas déjà.

    En fait, si l’on voulait être puriste, POST ne devrait être utilisé pour créer une ressource que lorsque PUT n’est pas une option (url de la ressource inconnue à l’avance, ou effet de bord à gérer à la création, etc.)

    Concernant l’autre commentaire, je peux dire que j’ai testé la création d’apis purement rest/roa, avec gestion de l’hypertexte, et je dois dire qu’en pratique, ben ça ne l’est pas trop (pratique). Il y a trop de différences entre un fonctionnement http « pur » et une véritable interaction humaine via un navigateur (réponses aux requêtes delete, taille du document html, redirection après un POST, etc.)

    Bref, c’était le quart d’heure prise de tête. Merci pour l’article, il m’a mis un sacré pied à l’étrier.

    Répondre
    1. naholyr Auteur de l’article

      Merci beaucoup pour cette précision utile!

      C’est vrai qu’il est important de garder un certain pragmatisme, mais appliquer certaines règles « puristes » ne me semble pas moins important. Notamment le côté idempotent du PUT qui est effectivement une spécification de REST et sur laquelle il faudrait pouvoir compter.

      Dans notre contexte, ça signifie entre autres que le principe d’envoyer un « objet partiel » pour ne modifier que certains champs ne devrait pas être supporté, car deux appels identiques pourraient alors renvoyer un résultat différent si un des champs non spécifiés a été modifié entre temps. On casse le principe de la méthode idempotente, et saymal j’avoue ;)

      Répondre
  4. Anthony

    Salut,
    Merci pour ton tuto, on me l’a conseillé et je ne regrette pas.

    Je te remonte tous les points qui m’ont posé problèmes pendant la partie 1 :
    – dans la partie environnement, je te conseille de rajouter le fait de lancer redis. C’est con, mais j’ai mis quelques minutes à comprendre ce qui n’allait pas :)

    – J’ai eu une erreur icic
    app.db.configure({namespace: 'bookmarks-test-rest'});
    app.db était undefined
    En fait, le problème venait de

    var db = exports.db = require('./db')();

    que j’ai remplacé par

    var db = app.db = require('./db')();

    – La première fois que j’ai lancé :

    ./node_modules/api-easy/node_modules/.bin/vows

    j’ai eu une erreur parce que deleteAll n’était pas encore défini.

    – J’ai remplacé deleteAll() dans les tests par deleteAll(function () {}) parce que deleteAll attend une callback

    Répondre
  5. Cherif

    Bonjour,

    J’ai trouve ce tuto sur le net et il a l’air vraiment interessant. J’ai commence a le suivre mais les instructions sont parfois assez vagues. Par exemple dans la section Redis, tu parles de creer un objet db.js. Oui mais ou? dans quel fichier? je savais pas.
    du coup j’ai essaye de voir si je pouvais trouver le code du tutoriel mais y’a pas de lien. Meme sur ton compte github (naholyr). Serait ce possible de rajouter un zip contenant les fichiers du projet? Ca permettra de comparer quand on ne comprend pas.
    Voila. Et merci pour ce tuto assez complet :).

    Répondre
    1. naholyr Auteur de l’article

      Comme son nom l’indique « db.js » n’est pas un objet, mais un fichier justement :)

      Merci pour la suggestion, c’est vrai que ce tuto est assez souvent visité, et un petit résumé (un gist serait probablement suffisant) ne ferait pas de mal. J’essaierai de m’en occuper cette semaine !

      Répondre
  6. Ping : Découvrir l’architecture du www à Devoxx ← JavaTeam Sodifrance

  7. pit

    Etant débutant, je rejoins Anthony sur les petites difficultés de certains passages. Et c’est vrai que les codes sources seraient très appréciés.
    En tout cas, merci pour ce tuto !

    Répondre
  8. Julius

    Super sympa ce tuto. Cependant, je n’arrive pas à comprendre comment après avoir entré la ligne suivante npm install express ejs redis, on arrive à avoir un dossier .bin

    comme dans la commande suivante ./node_modules/.bin/express -t ejs

    comment fais tu ça ? je suis bloqué sur ce point. Impossible d’avancer plus dans ton tuto.

    Répondre
    1. naholyr Auteur de l’article

      Désolé pour le temps de réponse, les vacances, tout ça tout ça ^^
      Ton problème a sans doute été résolu depuis, en tous cas c’est tout simple : c’est npm qui crée ce dossier, donc il n’y a rien à faire. Il va y placer des liens symboliques vers les exécutables déclarés par les packages (en l’occurrence express en expose un qui s’y retrouvera donc).

      Répondre

Laisser un commentaire