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
Manipule des ressources/collections
Un type d'architecture d'API
Orienté services
Contient plusieurs niveaux de définition
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
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
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
Les collection, au pluriel ou non ?
Peu importe, mais rester consistant !
/userOU
/usersFormat/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
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
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 classiques (CRUD basique)
Mais aussi
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
/user
Vide la collection d'utilisateur
/user/1
Supprime l'utilisateur ayant pour id 1
/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 /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
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
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.
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 /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é : HATEOAS
Hypermedia 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
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 ...
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 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"
}
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 Accept
Si 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