Designer une belle API Rest

Ou en tout cas essayer ...

On se demande souvent comment développer une API Rest simple, solide et évolutive, c’est la question à laquelle je vais essayer de répondre en présentant mon approche des différentes problématiques à prendre en compte.

Qui suis-je ?

Designer une belle API Rest

  • Qu'est-ce que REST ?
  • Points forts de REST
  • Rappel des bases
  • Les méthodes
  • Relations entre ressources : abuser des headers !
  • Versionning
  • Authentification

Qu'est-ce que REST ?

Un type d'architecture d'API

Manipule des ressources/collections

Orienté services

Contient plusieurs niveaux de définition

Qu'est-ce que REST ?

Manipule des ressources/collections

Un type d'architecture d'API

Orienté services

Contient plusieurs niveaux de définition

Points forts de REST

Simple : on abstrait la couche de persistance

Stateless : pas de session

Performant pour du texte : avec une politique de cache adaptée

Uniforme : Prévisible, self documenté (HATEOAS)

Portable : Aujourd'hui tout le monde connait HTTP/1.1

Rappel des bases

1 URI correspond à 1 ressource


GET /data/2.0/some-collection/some-id
Host : api.example.com

HTTP/1.1 200 OK

{
  hello : 'World',
  hey   : 'Ho',
  lets  : 'Go'
}
            

PAS REST : Une URI par version, pour une même ressource

Rappel des bases

1 URI correspond à 1 ressource


GET /data/some-collection/some-id
Host : api.example.com

HTTP/1.1 200 OK

{
  hello : 'World',
  hey   : 'Ho',
  lets  : 'Go'
}
            

REST

Rappel des bases

Les collection, au pluriel ou non ?

Peu importe, mais rester consistant !

/user
/user/1

OU

/users
/users/1

Rappel des bases

Format/tri/filtre/ordre de données en paramètre de requêtedans le header


GET /data/some-collection/some-id.xml
Host : api.example.com
            

GET /data/some-collection/some-id.json
Host : api.example.com
            

PAS REST : 2 URIs pour une ressource

Rappel des bases

Format/tri/filtre/ordre de données en paramètre de requêtedans le header


GET /data/some-collection/some-id
Host : api.example.com
Accept : application/json,application/xml;q=0.9
            

GET /data/some-collection/some-id
Host : api.example.com
Accept : application/xml,application/json;q=0.9
            

REST

Rappel des bases

Format/tri/filtre/ordre de données en paramètre de requêtedans le header


GET /data/some-collection/0/10/
Host : api.example.com
Accept : application/json
            

PAS REST : So damn many URIs

Rappel des bases

Format/tri/filtre/ordre de données en paramètre de requêtedans le header


GET /data/some-collection?start=0&size=10
Host : api.example.com
Accept : application/json
            

HTTP/1.1 206 Partial Content
            

REST

Rappel des bases

Pas de majuscules dans l'URI, priviléger le tiret


GET /data/someCollection
Host : api.example.com
            

Danger zone


GET /data/some-collection
Host : api.example.com
            

Safe zone

Rappel des bases

Jamais de verbes dans l'URI


GET /data/some-collection/some-id/get-data
Host : api.example.com
Accept : application/json
            

POST /data/some-collection/some-id/delete-data
Host : api.example.com
Accept : application/json
            

PAS REST : Utiliser les méthodes HTTP

Les méthodes HTTP

Les classiques (CRUD basique)

  • GET
  • DELETE
  • PUT
  • POST

Mais aussi

  • PATCH
  • OPTIONS
  • HEAD
  • TRACE
  • CONNECT

GET

  • READ du CRUD
  • Nullipotent
  • On récupère des données

GET


GET /data/user
Host : api.example.com
            
Renvoie la collection d'utilisateurs (ou la collection de leur URIs)


GET /data/user/1
Host : api.example.com
            
Renvoie l'utilisateur avec id 1

DELETE

  • DELETE du CRUD
  • Idempotent
  • On supprime la donnée ou on vide la collection

DELETE

/user
Vide la collection d'utilisateur

/user/1
Supprime l'utilisateur ayant pour id 1

PUT

  • CREATE ou UPDATE du CRUD
  • Idempotent
  • On crée ou on écrase les données

PUT

