Comment créer une bonne API Web - Partie 2

Posté le Monday, 02 November 2020 in Programmation

Bonjour,

Cet article fait partie d'un ensemble:

Qu'est qu'une API REST

REST est une norme dont voici les grandes lignes. Il n'est pas dans mon but de faire un cours sur REST (et il y en déjà de très bons sur internet). Je souhaiterais surtout parler des points qui me semblent importants. N'hésitez pas à venir me dire si vous pensez qu'il manque des points importants. Je viendrai alors compléter mon article.

Le principe de REST est de séparer l'API en différentes ressources logiques qui peuvent être manipulées par les verbes HTTP (GET, POST, ...). La réponse de son côté se base également sur les codes http.

Le contenu de la requête et de la réponse peut être dans le format objet de votre choix (json, yaml, xml, ...). On utilise alors le header Content-Type pour définir le contenu. Une API peut d'ailleurs gérer plusieurs formats et répondre au client le bon format en fonction de la demande du client.

Basé sur des ressources

Quand on parle de REST, il faut penser ressources. Mais qu'est qu'une ressource ?

Une ressource c'est un concept abstrait de REST. Une ressource c'est ce qui va être représenté par les données JSON que vous allez manipuler. Pour représenter une ressource on n'utilise pas un verbe, ou une action mais un nom. L'utilisation du nom est important car le verbe est representé par la méthode HTTP.

Une ressource peut:

  • être un singleton
GET /api/hosts/:id

{
  "id": 1,
  ...
}
  • être une collection
GET /api/hosts

[
  {
    "id": 1,
    ...
  },
  {
    "id": 2,
    ...
  },
]
  • être une sous-ressource
GET /api/hosts/:id/backups

[
  {
    "id": 1,
    ...
  },
  {
    "id": 2,
    ...
  },
]

La ressource est généralement nommée au pluriel et pour des raisons de consistance, doit toujours être nommée de la même manière même si le but est de récupérer un unique élément.

Utilise les verbes HTTP

Donc une API REST ce sont des ressources qui peuvent être manipulées par les verbes HTTP. Du coup l'action n'est pas portée par le nom de la ressource mais par la méthode. Quels sont donc ces méthodes ?

GET

