Les bonnes pratiques pour construire un API REST
Aujourd’hui, je vous propose de passer en revue tout ce que l’on attend d’une bonne API REST.
Si je devais résumer cet article en une phrase : Tout est une histoire de convention.
Rappel sur les API REST
Une API est un contrat d’interface à destination d’utilisateurs externes. Une API REST est désignée selon les principes du Resoure Oriented Design et consiste en trois concepts clés :
Resource: Une ressource est une donnée, par exemple un utilisateur.
Collection: Une collection est un groupe (liste) de ressources. Par exemple, une liste d’utilisateurs.
URL: L’URL Identifie la localisation d’une ressource ou d’une collection. Par exemple : /user ou /users
Toute la problématique d’une API efficace, consiste à mettre à disposition de façon claire et pratique les ressources et les collections.
1. Le kebab-case pour les URLs
Votre endpoint doit retourner la liste des tâches à faire dans la journée, attention au séparateur de mot qui sera –.
Bien :
/some-tasks
Pas Bien :
/someTasks
/some_tasks
En bonus, comme notre endpoint retourne une collection (liste de tâches), notez bien que tasks est au pluriel.
2. Le camelCase pour les paramètres
Vous souhaitez récupérer une tâche (ressource) précise d’une collection à l’aide de l’identifiant unique de la tâche, le paramètre sera écrit en camelCase.
Bien :
/tasks/{taskId}
Pas Bien :
/tasks/{TaskId}
Option de repli :
/tasks/{order_id}
En général, on attend d’une API que les paramètres des endpoints soient en camelCase. Maintenant, dans le cadre d’une API privée, il est tout à fait acceptable de remplacer le camelCase par une autre convention, comme le snake_case. Mettons, que l’emploi du Python soit majoritaire en sein de votre entreprise, cela peut s’avérer très pratique de manipuler des paramètres nativement en snake_case plutôt qu’en camelCase.
3. Utiliser le singulier pour une ressource et le pluriel pour une collection
Exemple d’une collection :
/tasks
Exemple d’une ressource :
/task
4. Accéder à une ressource d'une collection
La règle est simple, votre URL doit commencer avec la collection, suivi de l’identifieur de la ressource.
Exemple :
/tasks/{taskId}
Si la ressource possède plusieurs propriétés, la propriété que vous voulez récupérer sera précisée après l’identifieur de la ressource :
Ici, la date de la tâche taskId appartenant elle-même à la collection tasks sera récupérée de la façon suivante :
/tasks/{taskId}/date
5. On ne mixe pas les concepts afin de garder une API la plus consistante possible
ça on oublie :
/tasks/{taskId}/categories/{categoryId}
Là, c’est mieux :
/tasks/{taskId}
/categories/{categoryId}
6. Pas besoin de spécifier le verbe HTTP dans le nom de la ressource
Cela ne sert à rien de faire apparaître le verbe HTTP dans l’URL pour exprimer notre intention. À la place, utiliser les verbes HTTP pour décrire vos opérations.
Bien :
PUT /tasks/{taskId}
Pas Bien :
POST /updatetask/{userId}
GET /gettasks
Par contre, faire apparaître le verbe HTTP dans le nom des fonctions appelées par un framework tel que FastAPI ou Flask est une bonne idée.
Exemple :
from fastapi import FastAPI, Query
app = FastAPI()
@app.post("/tasks/")
def post_task():
pass
@app.get("/tasks/")
def get_tasks():
pass
@app.put("/tasks/{taskId}/")
def put_task(taskId: int):
pass
@app.delete("/tasks/{taskId}/")
def delete_task(taskId: int):
pass
@app.delete("/tasks/")
def delete_tasks():
pass
Pour le nommage de la fonction, il est aussi tout à fait acceptable de remplacer le verbe HTTP par son équivalent CRUD (Create, Read, Update, Delete).
Dans notre cas, cela donnerait :
from fastapi import FastAPI, Query
app = FastAPI()
@app.post("/tasks/")
def create_task():
pass
@app.get("/tasks/")
def read_tasks():
pass
@app.put("/tasks/{taskId}/")
def update_task(taskId: int):
pass
@app.delete("/tasks/{taskId}/")
def delete_task(taskId: int):
pass
Rappel : Avec FastAPI, le nom de la méthode est réutilisé dans la documentation de l’api, soyons donc explicite sur le nom de celle-ci !
J’aurais pu, par exemple, rendre la fonction de suppression de l’ensemble des tâches nettement plus explicite en la renommant de delete_tasks à delete_all_tasks.
7. Utiliser des verbes pour les URL qui ne pointent pas vers une ressource
Reprenons notre liste de tâches. Nous souhaitons refaire une tâche. Comme il s’agit d’une opération, notre endpoint ne retournera rien.
Dans ce cas, nous pouvons utiliser un verbe d’action explicite qui décrira précisément l’opération qui sera effectuée.
POST /tasks/1234/redo
8. Utiliser la convention camelCase pour nommer les propriétés d'une réponse JSON
Si vous construisez des endpoints dans lesquels le corps (body) de la requête ou la réponse est au format JSON, privilégié le camelCase. Comme pour les identifieurs, le snake_case est une option possible à étudier au cas par cas.
PS 1 : On ne mixe pas les deux conventions !
PS 2 : On est consistant et on utilise la même convention pour les identifieurs et les propriétés !
En convention camelCase :
{
userName: "pythoniste",
userId: "1"
}
En snake_case :
{
user_name: "pythoniste",
user_id: ""
}
9. Monitorer votre API
Une API HTTP RESTful doit implémenter a minima les endpoints /health, /version et /metrics.
/health # Retourne un status 200 OK
/version # Retourne le numéro de version de l'API
/metrics # Retourne diverse métriques sur l'API
En optionnel, mais fortement recommandé, on rajoutera aussi /debug et /status.
10. Ne pas exposer pas le nom de vos tables SQL comme nom de ressource
En effet, réutiliser le nom de vos tables expose votre architecture sous-jacente, ce qui peut donner des informations clés à un hacker pour trouver une faille via votre API.
11. Utiliser des outils pour designer votre API
Outre les points exposés dans cet article, des outils dédiés existent pour vous aider à concevoir une API la plus propre possible.
On retiendra notamment API Blueprint et OpenAPI (Swagger).
Ces mêmes outils sont souvent nativement intégrés dans les frameworks modernes utilisés pour concevoir des API. Si ce n’est pas le cas avec le Framework que vous utilisez, trouvez un plugin pour ajouter cette fonctionnalité.
Avoir une documentation la plus détaillée possible améliorera grandement l’expérience utilisateur.
Soyez vigilant à décrire chaque endpoint, chaque paramètre, et à rendre explicite les status de réponses pour que le code lui-même, quand on le lit, soit une documentation. Choisissez judicieusement vos status codes. Si aucune réponse ne doit être retournée, privilégiez un code 204 ou 201 en fonction des cas.
Exemple complet :
from fastapi import FastAPI, Query
from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT
app = FastAPI()
@app.post(
"/tasks/",
status_code=HTTP_201_CREATED,
description="Créer une tâche et l'ajouter à la collection de tâches.",)
def post_tasks(
taskContent = Query(..., description="Contenu de la tâche qui va être créée."),
):
pass
@app.get(
"/tasks/",
status_code=HTTP_200_OK,
description="Récupèrer l'ensemble de la collection (liste) de tâches.",)
def get_tasks():
pass
@app.put(
"/tasks/{taskId}/",
status_code=HTTP_204_NO_CONTENT,
description="Mettre à jour le contenu d'une tâche.",)
def put_task(
taskId: int = Query(..., description="Identifiant unique de tâche à mettre à jour."),
taskContent = Query(..., description="Contenu de la tâche à mettre à jour."),
) -> None:
pass
@app.delete(
"/tasks/{taskId}/",
status_code=HTTP_204_NO_CONTENT,
description="Supprimer une tâche.",)
def delete_task(
taskId: int= Query(..., description="Identifiant unique de la tâche à supprimer."),
) -> None:
pass
@app.delete("/tasks/",
"/tasks/",
status_code=HTTP_204_NO_CONTENT,
description="Supprimer toutes les tâches de la collection !!!",)
def delete_tasks() -> None:
pass
En bonus, la documentation exposée sur l’endpoint /docs vous permettra aussi de tester votre API en live ! Ça n’a pas de prix…
12. Utiliser un numéro de version Ordinal
Toujours avoir un numéro de version pour votre API et l’incrémenter à chaque fois que nécessaire. Le numéro de version sera toujours quelque chose du style : v1, v2, etc.
Exemple :
http://api.pythoniste.fr/v1/tasks
Gérer une version d’API est obligatoire, car pour les entités externes qui utilisent votre API, changer un endpoint peut casser leur fonctionnalité.
13. Inclure le nombre total de ressources dans une réponse
Si une API retourne une liste d’objets, inclure le nombre total d’objets en tant que propriété dans la réponse simplifiera grandement la vie des utilisateurs.
Bien :
{
tasks: [
...
],
total: 123
}
Pas bien :
{
tasks: [
...
]
}
14. Accepter les paramètres limit et offset
Toujours accepter les paramètres limit et offset pour les opérations de type GET
Par exemple :
GET /tasks?offset=5&limit=5
Ce qui se traduit avec FastAPI de la façon suivante :
from fastapi import FastAPI, Query
from starlette.status import HTTP_200_OK
app = FastAPI()
@app.post(@app.get(
"/tasks/",
status_code=HTTP_200_OK,
description="Récupèrer l'ensemble de la collection (liste) de tâches.",)
def get_tasks(
limit: int = Query(100, description="limite maximale du nombre d'objets retournés de la collection retourné dans la réponse. Par défaut, retourne toute au maximum 100 objets."),
offset: int = Query(0, description="offset des objets de la collections. Par défaut, retourne les éléments à partir de l'identifiant 0."),
):
pass
Grâce à limit et offset, le front pourra facilement paginer les résultats.
15. Filtrer une Query avec un paramètre fields
Garder toujours en mémoire la quantité de data que vous allez retourner.
Pourquoi ne pas proposer à l’utilisateur de l’API de choisir lui-même les champs qu’il souhaite récupérer ?
GET /users?fields=id,name Cela peut vraiment aider à réduire la taille des réponses dans certains cas.
16. Ne pas passer les Tokens d'Authentification dans les URLs
Un token d’authentification ne doit JAMAIS être exposé dans l’URL, JAMAIS.
Très très très mauvaise idée :
GET /users/123?token=654sfsdlfhsdfmmsdjflkjsdh
Si vous devez gérer un système d’authentification, le token devra se trouver dans le header :
Authorization: Bearer xxxxxx, Extra yyyyy
Ainsi, la durée de vie du token sera courte et moins exposée.
17. Toujours valider le Content-Type
Un serveur qui ne gère pas la validation du Content-Type pourrait s’exposer à des failles de sécurités majeures.
Par exemple, en acceptant le contenu : application/x-www-form-urlencoded, un hackeur pourrait créer un formulaire et générer des méthodes POST vers votre DB.
Donc, TOUJOURS valider le content-type et si vous souhaitez en avoir par défaut, privilégier application/json
18. Utiliser les bons verbes HTTP associés aux bonnes méthodes CRUD (Create Read Update Delete)
Chaque verbe HTTP sert un objectif précis qu’il faut toujours garder en mémoire quand on l’utilise :
GET : Récupérer une ressource / collection.
POST : Créer de nouvelles ressources / sous-ressource.
PUT : Mettre à jour des ressources existantes.
PATCH : Mettre à jour des ressources existantes. Seuls les champs fournis seront mis à jour.
DELETE : Supprimer des ressources existantes.
19. Utiliser les relations dans les URLs ciblé des ressources spécifiques
Quelques exemples parlants :
GET /users/2/address: Obtenir la liste de toutes les adresses de l'utilisateur 2.
GET /users/2/address/1: Obtenir le détail de l'adresse 1 de l'utilisateur 2.
DELETE /users/2/address/1: supprimer l'adresse 1 de l'utilisateur 2.
PUT /users/2/address/1: Mettre à jour l'adresse 1 de l'utilisateur 2. PUT n'est a utilisé que sur une URL pointant vers une ressource unique.
POST /users: Créer un nouvel utilisateur et retourner ses détails. POST ne s'utilise que sur une URL pointant vers une collection.
20. CORS
Supporter les entêtes (headers) de type CORS (Cross-Origin Resource Sharing) pour toutes les API publiques.
Supporter des entêtes de type CORS consiste à autoriser un utilisateur à consommer votre API à partir d’une origine validée, soit spécifique, soit de type “*”. Gérer des entêtes CORS permet de gérer l’autorisation de consommer l’API à travers un token OAuth valide.
Ne combinez pas les identifiants utilisateurs avec une validation d’origine.
21. La sécurité
C’est la base, tous les endpoints, ressources et services de votre API doivent être en HTTPS (TLS-encrypted).
Idem pour tous les callback d’URLs, endpoints de notification push et webhooks.
22. Les erreurs
De quel genre d’erreurs parlons-nous ? Eh bien, il s’agit des erreurs que pourrait rencontrer un client lors de l’utilisation de votre API.
Les erreurs, ou plus spécifiques les erreurs de service, arrivent quand un client envoi une requête invalide ou incorrecte à votre service et votre service rejette la requête avec un code spécifique.
Par exemple, on retournera des codes erreurs 4xx spécifiques en fonction du type d’erreur : identifiants invalide, paramètre invalide, id inconnu (quoique)…
Essayez, en fonction du framework que vous utilisez et si c’est possible, de retourner tous les problèmes de validation dans une seule réponse.
Par contre, MAÎTRISER les informations que vous donnez au client et n’en dite pas trop !
Vous gérez des identifiants et le hacker essaye de deviner si le compte existe. Retournez une erreur, un code d’erreur adapté avec un détail vague du style : identifiant OU mot de passe incorrect.
23. Quelques bonus avec FastAPI
Aller, avant de finir, petit tour d’horizon de ce qui top à faire avec FastAPI.
Typer les fonctions, et surtout celle de vos endpoints !
De manière générique, c’est une bonne pratique à faire partout dans l’ensemble de votre code base ! Mais je tenais à le rappeler quand même !
Le nom des fonctions génère des infos dans la doc de l'API !
Donc on oublie :
@app.get("/tasks/contents/")
def get():
pass
Et on essaye plutôt quelque chose comme cela :
@app.get("/tasks/contents/")
def get_all_content_from_tasks():
pass
Retourner vos modèles SQLAlchemy et laisser FastAPI filter les données avec des modèles pydantic
Si les ressources retournées par l’API proviennent d’une base de données, retourner directement le résultat de la query et laisser l’API faire le filtrage des champs à l’aide d’un modèle Pydantic. Pas besoin de le faire vous-même à la main, puisque FastAPI le fera pour vous.
from fastapi import FastAPI, Query
from starlette.status import HTTP_200_OK
from pydantic import BaseModel
from sqlalchemy import Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
app = FastAPI()
class TaskModel(Base)
__tablename__="users"
id: int = Column(Integer())
name: str = Column(String())
content: str = Column(String())
...
class TaskResponse(BaseModel):
id: int
name: str
@app.get(
"/tasks/taskId",
status_code=HTTP_200_OK,
response_model=TaskResponse,
description="Retourne les détails précis d'une tâche. Voir le modèle TaskResponse pour le détail du contenu.",)
def get_task(
taskId: int= Query(..., description="Identifiant unique de la tâche à supprimer."),
) -> TaskModel:
...
Dans l’exemple précédent, même si la donnée retournée possède les champs : id, name et content, seul id et name seront retournés dans la réponse grâce au modèle de réponse.
Décorréler la couche métier de la couche API
Si possible, privilégier de gérer vos exceptions HTTP au plus près de l’endpoint voir uniquement au niveau des endpoints comme celle-ci :
@app.get("/pouet/")
def endpoint_de_la_mort():
raise HTTPException()
Et pour votre code métier, gérer des exceptions classiques.
def code_metier_de_la_mort():
raise Exception("ca sent le sapin !")
@app.get("/pouet/")
def endpoint_de_la_mort():
try:
code_metier_de_la_mort()
except Exception:
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, details=str(e))
Chaque chose à une place dans un projet et plus vous êtes organisé, plus vous irez loin !
En vrac
- Utiliser Pydantic pour gérer les settings de l’API ;
- Créer dans vos projets des dossiers séparés pour : models (sqlalchemy) / enums (python enums) / schemas & responses (pydantic) / core (settings pydantics) / endpoints (là où sera la définition de chaque endpoint / …
Le mot de la fin
Voilà, je pense qu’on a fait le tour de la majorité des trucs que j’avais envie de partager avec vous. J’espère que vous aurez appris quelques trucs. Si vous voyez d’autres choses à ajouter, écrivez-moi un courriel ou laisser un commentaire et je me ferais un plaisir de mettre à jour cet article avec vos recommandations.