/user
Écrase toute la collection user par la collection envoyée en corps de requête

/user/1
Si l'utilisateur n'existe pas, la crée, sinon l'écrase

Attention à bien envoyer la ressource entière
Le PUT ne correspond pas à une mise à jour partielle

PUT


PUT /data/user/1
Host : api.example.com

{
  "login"    : "Hello",
  "password" : "World"
}
            

On crée (ou remplace) l'utilisateur 1 à partir de tout le corps de la requète

POST

  • 2 comportements différents
  • Non-idempotent

POST

1er comportement : Provoque le changement d'une ressource

/user/1/picture
Si l'utilisateur a une photo, la crée. Sinon autre chose (par ex: exception/remplace/...)

/user/1/friend/5
Ajoute l'utilisateur 5 à sa friend list, sinon le retire

POST

2nd comportement : ajoute un élément à une collection, sans définir son id

/user/1/center-of-interest
Ajoute un centre d'intérêt à la liste.

PATCH

  • Non-idempotent
  • On met à jour partiellement des données

PATCH

Mon conseil : RFC 6902


PUT /data/user/1
Host : api.example.com
Content-Type: application/json-patch+json

[
  { "op": "test", "path": "/a/b/c", "value": "foo" },
  { "op": "remove", "path": "/a/b/c" },
  { "op": "add", "path": "/a/b/c", "value": [ "foo", "bar" ] },
  { "op": "replace", "path": "/a/b/c", "value": 42 },
  { "op": "move", "from": "/a/b/c", "path": "/a/b/d" },
  { "op": "copy", "from": "/a/b/d", "path": "/a/b/e" }
]
            

GET /data/user/1
Host : api.example.com

{
  "a" : {
    "b" : {
      "d" : 42,
      "e" : 42
    }
  }
}
            

OPTIONS

  • Nullipotent
  • Retourne les methodes autorisées à l'URI appelée

OPTIONS


OPTIONS /data/user/1
Host : api.example.com

HTTP/1.1 204 No content
Allow : GET,PUT,DELETE,OPTIONS,PATCH,HEAD
            

HEAD

  • Nullipotent
  • Retourne le header renvoyé lors d'un GET à l'URI appelée

Les relations entre ressources

Des relations ? Mais pourquoi ?

Le niveau 3 d'une architecture REST indique qu'une telle api doit être self-descritive

Le mot clé : HATEOAS
Hypermedia as the Engine of Application State

Les relations entre ressources

Que nous dit HATEOAS ?

Je ne dois jamais avoir à deviner :

  • Comment accéder à une ressource
  • Quelles sont les relations entre mes ressources

Les relations entre ressources

Simple avec XML


GET /data/user/1
Host : api.example.com

HTTP/1.1 200 OK



  1
  CHALET
  Nicolas
  <link rel="self" href="/data/user/1" />
  <link rel="shipping-adress" href="/data/adress/29" />

            

XML c'est bien, si on aime la redondance ...

Les relations entre ressources

Hal+json


{
  "_links": {
    "self": { "href": "/orders" },
    "curies": [{
      "name": "ea",
      "href": "http://example.com/docs/rels/{rel}",
      "templated": true
    }],
    "next": { "href": "/orders?page=2" },
    "ea:find": {
      "href": "/orders{?id}",
      "templated": true
    },
    "ea:admin": [{
      "href": "/admins/2",
      "title": "Fred"
    }, {
      "href": "/admins/5",
      "title": "Kate"
    }]
  },
  "currentlyProcessing": 14,
  "shippedToday": 20,
  "_embedded": {
    "ea:order": [
      {
        "_links": {
          "self": { "href": "/orders/123" },
          "ea:basket": { "href": "/baskets/98712" },
          "ea:customer": { "href": "/customers/7809" }
        },
        "total": 30.00,
        "currency": "USD",
        "status": "shipped"
      }, {
        "_links": {
          "self": { "href": "/orders/124" },
          "ea:basket": { "href": "/baskets/97213" },
          "ea:customer": { "href": "/customers/12369" }
        },
        "total": 20.00,
        "currency": "USD",
        "status": "processing"
      }
    ]
  }
}
            

Verbeux ...

Les relations entre ressources

Pour une ressource


GET /data/user/1
Host : api.example.com

HTTP/1.1 200 OK