GET est utilisé pour récupérer une ressource. En aucun cas GET doit être utilisé pour effectuer une modification (ce serait comme effectuer des modifications dans le getter d'une classe1). GET ne doit pas modifier l'état de la ressource et doit être idempotent2. On peut donc appeler autant de fois que l'on souhaite la méthode GET, le résultat doit toujours être le même tant qu'une autre méthode (POST, PUT, ...) ne vient pas modifier l'état de la ressource.

Si la ressource peut être produite par le serveur, le code HTTP de réponse doit être 200 OK et contenir le contenu de la ressource de la réponse dans le corps.

Le client peut alors cacher la requête s'il le souhaite, et il est possible d'utiliser les headers HTTP standards pour lui indiquer combien de temps (cf header Cache-Control). Attention ce header peut être ignoré ou peut être utilisé par le client afin d'agir sur son rate limit. A aucun moment il ne faut faire confiance au client et il faut préférer cacher les requêtes en interne (via un mêmcached) et définir une limite d'appels plutôt que de solliciter son back si ce dernier ne peut pas tenir la charge.

ne doit pas avoir d'impact/d'effet de bord dans l'application.

HEAD

La méthode HEAD a le même effet que GET mais ne retourne pas de contenu. Le header peut-être utilisé pour vérifier l'état de la ressource (et voir si le cache doit être invalidé).

La méthode est considéré comme safe et idempotent.

OPTION

La méthode OPTION est utilisée pour vérifier les capacités de la méthode HTTP. Il est notament utilisé par les navigateurs pour vérifier les entêtes CORS d'une API (en mode preflight) avant d'effectuer la véritable requête.

Pour interroger le serveur de facon générale * peut être utilisé à la place de l'URI. Ce seront donc les capacités globales du serveur qui seront retournées

La méthode est considérée comme safe et idempotent.

POST

POST doit être utilisé pour créer une nouvelle ressource associée à la requête demandée. POST est utilisé pour ajouter un nouvel élément à une collection.

Lorsque la ressource a été créé, la réponse doit être 201 Created et le corps du message ne doit pas contenir d'informations. A la place on laisse le client récuperer la ressource en utilisant la méthode GET ci-dessus. Pour cela, le lien d'accès à la ressource doit être positionné dans le header Location de la réponse HTTP.

Si toutefois la création de la ressource ne peut pas être liée à une ressource identifiable par un URI, la méthode peut alors retourner 204 No Content ou 200 OK suivant si la réponse possède un contenu ou non.

La réponse ne peut pas être cachée (sauf indication contraire par header HTTP).

Les requêtes POST ne sont pas idempotent et peuvent changer l'état de la ressource. Dans ce cas le client devra d'ailleurs invalider son cache. Deux appels identiques POST peuvent génerer des résultats différents.

PUT

La différence la plus importante entre POST et PUT se situe sur l'URI. La méthode PUT est utilisée sur les URI avec un identifiant (et donc unique), alors que la méthode POST est utilisée sur URI représentant une collection.

PUT doit alors être utilisé pour mettre à jour une ressource existante (voir en créer une nouvelle si elle n'existe pas). La ressource intégrale doit être passée. Pour une mise à jour partielle, il faut voir la méthode PATCH.

S'il y a création d'une ressource, comme pour POST, l'API doit retourner un 201 Created. Dans le cas d'une mise à jour la méthode retourne alors 204 No Content ou 200 OK suivant que la réponse possède un contenu ou non.

Les requêtes PUT mettant à jour la ressource, le client devra invalider son cache sur la base de l'URI. La méthode est considérée comme idempotent car c'est la ressource entière qui est remplacée.

PATCH

PATCH est utilisé pour mettre à jour une ressource partiellement. La mise à jour doit être également atomique[^].

Dans la RFC RFC 5789 de la méthode PATCH, il y est écrit que le contenu doit contenir la description des modifications :

PATCH /file.txt HTTP/1.1
Host: www.example.com
Content-Type: application/example
If-Match: "e0023aa4e"
Content-Length: 100

[description of changes]

Utiliser une implémentation free-style de la description des changements risque d'être source d'erreurs pour le client comme pour le serveur. En effet, le client pourrait s'attendre dans certains cas à des ajouts dans un tableau, ou au remplacement du tableau.

Il existe différentes RFC décrivant la bonne manière de faire pour PATCHer un document JSON. Il vaut mieux alors se baser dessus pour développer nos API. Cela permet d'être clair sur le fonctionnement de la méthode.

On peut alors se baser sur les RFC 6902 et RFC 7396 pour décrire le contenu de cette description des modifications.

Pour les API basées sur des XML, référez-vous aux RFC équivalents décrivant les méthodes de PATCH pour le XML.

Si on se base sur la RFC 6902 on peut alors reprendre l'exemple qui décrit un changement comme étant une liste de différences à appliquer (noté le Content-Type):

PATCH /my/data HTTP/1.1
Host: example.org
Content-Length: 326
Content-Type: application/json-patch+json
If-Match: "abc123"

[
  { "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" }
]

Le format à l'avantage d'être complet et standard.

Pour gérer le format dans l'application, on peut retrouver des librairies capables de lire et de générer ce genre de différenciel (par exemple fast-json-patch).

Avec la RFC 7396, on peut adapter le Content-Type et avoir un format de patch plus lisible :

PATCH /target HTTP/1.1
Host: example.org
Content-Type: application/merge-patch+json

{
  "a":"z",
  "c": {
    "f": null
  }
}

Par contre ce format a ses limitations :

  • il ne peut que mettre à jour des éléments (pas de suppression)
  • dans le cadre de valeurs comme des tableaux, c'est le contenu intégral qui est remplacé. Pas d'ajout ou de merge.

Il est alors très important de définir dans sa documentation la liste des Content-Type accepté par sont API

DELETE

La méthode DELETE porte bien son nom car elle sert à supprimer une ressource identifiée par son URI.

Si la suppression a été effectuée, l'API retourne un 200 OK si le contenu de la réponse inclut un status de la suppression ou un 204 No Content sinon. Si la ressource n'existe pas, l'API retourne alors un 404 Not Found.

L'API peut retourner un 202 Accepted si la demande a été prise en compte mais sera traitée ultérieurement.

Plusieurs appels à la méthode DELETE sur une même URI ne doivent pas avoir d'effets de bord. La seule différence est sur la réponse qui passe à 404 Not Found une fois que la ressource a été supprimée.

LINK et UNLINK

Les méthodes LINK et UNLINK servent à lier/délier des ressources entre elles.

Je ne détaillerai pas leurs utilisations car elles sont peut utilisées. Vous pouvez en lire plus sur le site de l'IETF.

Respecter les codes retour HTTP

La norme REST se base sur les code de retour HTTP. Par exemple un code HTTP 404 Not Found signifie que la ressource ne peut pas être trouvée. Il est important pour les applications clientes de respecter ses codes HTTP.

Commençons par un petit rappel sur les codes de retour.

Ces derniers sont classés en 5 catégories:

  • 1xx: Informationnel. Ces codes HTTP sont utilisés pour communiquer des informations sur l'état du transfert à un niveau protocolaire (donc pas au niveau applicatif).
  • 2xx: Succès. Ces codes HTTP sont utilisés pour signifier le succès de la requête.
  • 3xx: Redirection. Le client doit prendre des actions supplémentaires afin de terminer la requête.
  • 4xx: Erreur dûe à la requête du client. C'est de la faute du client si la réponse ne peut pas être fournie. Il doit la reformuler (si la requête est mauvaise) ou attendre dans le cas d'un rate limit.
  • 5xx: Erreur dûe au serveur. Le serveur n'a pas réussi à répondre au client, et c'est de son entière responsabilité.

Il est important de bien choisir son code de retour pour que le client puisse bien comprendre quelles actions il peut entreprendre pour avoir sa réponse. Envoyer un code 5xx alors que la requête ne contient pas tout les champs obligatoires est le meilleur moyen pour remplir votre boîte de support.

Inversement renvoyer un code 4xx pour une erreur côté serveur, risque de perturber l'utilisateur qui ne comprendra pas pourquoi l'API ne fonctionne pas comme attendu.

Passons en revue quelques exemples de codes utilisables (j'ai fait uniquement une sélection de codes qui peuvent avoir un intérêt pour une API REST) :

2xx

200 OK

Parfait, pas de problème, c'est le succès complet.

Contrairement à la réponse 204 ci-dessous, il est nécessaire de fournir un corps au message (sauf pour le header HEAD qui ne renvoit jamais de corps de message).

201 Created

Une nouvelle ressource a été créé avec succès et ajoutée à la collection. Le corps du message doit être vide. Le client peut invalider le cache de la collection.

Ce code de retour doit être utilisé une fois que la ressource a été créé par le serveur et l'emplacement de celle-ci doit être retourné dans le header HTTP Location.

Si la ressource ne peut être créé tout de suite, alors voir le header suivant.

202 Accepted

La requête a bien été reçue et sera traitée ultérieurement (via un batch par exemple). La création peut se produire, ou une erreur peut arriver ultérieurement.

Dans le corps du message le serveur doit retourner au client le maximum d'informations qui permettent à ce dernier de savoir quand sa demande sera traitée. Par exemple on peut mettre dans le corps du message :

  • Le statut de la demande
  • La date d'exécution du process
  • L'URI pour accéder à la demande et obtenir son statut
  • Une estimation du temps de traitement.
204 No Content

Le serveur a bien traité la demande mais aucune information ne sera retournée dans le body.

Ce code HTTP est généralement utilisé avec les méthodes POST, PUT, DELETE quand le serveur n'a aucune information (au dela du header Location) à retourner au client. Il peut également être utilisé avec les méthodes GET pour indiquer que la ressource existe (contrairement au 404) mais aucune représentation de la ressource n'existe.

La réponse ne doit jamais contenir de contenu.

Sauf si un header (tel que Location) l'indique, le client n'a pas besoin de modifier son cache.

206 Partial Content

Utilisé avec le header Range dans la requête pour n'envoyer qu'un résultat partiel.

Cette réponse peut-être utilisée dans les requêtes de pagination en conjonction uniquement avec le header Range.

3xx

301 Moved Permanently

La ressource a été déplacée définitivement à un autre endroit. Le header Location contient le nouvel emplacement. Le résultat de la requête peut être caché.

On peut utiliser ce retour pour signifier un changement de l'API suffisamment important pour que cette version ne soit plus à jour, mais il est préférable de versionner l'API, plutôt que d'utiliser les redirections.

302 Found

La ressource a été déplacée temporairement à un autre endroit. Le header Location contient l'emplacement. La requête ne peut être cachée que si les headers de caches sont présents.

Le client doit refaire exactement la même requête mais sur le nouvel emplacement défini dans le header Location. Cette règle n'est pas respectée par les navigateurs qui remplacent la méthode par GET et c'est pour ça que les codes 303 et 307 ont été créés.

303 See Other

Ce code de retour, généralement utilisé avec des PUT ou POST indique que le serveur a terminé son travail mais préfère retourner au client une URI où trouver la ressource ou un message de status.

Le client peut alors décider ou non d'aller voir à l'adresse donnée via la méthode GET uniquement.

La réponse ne peut pas être modifiée.

304 Not Modified

Utilisé généralement avec une requête GET ou HEAD pour indiquer que la ressource n'a pas été modifiée par rapport aux informations spécifiées dans le header (If-None-Match et If-Modified-Since).

Cette réponse est utilisée pour économiser du temps de traitement et de la bande passsante côté serveur et côté client, vu que le client peut reprendre la réponse qui se présente dans son cache.

Généralement, une réponse précédente a été déjà envoyée au client avec les headers suivants: Cache-Control, Content-Location, Date, ETag, Expires, et Vary.

307 Temporary Redirect

Le serveur indique que la ressource doit être récupérée à un autre endroit en ré-envoyant la requête telle quelle à l'URI spécifiée dans le header Location. Toute future requête doit continuer à être envoyée au endpoint actuel.

La différence entre une réponse 302 et 307 réside dans le fait que la requête ne doit pas être modifiée lors de l'appel à l'URI de la réponse retournée par la 307. Avec une 302, certains clients changent incorectement cette valeur en GET.

4xx

400 Bad Request

Le serveur ne comprend pas la requête à cause de sa syntaxe. Le client ne doit pas répéter sa requête sans modification.

Ce message peut être utilisé quand aucun autre message de type 4xx n'est approprié.

401 Unauthorized

La ressource nécessite une authentification. Le client peut répéter sa requête s'il possède le bon header d'Authorization.

Si le header d'authentification est présent mais que le serveur retourne une 401, cela signifie que l'authentification a été refusée avec ces paramètres.

403 Forbidden

L'authentification du client est bien reconnue par le serveur, mais le client ne possède pas les autorisations nécessaires pour accéder à la ressource.

Le client ne doit pas répéter la demande.

404 Not Found

La ressource n'existe pas sur le serveur, pour le moment. Le client peut potentiellement refaire la requête plus tard.

Ce status est également utilisé lorsque le serveur ne veut pas révéler pourquoi la requête a été refusée.

409 Conflict

La requête est bien formée (contrairement à 400) mais elle ne peut pas être complétée dû à l'état de la ressource.

410 Gone

La ressource n'existe pas sur le serveur, mais le serveur sait (par un moyen interne) que la ressource a existé par le passé et qu'il n'est pas dans la capacité de fournir une URI où trouver la nouvelle version.

416 Requested Range Not Satisfiable

L'intervalle utilisé dans le header Range ne peut pas être satisfait.

429 Too Many Requests

Utilisé pour prévenir le client qu'il a atteint ses limites (rate limit)

5xx

500 Internal Server Error

Le serveur clame haut et fort "Une erreur inattendue s’est produite".

L'erreur 500 n'est jamais de la faute du client. Le client peut donc retenter sa requête plus tard.

501 Not Implemented

Pratique pour les API en cours de développement, l'API existe mais n'a pas encore été implémentée.

503 Service Unavailable

Le serveur n'est pas encore prêt à répondre à la requête (par exemple il démarre ou il s'arrête).

Utilise la notion de lien hypermedia (HATEOAS)

HATEOAS, c'est pour Hypermedia As The Engine Of Application State.

Peu d'API sont REST HATEOAS. Le but est de REST HATEOAS est de lier les réponses REST les unes aux autres tout comme le Web lie les pages les unes aux autres. Le principe est donc d'ajouter la notion de lien hypermédia dans une API.

Dans la théorie, un client pourra donc s'adapter aux changement de l'API grâce à ces liens.

Pour cela vous pouvez pouvez dans cadre du JSON vous baser sur HAL ou sur JSON:API.

JSON:API

Voici un exemple utilisant la syntaxe JSON:API.

{
  "links": {
    "self": "/api/hosts",
    "next": "/api/hosts?page[offset]=2",
    "last": "/api/hosts?page[offset]=10"
  },
  "data": [
    {
      "type": "hosts",
      "id": "pc-ulrich",
      "attributes": {
        "dhcp": [
          {
            "address": "192.168.101",
            "start": 0,
            "end": 50
          }
        ]
      },
      "relationships": {
        "backups": {
          "links": {
            "self": "/api/hosts/pc-ulrich/relationships/backups",
            "related": "/api/hosts/pc-ulrich/backups"
          },
          "data": [
            { "type": "backups", "id": 0 },
            { "type": "backups", "id": 1 },
            { "type": "backups", "id": 2 },
            { "type": "backups", "id": 3 }
          ]
        }
      },
      "links": {
        "self": "/hosts/pc-ulrich"
      }
    }
  ]
}

Comme vous pouvez voir, l'idée est de pouvoir à la lecture de la réponse comprendre automatiquement les relations entre les différents composants de l'API.

Faut-il faire une API HATEAOS

Le but d'une telle API est de permettre au client de la découvrir automatiquement. Il serait envisageable alors de créer un client capable, sur la base de l'API et des types de données qui y sont décrits, de pouvoir génerer une IHM directement.

Même si le principe d'une API HATEAOS est louable, le temps d'écriture d'une telle API est très consommatrice de temps. De plus, généralement, un client est généralement créé pour des utilisateurs et non par une API.

Une bonne documentation peut amplement suffir pour développer une application, et comprendre les interactions entre les différentes APIs ?

Générér sa documentation avec Swagger

Il existe de très bon outils pour écrire sa documentation.

L'un d'entre eux s'appelle Swagger basé sur la spécification OpenAPI. Il y a alors plusieurs manières de faire pour générer sa documention qui dépendent également des capacités du language.

  • Sur un language typé, on peut générer sa documentation à partir du code. Il est alors possible via des annotations ou des commentaires du code de définir les informations.

  • Il est également possible d'écrire le Swagger directement, et de générer le code après coup. Cela à l'avantage de pouvoir travailler l'API avant sans écrire une ligne de code.

L'important sur ces deux méthodes c'est que le code de l'application et le code du Swagger soient synchronisé.

  • Si l'API a déjà été développée, il est beaucoup plus dur de générer une API par dessus. Surtout si le language est non typé, et que toute la description doit être synchronisée à la main avec les objets de l'application, il y aura alors un risque que la documentation ne soit pas à jour.

Swagger WoodstockBackup

Cacher ses requêtes

Il est important côté client de cacher les requêtes afin de rester en dessous du Rate Limit, et aussi d'économiser les ressources de bande passante de l'application.

Comme dit plus haut par contre, il ne faut pas que le serveur prenne comme hypothèse que le client va cacher les requêtes au risque de se faire saturer par des requêtes. Côté serveur, il faut définir des limites d'accès de l'API, et surtout définir également les headers de Cache qui vont bien afin d'avertir le client de la durée pendant laquelle il doit garder la requête.

En effet seul le serveur connaît fonctionnellement combien de temps une donnée peut être gardée.

Quelques références

  • https://zestedesavoir.com/tutoriels/299/la-theorie-rest-restful-et-hateoas/
  • https://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api
  • https://stackoverflow.blog/2020/03/02/best-practices-for-rest-api-design/
  • https://restfulapi.net/http-methods/
  • https://williamdurand.fr/2014/02/14/please-do-not-patch-like-an-idiot/

  1. Ce que j'ai déjà vu faire. Pour préciser les choses, un getter doit être safe. Cela signifie que l'appel 

  2. En mathématiques et en informatique, l'idempotence signifie qu'une opération a le même effet qu'on l'applique une ou plusieurs fois.: Dans notre cas cela signifie qu'elles n'ont pas d'effets de bord. 

  3. Ce qui veux dire que tout est fait ou rien.