API RESTful avec Adonis

Adonis est un framework Node.js orienté MVC. Contrairement à Express, il n'est pas monolitique. Il offre une solution assez complète allant de l'ORM aux tests unitaires en passant par la gestion des logs et d'autres choses...

Dans ce tutoriel, nous allons créer une API RESTful avec une base de données Postgres (vous pouvez utiliser SQLite, MySQL, MariaDB et d'autres SGBDR SQL avec Adonis).

A noter : ce tutoriel a été rédigé avec Adonis en version 4.1.

Préparatifs

Dans cette première partie, nous allons installer Adonis et préparer (rapidement) le serveur Postgres.

Installation d'Adonis

Pour installer Adonis vous aurez besoin d'avoir une version supérieure à égale à Node.js 8.

$ npm i -g @adonisjs/cli

Liste des commandes d'Adonis

Génération du projet

Ensuite, lancez la commande ci-dessous.

# adonis-api est le nom du projet
$ adonis new adonis-api --api-only

Dès que l'installation est terminée, vous pouvez démarrer le serveur.

cd adonis-api
adonis serve --dev

Pour vous connecter à votre application sur http://localhost:3333 qui renvoit un message en JSON {"greeting":"Hello world in JSON"}.

Configuration de la base de données

Création d'un container Docker (ou pas selon votre choix).

$ docker run --name adonis-postgres -p 5432:5432 -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=mysecretpassword -e -d postgres

Après création du container.

docker run -it --rm --link adonis-postgres:postgres postgres psql -h postgres -U admin

Dans votre SGBDR, créez 2 bases de données. La première pour l'API et la seconde pour faire les tests unitaires.

CREATE DATABASE adonis_api;
CREATE DATABASE adonis_api_test;

Dans le fichier de configuration ".env", modifiez les lignes ci-dessous en fonction de la configuration de votre SGBD (ci-dessous avec la configuration du container Docker).

DB_CONNECTION=pg
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=admin
DB_PASSWORD=mysecretpassword
DB_DATABASE=adonis_api

Remarque : si vous utilisez Git, ajoutez le fichier ".env" dans le fichier ".gitignore" (en plus du dossier "node_modules").

Modèle et migration

Dans cette seconde partie, nous allons générer un modèle et configurer la migration des données. Adonis utilise le générateur de requête Knex.js.

Tout d'abord le modèle que nous générons en ligne de commande.

$ adonis make:model Post --migration

Afin d'écrire les instructions de migration pour créer ou pour détruire le schéma de la table dans le fichier "database/migrations/\_post_schema.js".

//database/migrations/\<timestamp>\_post_schema.js

'use strict'

const Schema = use('Schema')

class PostSchema extends Schema {
  up () {
    this.create('posts', (table) => {
      table.increments()
      table.string('title', 255)
      table.text('content')
      table.timestamps()
    })
  }

  down () {
    this.drop('posts')
  }
}

module.exports = PostSchema

La ligne table.increments() correspond au champ "id" incrémenté et la ligne table.timestamps() à 2 champs générés de type "datetime" nommés "created_at" et "updated_at".

Pour communiquer avec le SGBD, il faut installer le module concerné. Avec Postgres.

npm install pg --save

On peut désormais lancer la migration.

adonis migration:run

A la suite de cette commande, vous pouvez vérifier l'état de la table "posts" dans votre SGBD.

adonis_api=# \d+ posts
                                                           Table "public.posts"
   Column   |           Type           | Collation | Nullable |              Default              | Storage  | Stats target | Description 
------------+--------------------------+-----------+----------+-----------------------------------+----------+--------------+-------------
 id         | integer                  |           | not null | nextval('posts_id_seq'::regclass) | plain    |              | 
 title      | character varying(255)   |           |          |                                   | extended |              | 
 content    | text                     |           |          |                                   | extended |              | 
 created_at | timestamp with time zone |           |          |                                   | plain    |              | 
 updated_at | timestamp with time zone |           |          |                                   | plain    |              | 
Indexes:
    "posts_pkey" PRIMARY KEY, btree (id)

Remarque : si vous avez fait une erreur dans l'écriture de votre migration, tapez la commande adonis migration:reset pour annuler et revenir à 0.

Création des routes

Dans cette troisième partie, nous allons créer nos routes. On part sur une base CRUD (Create Read Update Delete) classique.

Schéma des routes

Verb URL Action Vérifier ID Vérifier champs
POST /api/v1/posts Ajouter une ligne Non Oui
GET /api/v1/posts Lister les lignes Non Non
GET /api/v1/posts/:id Obtenir une ligne Oui Non
PUT /api/v1/posts/:id Modifier une ligne Oui Oui
DELETE /api/v1/posts/:id Supprimer une ligne Oui Non

Pour les requêtes de type GET (la seconde), PUT et DELETE, nous aurons besoin de récupérer l'id de la ligne concerné. Et verifier les champs des requêtes de type POST et PUT.

Stockage des messages génériques

On va créer un fichier avec nos messages génériques que l'on affiche en JSON. Cela va nous permettre de ne pas nous répéter lors de la création des tests automatisés dans la prochaine partie. Dans le dossier "app", créez un nouveau dossier "app/helpers" dans lequel vous créez un nouveau fichier "Messages.js".

// app/Helpers/Messages.js

module.exports = {
  post: {
    errorNotFound: 'Post not found',
    errorBadRequest: 'Fields error',
    okNothing: 'No posts available',
    okDeleted: 'Post deleted'
  }
}

Création d'un contrôleur

Avec une ligne de commande, on génère notre contrôleur "postController".

adonis make:controller postController
> Select controller type 
‣ For HTTP requests
  For Websocket channel

Dans ce contrôleur, on charger le modèle, la database, le fichier contenant les messages génériques et l'outil de log intégré "logger". Chaque fonction représente une route.

// app/Controllers/Http/postControllers.js

'use strict'

const Post = use('App/Models/Post')
const Database = use('Database')
const Messages = use('App/Helpers/Message')
const Logger = use('Logger')

class PostController {
  // Ajouter une ligne
  async create ({ request, response }) {
  }

  // Lister les lignes
  async index ({ request, response }) {
  }

  // Obtenir une ligne
  async fetchOne ({ params, request, response }) {
  }

  // Modifier une ligne
  async update ({ params, request, response }) {
  }

  // Supprimer une ligne
  async delete ({ params, request, response }) {
  }
}

module.exports = postController

Dans le fichier start/routes.js, on déclare nos routes groupées avec le préfixe "api/v1".

// start/routes.js

'use strict'

const Route = use('Route')

Route.get('/', ({ request }) => {
  return { greeting: 'Hello world in JSON' }
})

Route.group(() => {
  Route.post('posts', 'PostController.create')
  Route.get('posts', 'PostController.index')
  Route.get('posts/:id', 'PostController.fetchOne')
  Route.put('posts/:id', 'PostController.update')
  Route.delete('posts/:id', 'PostController.delete')
}).prefix('api/v1')

Chaque route à son contrôleur et sa fonction attribuée séparée par un point.

adonis route:list

Ajouter une ligne

Pour ajouter une ligne, on doit récupérer son contenu.

// app/Controllers/Http/postControllers.js (extrait)

// Ajouter une ligne
async create ({ request, response }) {
  const body = request.only(['title', 'content'])

  if (body.title && body.content) {
    const newPost = new Post()
    newPost.title = body.title
    newPost.content = body.content
    await newPost.save()

    Logger.info('%s - %s', request.method(), request.url())
    return response.status(201).json(newPost)
  }

  Logger.error('%s - %s', request.method(), request.url())
  return response.status(400).json({ message: Messages.post.errorBadRequest })
}

On récupère les valeurs rentrées par l'utilisateur dans la constante body. Puis on vérifie que les champs "title" et "content" ont été saisi. Puis on instancie une autre constante en se basant sur le schéma d'un nouveau "Post". Et pour finir, on enregistre dans la base et on retourne le résultat avec la valeur de la nouvelle ligne.

Lister les lignes

On veut retouner la liste des lignes présentes dans la table.

// app/Controllers/Http/postControllers.js (extrait)

// Lister les lignes
async index ({ request, response }) {
  const posts = await Database
  .table('posts')
  .orderBy('id', 'desc')

  if (Object.keys(posts).length === 0) {
    Logger.error('%s - %s', request.method(), request.url())
    return response.json({ message: Messages.post.okNothing })
  }

  Logger.info('%s - %s', request.method(), request.url())
  return posts
}

Pour cela, on utilise la fonction all().

Afficher une ligne

Pour afficher une ligne, on doit récupérer son id en paramêtre.

// app/Controllers/Http/postControllers.js (extrait)

/* Obtenir une ligne */
async fetchOne ({ params, request, response }) { 
  const post = await Post.find(params.id)

  if (!post) {
    Logger.error('%s - %s', request.method(), request.url())
    return response.status(404).json({ message: Messages.post.errorNotFound })
  }

  Logger.info('%s - %s', request.method(), request.url())
  return post   
}

La fonction find retourne toujours une seule ligne.

Modifier une ligne

Pour modifier une ligne, on doit récupérer son contenu dans le body et son id en paramêtre.

// app/Controllers/Http/postControllers.js (extrait)

/* Modifier une ligne */
async update ({ params, request, response }) {
  const body = request.only(['title', 'content'])
  const post = await Post.find(params.id)

  if (!post) {
    Logger.error('%s - %s', request.method(), request.url())
    return response.status(404).json({ message: Messages.post.errorNotFound })
  }

  if (body.title && body.content) {
    post.title = body.title
    post.content = body.content
    await post.save()

    Logger.info('%s - %s', request.method(), request.url())
    return post
  }

  Logger.error('%s - %s', request.method(), request.url())
  return response.status(400).json({ message: Messages.post.errorBadRequest })
}

Semblable à la création d'un nouveau post à l'exception que l'on récupère l'id concerné.

Supprimer une ligne

Pour supprimer une ligne, on doit récupérer son id.

// app/Controllers/Http/postControllers.js (extrait)

/* Supprimer une ligne */
async delete ({ params, request, response }) {
  const post = await Post.find(params.id)

  if (!post) {
    Logger.error('%s - %s', request.method(), request.url())
    return response.status(404).json({ message: Messages.post.errorNotFound })
  }

  await post.delete()

  Logger.info('%s - %s', request.method(), request.url())
  return response.json({ message: Messages.post.okDeleted })
}

On supprime le post concerné (s'il existe) et on affiche un message personnalisé.

Activation de CORS

L'activation du CORS permet d'écouter l'API sur un autre port ou domaine que celui du serveur (3333 par défaut) depuis un navigateur Web.

Ouvrez le fichier "config/cors.js" et modifiez les 3 lignes ci-dessous.

// config/cors.js (extrait)

origin: '*',
methods: ['GET', 'PUT', 'POST', 'DELETE'],
headers: true,

Tests automatisés

Dans cette troisième et dernière partie, nous allons automatiser nos tests. Il existe 2 manières de tester son API. La première, de façon manuelle avec un logiciel comme Postman. Quant à la seconde, elle consiste à écrire soit même les tests qui se réveleront indispensables dans le cas d'un développement en intégration continue, les tests automatisés.

Installation du package Vow

Tapez la commande ci-dessous.

adonis install @adonisjs/vow
create: vowfile.js
create: test/unit/example.spec.js
create: .env.testing

Vous pouvez supprimez le fichier "test/unit/example.spec.js".

Dans le fichier "start/app.js", ajoutez la dépendance de Vow.

// start/app.js (extrait)

const aceProviders = [
  '@adonisjs/lucid/providers/MigrationsProvider',
  '@adonisjs/vow/providers/VowProvider'
]

On peut désormais générér le fichier de test ("test/unit/post.spec.js") avec la commande ci-dessous.

$ adonis make:test post

Isolation des tests

Avant de continuer, on va demander à Adonis de travailler sur une autre base. Cette dernière sera utilisée uniquement dans le cadre des tests. Par défaut, elle sera tout le temps vide sauf au moment des tests. On appel cette dernière dans le fichier ".env.testing" (ci-dessous avec la configuration du container Docker).

HOST=127.0.0.1
PORT=4000
NODE_ENV=testing
DB_CONNECTION=pg
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=admin
DB_PASSWORD=mysecretpassword
DB_DATABASE=adonis_api_test

Remarque : si vous utilisez Git, ajoutez le fichier ".env.testing" dans le fichier ".gitignore".

Configuration de Vow

Il faut indiquer que l'on souhaite faire la migration des données dans le fichier "vowfile.js". Décommentez les lignes 14, 37 et 60 pour exécuter la migration au début des tests et réinitialiser la migration en fin des tests ("up" et "rollback"). De cette façon, on a toujours une base de données vide en début et à la fin des tests.

Ecriture des tests

On va donc travailler dans le fichier précédement généré "test/unit/post.spec.js".

// test/unit/post.spec.js
'use strict'

const { test, trait } = use('Test/Suite')('posts API')
const API_URL = 'api/v1/posts'
const Messages = use('App/Helpers/Messages')

trait('Test/ApiClient')

Vérification d'un message d'erreur

// test/unit/post.spec.js (extrait)

test('No posts', async ({ client }) => {
  const response = await client
  .get(API_URL)
  .end()

  response.assertHeader('content-type', 'application/json; charset=utf-8')
  response.assertStatus(200)
  response.assertJSONSubset({
    message: Messages.post.okNothing
  })
})

Ajout d'une ligne incomplète

// test/unit/post.spec.js (extrait)

test('Post a post with missing field', async ({ client }) => {
  const a = await client
  .post(API_URL)
  .send({
    title: 'test'
  })
  .end()

  a.assertHeader('content-type', 'application/json; charset=utf-8')
  a.assertStatus(400)
  a.assertJSONSubset({
    message: Messages.post.errorBadRequest
  })
})

Ajout de 2 lignes

// test/unit/post.spec.js (extrait)

test('Post 2 posts', async ({ client }) => {
  const a = await client
  .post(API_URL)
  .send({
    title: 'My First Post',
    content: 'Lorem Ipsum'
  }).end()

  a.assertHeader('content-type', 'application/json; charset=utf-8')
  a.assertStatus(201)
  a.assertJSONSubset({
    id: 1,
    title: 'My First Post',
    content: 'Lorem Ipsum'
  })

  const b = await client
  .post(API_URL)
  .send({
    id: 2,
    title: 'My Second Post',
    content: 'Rosa Rosae'
  }).end()

  b.assertHeader('content-type', 'application/json; charset=utf-8')
  b.assertStatus(201)
  b.assertJSONSubset({
    title: 'My Second Post',
    content: 'Rosa Rosae'
  })

  const response = await client
  .get(API_URL)
  .end()

  response.assertHeader('content-type', 'application/json; charset=utf-8')
  response.assertStatus(200)
  response.assertJSONSubset([{
    id: 1,
    title: 'My First Post',
    content: 'Lorem Ipsum'
  },
  {
    id: 2,
    title: 'My Second Post',
    content: 'Rosa Rosae'
  }])
})

Ajout de 2 lignes :

Vérification de l'ajout

Vérification de l'existence d'un post

// test/unit/post.spec.js (extrait)

test('Get a post', async ({ client }) => {
  const response = await client
  .get(`${API_URL}/1`)
  .end()

  response.assertHeader('content-type', 'application/json; charset=utf-8')
  response.assertStatus(200)
  response.assertJSONSubset({
    title: 'My First Post',
    content: 'Lorem Ipsum'
  })
})

Modification d'un post avec un champ manquant

// test/unit/post.spec.js (extrait)

test('Update a post with a missing field', async ({ client }) => {
  const response = await client
  .put(`${API_URL}/1`)
  .field('title', 'test2')
  .end()

  response.assertHeader('content-type', 'application/json; charset=utf-8')
  response.assertStatus(400)
  response.assertJSONSubset({
    message: Messages.post.errorBadRequest
  })
})

Modification d'un post avec un mauvais id

// test/unit/post.spec.js (extrait)

test('Update a post with bad id', async ({ client }) => {
  const response = await client
  .put(`${API_URL}/42`)
  .field('title', 'test2')
  .field('content', 'adonis')
  .end()

  response.assertHeader('content-type', 'application/json; charset=utf-8')
  response.assertStatus(404)
  response.assertJSONSubset({
    message: Messages.post.errorNotFound
  })
})

Modification d'un post

// test/unit/post.spec.js (extrait)

test('Update a post', async ({ client }) => {
  const response = await client
  .put(`${API_URL}/1`)
  .field('title', 'My First Post')
  .field('content', 'Updated')
  .end()

  response.assertHeader('content-type', 'application/json; charset=utf-8')
  response.assertStatus(200)
  response.assertJSONSubset({
    title: 'My First Post',
    content: 'Updated'
  })
})

Suppression d'un post avec un mauvais id

// test/unit/post.spec.js (extrait)

test('Delete a post with bad id', async ({ client }) => {
  const response = await client
  .delete(`${API_URL}/42`)
  .end()

  response.assertHeader('content-type', 'application/json; charset=utf-8')
  response.assertStatus(404)
  response.assertJSONSubset({
    message: Messages.post.errorNotFound
  })
})

Suppression d'un post

// test/unit/post.spec.js (extrait)

test('Delete a post', async ({ client }) => {
  const response = await client
  .delete(`${API_URL}/1`)
  .end()

  response.assertHeader('content-type', 'application/json; charset=utf-8')
  response.assertStatus(200)
  response.assertJSONSubset({
    message: Messages.post.okDeleted
  })
})

Vérification de l'existence d'une 404

// test/unit/post.spec.js (extrait)

test('Post 404', async ({ client }) => {
  const response = await client
  .get(`${API_URL}/1`)
  .end()

  response.assertHeader('content-type', 'application/json; charset=utf-8')
  response.assertStatus(404)
  response.assertJSONSubset({
    message: Messages.post.errorNotFound
  })
})

Vérification de la ligne restante

// test/unit/post.spec.js (extrait)

test('All posts', async ({ client }) => { 
  const response = await client
  .get(API_URL)
  .end()

  response.assertHeader('content-type', 'application/json; charset=utf-8')
  response.assertStatus(200)
  response.assertJSONSubset([{
    title: 'My Second Post',
    content: 'Rosa Rosae'
  }])
})

Lancez les tests avec la commande adonis test.

adonis test

Remarque : les tests étant exécutés sur un autre port (4000 par défaut), il n'est pas nécessaire de faire tourner le serveur de l'API (3333 par défaut).

Conclusion

La mise en place d'une API RESTful avec Adonis se révèle assez rapide en gagnant du temps avec la migration. Le seul bémol à l'heure de l'écriture de ce tutoriel est l'absence d'un watcher sur les tests unitaires contrairement à Jest. Un point qui n'a pas été abordé ici, est l'authentification et d'autre choses à voir sur la documentation officielle.

Sources