Erwan GÉRÉEC

Full Stack Developer & Scrum Master

__Erwan

Mobile developer in my spare time (Android Kotlin, Flutter)

(NodeJS, VueJS)

Web app

Mobile app

RESTs API

Moteur de calcul

BDD

Collecteur

Points d'accès Wifi

Web app

Mobile app

RESTs API

Moteur de calcul

BDD

Collecteur

Points d'accès Wifi

EN 1 HEURE CHRONO

On va voir comment utiliser le framework, découvrir son architecture et jouer avec son ORM Waterline pour créer des APIs REST

SailsJS, Késako ?

Un framework basé sur Express

- Un framework Javascript pour créer des applications Node.js suivant le pattern MVC (Model/View/Controller)

- Idéal pour concevoir des applications temps réel et/ou des services REST

- Basé sur Express

- Fournit un support websocket avec socket.io intégré par défaut

- Pré-configuré avec une liste de conventions définies par défaut

- Entièrement configurable et personnalisable

- Support de gridFS (Stockage fichier dans base mongo)

- Upload d'image sur Amazon S3

Avec un puissant ORM (Waterline)

Un ORM?

- Pour "Object Relationnal Mapping". Fournit une couche d'abstraction pour interagir avec la base de données peu importe la base de données que vous utilisez.

- Plusieurs "adapteurs" disponibles avec Waterline:

      - Local Disk (par défaut, utile en mode développement)

      - MySQL

      - PostgreSQL

      - MongoDB

      - Redis

      - Cassandra

      Mais aussi: Oracle, OrientDB, MySQL Server...

 

Avantages

      - Pré-configuré : Offre un cadre de démarrage à l'équipe de développement

      - Environnement NodeJS : Accès à tous les modules NPM

      - Entièrement en javascript : utilisation commune du JS côté Back et Front

      - Documentation très complète

      - Basé sur Express

      - Ecriture en JavaScript ou TypeScript

      - Entièrement paramétrable

      - Prêt pour être intégré avec des bases de données multiples grâce à Waterline

 

Inconvénients

      - Framework moins connu qu'Express

      - Basé sur npm (On tire des modules de modules de modules, etc..)

      - Taille de repository qui reste du coup assez importante

 

 

 

Installation

On installe SailsJS globalement avec npm

npm install sails -g

C'est le bon moment pour aller prendre une tasse de café.

Nouveau projet

sails new brestjs

On part sur un projet vide: [2]

Choose a template for your new Sails app:
 1. Web App  ·  Extensible project with auth, login, & password recovery
 2. Empty    ·  An empty Sails app, yours to configure
 (type "?" for help, or <CTRL+> to cancel)
?
cd brestjs
sails lift

On démarre le projet:

On créé le projet:

Le nouveau projet est prêt.

 

Par défaut, SailsJS renvoie une page statique basée sur du templating EJS.