{
  "_links": {
    "self"            : { "href" : "/data/user/1" },
    "shippingAddress" : { "href" : "/data/address/29" }
  },
  "_data": {
    "id"        : 1,
    "firstName" : "Nicolas",
    "lastName"  : "CHALET"
  }
}
            

Les relations entre ressources

Pour une collection paginée


GET /data/user?start=0&size=2
Host : api.example.com

HTTP/1.1 206 Partial Content

{
  "_links": {
    "self"  : { "href" : "/data/user?start=0&size=2" },
    "next"  : { "href" : "/data/user?start=2&size=2" },
    "first" : { "href" : "/data/user?start=0&size=2" },
    "last"  : { "href" : "/data/user?start=16&size=2" },
  },
  "_data": [
    {
      "_links": {
        "self"            : { "href" : "/data/user/1" },
        "shippingAddress" : { "href" : "/data/address/29" },
      },
      "id"        : 1,
      "firstName" : "Nicolas",
      "lastName"  : "CHALET"
    }, {
      "_links": {
        "self"            : { "href" : "/data/user/2" },
        "shippingAddress" : { "href" : "/data/address/35" },
      },
      "id"        : 2,
      "firstName" : "Tony",
      "lastName"  : "STARK"
    }
  ]
}
            

Les relations entre ressources

Étoffons le header


GET /data/user?start=0&size=2
Host : api.example.com

HTTP/1.1 206 Partial Content
Accept-Ranges: user
Content-Range: user 0-2/18

{
  "_links": {
    "self"  : { "href" : "/data/user?start=0&size=2" }
  },
  "_data": [
    {
      "_links": {
        "self"            : { "href" : "/data/user/1" },
        "shippingAddress" : { "href" : "/data/address/29"  }
      },
      "id"        : 1,
      "firstName" : "Nicolas",
      "lastName"  : "CHALET"
    }, {
      "_links": {
        "self"            : { "href" : "/data/user/2" },
        "shippingAddress" : { "href" : "/data/address/35"  }
      },
      "id"        : 2,
      "firstName" : "Tony",
      "lastName"  : "STARK"
    }
  ]
}
            

Mais on a toujours un niveau d'indentation inutile

Les relations entre ressources

Web Linking (RFC 5988)


GET /data/user?start=0&size=2
Host : api.example.com

HTTP/1.1 206 Partial Content
Accept-Ranges: user
Content-range: user 0-2/18
Link: </data/user?start=0&size=2>; rel="self"

[
  {
    "_links": {
      "self"            : { "href" : "/data/user/1" },
      "shippingAddress" : { "href" : "/data/address/29"  }
    },
    "id"        : 1,
    "firstName" : "Nicolas",
    "lastName"  : "CHALET"
  }, {
    "_links": {
      "self"            : { "href" : "/data/user/2" },
      "shippingAddress" : { "href" : "/data/address/35"  }
    },
    "id"        : 2,
    "firstName" : "Tony",
    "lastName"  : "STARK"
  }
]
            

Les relations entre ressources

Pour l'utilisateur


GET /data/user/1
Host : api.example.com

HTTP/1.1 200 OK
Link: </data/user/1>; rel="self",
</data/address/29>; rel="shippingAddress"

{
  "id"        : 1,
  "firstName" : "Nicolas",
  "lastName"  : "CHALET"
}
            

Les relations entre ressources

On peut même retirer le self avec
le header Content-Location


GET /data/user/1
Host : api.example.com

HTTP/1.1 200 OK
Content-Location: /data/user/1
Link: </data/address/29>; rel="shippingAddress"

{
  "id"        : 1,
  "firstName" : "Nicolas",
  "lastName"  : "CHALET"
}
              

Les relations entre ressources

Maintenant la méthode HEAD prends tout son sens, on va pouvoir parcourir toute l'API sans requêter vraiment la ressource


HEAD /data
Host : api.example.com

HTTP/1.1 204 No Content
Link: </data/user>; rel="user",
</data/address>; rel="shippingAddress"
            

De plus, avec la méthode OPTIONS, on saura ce qu'on peut y faire

Versionning d'une API REST

Il y a 3 façons "communes", aucune n'est vraiment bonne, mais bon, faut bien en choisir une ...

  • Dans l'URI
  • Dans un custom header
  • Dans le header Accept

