On Github Grobim / how-to-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.
Un type d'architecture d'API
Manipule des ressources/collections
Orienté services
Contient plusieurs niveaux de définition
Un type d'architecture d'API : - Pas une norme - Un design d'API - Pas obligatoire Manipule des objects, collections et récupère des données. Plusieurs niveau de définition : - Je ne vais pas les faire 1 à 1 - A ne pas mettre en opposition à SOAP : SOAP = rest de niveau 0Manipule des ressources/collections
Un type d'architecture d'API
Orienté services
Contient plusieurs niveaux de définition
Un type d'architecture d'API : - Pas une norme - Un design d'API - Pas obligatoire Manipule des objects, collections et récupère des données. Plusieurs niveau de définition : - Je ne vais pas les faire 1 à 1 - A ne pas mettre en opposition à SOAP : SOAP = rest de niveau 0Simple : 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
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
Pas REST : Version dans l'URI1 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
Les collection, au pluriel ou non ?
Peu importe, mais rester consistant !
/user/user/1OU
/users/users/1Format/tri/filtre/ordre de données en paramètre de requête où dans 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
Format/tri/filtre/ordre de données en paramètre de requête où dans 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
Format/tri/filtre/ordre de données en paramètre de requête où dans le header
GET /data/some-collection/0/10/ Host : api.example.com Accept : application/json
PAS REST : So damn many URIs
Format/tri/filtre/ordre de données en paramètre de requête où dans le header
GET /data/some-collection?start=0&size=10 Host : api.example.com Accept : application/json
HTTP/1.1 206 Partial Content
REST
BONUS : Retour 206 et non 200 : Partial contentPas 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
Une URI est case-insensitive sur certains sites et d'autres non, préférer être consistant - Wikipedia l'est sur le premier caractère - Stackoverflow est case insensitiveJamais 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 classiques (CRUD basique)
Mais aussi
GET /data/user Host : api.example.comRenvoie la collection d'utilisateurs (ou la collection de leur URIs)
GET /data/user/1 Host : api.example.comRenvoie l'utilisateur avec id 1
/userVide la collection d'utilisateur
/user/1Supprime l'utilisateur ayant pour id 1
/userÉcrase toute la collection user par la collection envoyée en corps de requête
/user/1Si l'utilisateur n'existe pas, la crée, sinon l'écrase
Attention à bien envoyer la ressource entièreLe PUT ne correspond pas à une mise à jour partielle
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
Si l'utilisateur avait d'autres champs comme des ROLES, on les supprime1er comportement : Provoque le changement d'une ressource
/user/1/pictureSi l'utilisateur a une photo, la crée. Sinon autre chose (par ex: exception/remplace/...)
/user/1/friend/5Ajoute l'utilisateur 5 à sa friend list, sinon le retire
2nd comportement : ajoute un élément à une collection, sans définir son id
/user/1/center-of-interestAjoute un centre d'intérêt à la liste.
Si on appelle 3 fois, 3 ajouts de la ressource passée en body, avec 3 ids différentsMon 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 /data/user/1 Host : api.example.com HTTP/1.1 204 No content Allow : GET,PUT,DELETE,OPTIONS,PATCH,HEAD
Des relations ? Mais pourquoi ?
Le niveau 3 d'une architecture REST indique qu'une telle api doit être self-descritive
Le mot clé : HATEOASHypermedia as the Engine of Application State
Que nous dit HATEOAS ?
Je ne dois jamais avoir à deviner :
Simple avec XML
GET /data/user/1 Host : api.example.com HTTP/1.1 200 OK <!--?xml version="1.0" encoding="utf-8" ?--> <user> <id>1</id> <firstname>CHALET</firstname> <lastname>Nicolas</lastname> <link rel="self" href="/data/user/1" /> <link rel="shipping-adress" href="/data/adress/29" /> </user>
XML c'est bien, si on aime la redondance ...
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 ...
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" } }
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" } ] }
É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
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" } ]
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" }
On peut même retirer le self avecle 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" }
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
Il y a 3 façons "communes", aucune n'est vraiment bonne, mais bon, faut bien en choisir une ...
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 ...
Dans l'URI
PROS
CONS
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 ...
Dans un custom header
PROS
CONS
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 ...
Dans le header Accept
PROS
CONS
Que faire si l'utilisateur ne fournit pas de version ?
Mon choix personnel
Version dans le header AcceptSi pas de version demandée, fournir la première
Les grands principes
Etape 1 : côté client, premier appel
POST /login Host : api.example.com Accept: application/com.myapp-v1+json { "name" : 'SupAdmin', "password" : 'SuPassword' }
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 }); } });
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
Etape 4 : côté client, requête sur une resource protégée
Envoie du token à chaque requête mais encore plusieurs choix ...
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" }
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()); } }); }
Retrouver les slides : https://grobim.github.io/how-to-rest