erwan:brestjs erwangereec$ sails lift

 info: Starting app...

 info: 
 info:                .-..-.
 info: 
 info:    Sails              <|    .-..-.
 info:    v1.2.3              |\
 info:                       /|.\
 info:                      / || \
 info:                    ,'  |'  \
 info:                 .-'.-==|/_--'
 info:                 `--'-------' 
 info:    __---___--___---___--___---___--___
 info:  ____---___--___---___--___---___--___-__
 info: 
 info: Server lifted in `/Users/erwangereec/Prez/brestjs`
 info: To shut down Sails, press <CTRL> + C at any time.
 info: Read more at https://sailsjs.com/support.

debug: -------------------------------------------------------
debug: :: Mon Jan 13 2020 18:47:01 GMT+0100 (GMT+01:00)

debug: Environment : development
debug: Port        : 1337
debug: -------------------------------------------------------

Hot Reloading

npm i -g nodemon
npm run sails

Créez un script "sails" dans le fichier package.json :

Installer nodemon : (https://nodemon.io/)

...
"scripts": {
    "sails": "nodemon app.js"
},
...

Exécutez votre script:

Architecture d'une application SailsJS

Structure du projet

/api : Couches logiques: Controlleurs, Services, Modèles de données

/assets : Tout le nécessaire pour l'application côté frontend (optionnel)

/config : les fichiers de configuration de l'application SailsJS (Base de donnée, websockets, ..)

/node_modules : Modules Node installés par défaut

/tasks : Liste de tâches Grunt pour la compilation

/views : templates de page statiques au format EJS

app.js : Fichier d'entrée du projet

Cheminement d'une requête

Controleur

Service 1

Modèle

Base de données

Service 2

Service n...

Routes

Policies

Routes

Tout se passe dans un seul fichier : routes.js

 

C'est très déclaratif et facile à maintenir.

 

On peut définir des sous-dossiers pour une meilleure organisation du projet.

["POST /beers"]: {
  controller: "BeerController",
  action: "createBeer"
},

["GET /beers"]: {
  controller: "BeerController",
  action: "getAllBeers"
},

["GET /beers/:beedId"]: {
  controller: "BeerController",
  action: "getOneBeer"
},

["PUT /beers/:beerId"]: {
  controller: "BeerController",
  action: "updateOneBeer"
},

["DELETE /beers/:beerId"]: {
  controller: "BeerController",
  action: "deleteOneBeer"
}

On va y définir toutes les URLs de nos services REST

config/routes.js

Policies

Pour contrôler les données en entrée avant de diriger la requête vers le bon contrôleur.

 

C'est l'endroit idéal pour tester l'authentification associée à une requête, vérifier qu'un utilisateur a la permission d'accéder à une ressource donnée, vérifier la validité des paramètres du body.

 

Pour chaque route, on déclare les policies qui y sont associées.

 

config/policies.js

module.exports.policies = {

  "BeerController": {
    createBeer: [
      "isBeerSchemaValidated"
    ]
  }

};

Hapijs/joi

api/policies/isBeerSchemaValidated.js

const Joi = require("joi");
module.exports = function(req, res, next) {

    let params = req.body;

    // Define validation schema
    const schema = Joi.object().keys({
        name              : Joi.string().required(),
        origin            : Joi.string().required(),
        manufacturingDate : Joi.string().isoDate()
    });

    // Validate the request body data with the schema
    Joi.validate(params, schema, async (errors) => {
        if (errors) {
            // Return 400 Bad Request
        } else {
            return next(); // Continue to the next policy
        }
    });

};

Une lib pour valider très facilement les données en entrée de vos APIs POST et PUT.

Hapijs/joi

- motDePasse : Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$'))

 

- motDePasseRep : Joi.ref('motDePasse')

 

- nom : Joi.string().alphanum().min(3).max(30).required()

 

- dateDeNaissance : Joi.number().integer().min(1900).max(2013)

 

- planetes : Joi.string().valid(["pluton", "neptune"])

 

Controlleurs

- Consomme un ou plusieurs services pour travailler sur les données.

- Retourne la réponse avec le code de retour approprié.

(RFC 7231: https://tools.ietf.org/html/rfc7231)

  /**
   * Create a new Beer
   * This API is used to create a new beer
   *
   * @name createBeer
   * @param {Object} req
   * @param {Object} res
   */
  async createBeer(req, res) {

    try {

      var createBeerRes = await BeerService.createBeer(req.body);

      if (createBeerRes.status === "SUCCESS") {
        return res.status(200).json(createBeerRes.data);
      } else {
        return res.status(500); // Fucking world
      }

    } catch (err) {
      // ...
    }

  }

api/controllers/BeerController.js

Services

- Contiennent des méthodes réutilisables à travers l'application

 

- Sont automatiquement injectés par Sails pour être utilisés dans les contrôleurs. (Injection de dépendances)

 

- Travaillent sur les modèles de données en utilisant l'ORM Waterline

async createBeer(params) {

    let attrs = {};

    if (params.hasOwnProperty("name")) {
      attrs.name = params.name;
    }

    if (params.hasOwnProperty("origin")) {
      attrs.origin = params.origin;
    }

    var createdBeer = await Beer.create(attrs).fetch();

    return {
      status: "SUCCESS",
      data: createdBeer
    }

  }

api/services/BeerService.js

Modèle 1/5

- Un fichier par modèle de données

 

- Contient la déclaration des primitives (attributs) et des associations

 

- Plusieurs types gérés par défaut (string, integer, json, datetime, boolean...)

module.exports = {

  attributes: {

    //  ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦  ╦╔═╗╔═╗
    //  ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
    //  ╩  ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝

    name: {
      type: "string",
      required: true
    },

    origin: {
      type: "string"
    }

    //  ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
    //  ╠═╣╚═╗╚═╗║ ║║  ║╠═╣ ║ ║║ ║║║║╚═╗
    //  ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝

  }

}

api/models/Beer.js

Modèle 2/5

On peut définir une valeur par défaut qui sera renseignée automatiquement en cas d'absence de valeur fournie pour l'attribut.

module.exports = {

  attributes: {

    //  ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦  ╦╔═╗╔═╗
    //  ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
    //  ╩  ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝

    phoneNumber: {
      type: 'string',
      defaultsTo: '111-222-3333'
    }

  }

}

Modèle 3/5

On peut définir des valeurs de validation d'un attribut.

module.exports = {

  attributes: {

    //  ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦  ╦╔═╗╔═╗
    //  ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
    //  ╩  ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝

    musicType: {
      type: 'string',
      isIn: ['pop', 'electro', 'classic'],
    }

  }

}

Modèle 4/5

On peut décider de chiffrer la valeur d'un attribut en base de donnée.

 

Dans ce cas la valeur récupérée lors d'une recherche sera elle aussi chiffrée. (Pourra être déchiffrée en fournissant .decrypt() dans la requête)

module.exports = {

  attributes: {

    //  ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦  ╦╔═╗╔═╗
    //  ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
    //  ╩  ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝

    password: {
      type: 'string',
      encrypt: true
    }

  }

}

Modèle 5/5

- Une méthode "customToJSON()' définie par défaut qui permet de customiser les clés qui seront retournées pour ce modèle.

 

- Plusieurs méthodes de callback disponibles pour gérer le cycle de vie d'un modèle : beforeCreate(), afterCreate(), beforeUpdate(), afterUpdate(), beforeDestroy(), afterDestroy()

customToJSON: function () {
   const data = _.omit(this, ["password"]);
   return data;
},

afterDestroy: async (deletedUser, proceed) => {
   await Activity.destroy({user: deletedUser.id});
   await proceed();
}

api/models/User.js

Jouons avec Waterline

Quelques mots...

- Le langage de requête s'appelle "Waterline Query Language".

 

- Waterline sait comment interpréter ce langage pour le convertir dans le langage adapté à la base de données cible.

 

- Waterline utilise le(s) adaptateur(s) de base de données déclaré(s) dans votre projet pour traduire ce langage en requêtes natives et envoyer ces requêtes à la base de données.

 

- Cela signifie que vous pouvez utiliser la même requête avec MySQL que vous le faites avec Redis ou MongoDB.

 

- Cela signifie également que vous pouvez modifier votre base de données avec un minimum de modifications dans votre code applicatif.

Quelques exemples

find({})

var data = await Communaute.find({
  name: 'brestjs'
});
var data = await Communaute.find({
  name: 'brestjs',
  location: 'brest'
});
var data = await Communaute.find({
  name: {
    'contains' : 'brest'
  }
});

find({})

var data = await User.find({
  age: { '<': 30 }
});
var data = User.find({
  name: { in: ['jules', 'enzo'] }
});
var data = await Log.find({
  trace: { contains: 'error' }
});

Tous les critères

'<'

'<='

'>'

'>='

'!='

nin

in

contains

startsWith

endsWith

Options

Définir une limite sur le volume de données

User.find({
   where: { name: 'john' },
   limit: 20
});

Tri ascendant

User.find({
   where: { name: 'john' },
   sort: 'name ASC'
});
User.find({
   where: { name: 'john' },
   sort: 'name DESC'
});

Tri descendant

create()

Création d'un nouveau utilisateur

await User.create({
   name:'john',
   age: 20
});
await User.create({
   name:'john',
   age: 20
}).fetch();

Création d'un nouveau utilisateur et récupération immédiate

update() / updateOne()

Mise à jour de plusieurs entrées à la fois

await User.update({ name:'john' })
.set({
  name:'lucas'
});
await User.updateOne({ name:'john' })
.set({
  name:'lucas'
});

Mise à jour d'une entrée

Mise à jour et récupération immédiate

await User.update({ name:'john' })
.set({
  name:'lucas'
}).fetch();

stream()

Utile pour travailler sur une grande quantité de données sans mettre votre serveur parterre...

await User.stream({
  name:'john'
}).eachRecord(async (user)=>{
  sails.log(`Nouvel utilisateur trouvé. Id: ${user.id}.`);
});

getDatastore()

Un accès direct à la base de données sans passer par Waterline. Peut vous être utile dans certains cas particuliers.

var db = Site.getDatastore().manager;
db.collection("site").ensureIndex({location: '2dsphere'}, function () {
   cb();
});

Exemple: On indique ici que la clé "location" du modèle site est un GeoJSON.

Gestion des associations

Association One-to-One 1/2

Exemple : Un utilisateur a une adresse postale.

module.exports = {

    attributes: {
        
        name: {
            type: "string"
        },
        
        email: {
            type: "string",
            required: true
        },

        address: {
            collection: "address", // Target model
            via: "user" // Current model
        }

    }

};
module.exports = {

    attributes: {
        
        rue: {
            type: "string"
        },

        codePostal: {
            type: "string"
        },
        
        ville: {
        	type: "string"
        },
        
        user: {
            model: "user",
            unique: true
        }

    }

};

api/models/User.js

api/models/Address.js

Pour ajouter une adresse à un utilisateur, il faut créer l'adresse en donnant l’id de l’utilisateur :

await Address.create({
   rue: "2 rue victor hugo", codePostal: "29200", ville: "brest",
   user: "abcdefghijklmnqrtuvwxyz"
})

Association One-to-One 2/2

Exemple : Un utilisateur a une adresse postale.

Du coup, lors de la récupération d'un utilisateur, on peut utiliser populate() pour populer automatiquement l'association address:

await User.findOne({
   email: "test@test.com"
}).populate("address");
{
   name: "test",
   email: "test@test.com",
   address: {
      rue: "2 rue victor hugo",
      codePostal: "29200",
      ville: "brest"
   }
}

Association One-to-Many

Exemple : Un utilisateur a plusieurs comptes bancaires.

module.exports = {

    attributes: {
        
        name: {
            type: "string"
        },
        
        email: {
            type: "string",
            required: true
        },

        bankAccounts: {
            collection: "bankAccount", // Target model
            via: "user" // Current model
        }

    }

};
module.exports = {

    attributes: {
        
        name: {
            type: "string"
        },
        
        solde: {
            type: "integer"
        },

        user: {
            model: "user"
        }

    }

};

api/models/User.js

api/models/BankAccount.js

Pour ajouter un compte à un utilisateur, il faut créer le compte en donnant l’id de l’utilisateur :

await BankAccount.create({ name: "compte courant", solde: 500, user: "abcdefghijklmnqrtuvwxyz" })

Association Many-to-Many

Exemple : Un utilisateur peut conduire plusieurs voitures. Une voiture peut être conduite par plusieurs utilisateurs.

module.exports = {

   attributes: {
     
     voitures: {
        collection: "voiture", // Target model
        via: "user" // Current model
        through: "voitureuser" // Pivot collection
     }

   }

};
module.exports = {

    attributes: {
        
        immatriculation: {
            type: "string"
        },
        
        users: {
            collection: "user",
            via: "voiture",
            through: "voitureuser"
        }

    }

};

api/models/User.js

api/models/Voiture.js

api/models/VoitureUser.js

module.exports = {

   attributes: {
     
     user: {
        model: "user"
     },
     
     voiture: {
        model: "voiture"
     }

   }

};

On créé dans ce cas une collection Pivot qui contiendra les ids des 2 objets.

Allons un peu plus loin

Une APIDoc aux petits oignons

Checklist

- Avoir un Guide d'authentification

- Avoir une section Getting Started

- Avoir une section API Reference avec descriptions des endpoints

- Avoir des exemples de code dans différents langages

- Avoir une description des réponses (succès et erreurs)

sails-hook-swagger-generator

Un outil pour générer une doc Swagger à partir de vos APIs écrites dans Sails.

["POST /beers"]: {
    controller: "BeerController",
    action: "createBeer",
    swagger: {
      summary: "Create one Beer",
      description: "This API allows to create a new beer.",
      body: {
        name: {
          type: "string",
          required: true,
          description: "This is the name of the Beer."
        },
        origin: {
          type: "string",
          required: true,
          description: "This is the country code origin of the Beer."
        }
      },
      parameters: [{
        in: "header",
        name: "Authorization",
        required: true,
        type: "string",
        description: "Bearer {{access_token}}"
      }],
      ...

L'outil génère un fichier swagger.json....

qui peut être lu sur : editor.swagger.io ...

... ou être utilisé pour générer une documentation avec Redoc

https://github.com/Redocly/redoc

Lecture du fichier swagger.json pour générer une APIdoc avec Redoc

node ./node_modules/redoc-cli/index.js bundle
  --output apidoc/swagger/apidoc.html
  --title 'Mon API API Reference'
  swagger/swagger.json

Résultat : (fichier apidoc.html)

Merci :)

Questions ?

Erwan GÉRÉEC

__Erwan

SailsJS

By Erwan Gereec

SailsJS

  • 420