Versionning d'une API REST

Dans l'URI


GET /data/v1/user
Host : api.example.com

HTTP/1.1 200 OK
...
            

GET /data/v2/user
Host : api.example.com

HTTP/1.1 200 OK
...
            

Versionning d'une API REST

Dans l'URI

PROS

  • URI très "portables", un simple copier coller et on partage la bonne version
  • Le routing est plus simple si chaque version est concernée par une application à part entière

CONS

  • Plusieurs URIs pour une même ressource : pas REST
  • Sémantiquement incorrect

Versionning d'une API REST

Dans un custom header


GET /data/user
Host : api.example.com
api-version: 1

HTTP/1.1 200 OK
...
            

GET /data/user
Host : api.example.com
api-version: 2

HTTP/1.1 200 OK
...
            

Versionning d'une API REST

Dans un custom header

PROS

  • Cool URIs don't change
  • Sémantiquement 'presque' correct

CONS

  • Non portable d'un simple copier-coller
  • On se retrouve hors spec HTTP

Versionning d'une API REST

Dans le header Accept


GET /data/user
Host : api.example.com
Accept: application/com.myapp-v1+json

HTTP/1.1 200 OK
...
            

GET /data/user
Host : api.example.com
Accept: application/com.myapp-v2+json

HTTP/1.1 200 OK
...
            

Versionning d'une API REST

Dans le header Accept

PROS

  • Cool URIs don't change
  • Sémantiquement correct

CONS

  • Non portable d'un simple copier-coller

Versionning d'une API REST

Que faire si l'utilisateur ne fournit pas de version ?

  • Fournir la dernière version
  • Fournir la première version

Versionning d'une API REST

Mon choix personnel

Version dans le header Accept
Si pas de version demandée, fournir la première

Authentification

Les grands principes

  • Une API doit rester stateless : pas de session
  • Un token encrypté et indécryptible côté client
  • Le token encrypté côté serveur à l'aide d'un code secret
  • Une solution simple à implémenter, le token JSON : JWT

Authentification

Etape 1 : côté client, premier appel


POST /login
Host : api.example.com
Accept: application/com.myapp-v1+json

{
  "name"     : 'SupAdmin',
  "password" : 'SuPassword'
}
            

Authentification

Etape 2 : côté serveur, réponse


var API_SECRET = 'IKeepMySecretsLockedDown';

User.findByName({name : req.body.name}, function(user) {
  if (!user) {
    res.json({
      success : false,
      message : 'User not found'
    });
  } else if (user.password !== req.body.password) {
    res.json({
      success : false,
      message : 'Wrong password'
    });
  } else {
    var token = jwt.encode(req.body, API_SECRET);
    res.json({
      success : true,
      message : 'Authentificated !',
      token   : token
    });
  }
});
            

Authentification

Etape 3 : côté client, réception, stockage du token


HTTP/1.1 200 OK

{
  "success" : true,
  "message" : "Authentificated",
  "token"   : "BLAHblahBLAHblahTOKEN"
}
            

Plusieurs choix pour le stocker

  • En cookie
  • En local storage
  • ... juste en mémoire ? ...

Authentification

Etape 4 : côté client, requête sur une resource protégée

Envoie du token à chaque requête mais encore plusieurs choix ...

  • En paramètre de requète (?token=BLAHblah...)
  • En header de requête (Authorization/X-quelquechose)

Authentification

Etape 4 : côté client, requête sur une resource protégée


GET /data/over-protected-data
Host : api.example.com
Accept: application/com.myapp-v1+json
Authorization: JWT BLAHblahBLAHblahTOKEN

HTTP/1.1 200 OK

{
  "wow"  : "suchSecret",
  "much" : "data"
}
            

Authentification

Etape 5 : côté serveur, contrôle des autorisations


var API_SECRET = 'IKeepMySecretsLockedDown';

function (req, res) {
  var authToken = req.headers.Authorization,
    credentials = jwt.decode(authToken, API_SECRET);

  User.validate(credentials).then(function(error) {
    if (error) {
      res.sendStatus(401);
    } else {
      res.json(OverProtectedData.get());
    }
  });
}
            

Des question ? :)

Reférences

Retrouver les slides : https://grobim.github.io/how-to-rest