Python et API REST
Les API REST (Representational State Transfer) permettent d’exposer un service web: lecture de données, ajout de nouvelles données, modification de données existantes, suppression de données, etc. Elles sont basées sur le protocole HTTP qui permet à une application cliente d’accéder aux données stockées sur un serveur.
Principes d’une API REST
Un serveur expose un certain nombre de points d’accès (end points ou routes) permettant d’accéder à des données en lecture ou écriture via des méthodes du protocole HTTP.
Par exemple, en ouvrant ce lien dans votre navigateur web, vous émettez une requête GET au serveur https://jsonplaceholder.typicode.com sur le point d’accès /posts. Le serveur vous renvoie alors un fichier au format JSON qui contient l’ensemble des articles de blog (fictifs) stockés sur ce serveur. Votre navigateur affiche le contenu du fichier.
Vous pouvez également émettre la même requête depuis un terminal avec l’outil curl:
$ curl https://jsonplaceholder.typicode.com/posts
Le même fichier s’affiche alors dans votre terminal.
Chaque serveur expose une API qui lui est propre en fonction des données qu’il héberge et des actions qu’il autorise sur celles-ci. Le serveur peut mettre en oeuvre plusieurs méthodes HTTP, notamment:
| Méthode | Action |
|---|---|
| GET | lecture d’une donnée |
| POST | créer une nouvelle donnée |
| PUT | modifier une donnée |
| PATCH | modifier partiellement une donnée |
| DELETE | supprimer une donnée |
Une rquête peut réussir ou échouer, par exemple, lorsque le point d’accès spécifié n’est pas correct, ou si les données transmises sont invalides. Le serveur renvoie systématiquement un code de statut en réponse à la requête. Un code est composé de trois chiffres. Le premier indique le statut de la requête:
| Code | Signification |
|---|---|
| 2xx | Opération réussie |
| 3xx | Redirection |
| 4xx | Erreur client |
| 5xx | Erreur serveur |
La liste complète des code d’erreurs est consultable dans la RFC 7231 section 6.
Accéder à une API REST
Dans cette partie, nous utilisons le service {JSON} Placeholder qui implémente une API REST pour le prototypage.
Ce service héberge les ressources suivantes:
| Point d’accès | Ressources |
|---|---|
/posts |
100 posts |
/comments |
500 comments |
/albums |
100 albums |
/photos |
5000 photos |
/todos |
200 todos |
/users |
10 users |
Ces ressources sont notamment accessibles via les points d’accès et méthodes ci-dessous:
| Méthode | Point d’accès | Effet |
|---|---|---|
GET |
/posts |
Obtenir la liste des messages blog |
GET |
/posts/<id> |
Obtenir le message de blog d’identifiant id |
GET |
/posts/<id>/comments |
Obtenir la liste des commentaires sur le message de blog d’identifiant id |
GET |
/comments?postId=<id> |
Idem |
POST |
/posts |
Ajouter un message de blog |
PUT |
/posts/<id> |
Remplacer un message de blog |
PATCH |
/posts/<id> |
Modifier un message de blog |
DELETE |
/posts/<id> |
Supprimer un message de blog |
Prise en main du service REST avec curl
L’outil curl permet d’émettre des requêtes HTTP à un serveur. L’option -X permet de spécifier la méthode utilisée (GET, PUT, etc). L’option -H définit l’en-tête (header) de la requête. Nous l’utiliserons pour passer des données au format JSON combinée avec l’option -d (voir exemple ci-dessous). Enfin, l’outil jq permet de traiter des données au format JSON dans le terminal.
Taper une à une les commandes ci-dessous dans un terminal et expliquer les résultats obtenus.
curl -X GET https://jsonplaceholder.typicode.com/posts/1 | jq
curl -X GET https://jsonplaceholder.typicode.com/posts/1/comments | jq
curl -X GET https://jsonplaceholder.typicode.com/posts/1000 | jq
echo $?
curl -X GET https://jsonplaceholder.typicode.com/users | jq
curl -X POST -H "Content-Type: application/json" -d '{"userId": 1, "title": "foo", "body": "bar"}' https://jsonplaceholder.typicode.com/posts | jq
curl -X GET https://jsonplaceholder.typicode.com/posts/101 | jq
curl -X DELETE https://jsonplaceholder.typicode.com/posts/1 | jq
curl -X GET https://jsonplaceholder.typicode.com/posts/1 | jq
Accéder à une API REST en Python avec requests
Le module Python requests permet d’accéder à une API REST très facilement, en échangeant des objets JSON, sans avoir à gérer le protocole HTTP (en-tête,…).
Taper le code suivant dans l’interface REPL et expliquer les résultats obtenus:
import requests
url = "https://jsonplaceholder.typicode.com/"
response = requests.get(url + "/posts/1")
response
response.json()
En utilisant help(response), indiquer comment savoir:
- le statut HTTP de la requête
- son en-tête HTTP
- l’URL de la requête
Solution
response.status_code
response.headers
response.url
Écrire le code python permettant d’obtenir les commentaire du message de blog numéro 1 à l’aide de requests. Vérifier que la requête s’est terminée avec succès. Afficher le contenu retourné par le serveur.
Solution
comments = requests.get(url + "posts/1/comments")
comments.ok
comments.json()
La requête précédente a retourné des données au format JSON qui peuvent être exploitées directement en python. Ces données consistent en une liste de dictionnaires, chaque dictionnaire correspondant à un commentaire. Écrire le code python qui construit la liste des adresses email obtenues lors de la requête précédente.
Solution
# comments est la variable qui stocke la réponse à la requête précédente
[c['email'] for c in comments.json()]
Afin d’ajouter un nouveau message de blog, nous allons effectuer une requête POST vers le point d’accès /posts. Lors de cette requête, nous devons passer un objet au format JSON définissant les attributs du message de blog. Pour cela, nous allons créer un dictionnaire contenant ces attributs et leurs valeurs. Le paramètre json de la méthode requests.post permet de passer le dictionnaire lors de la requête. Ajouter un message de blog ayant pour attribut {"userId": 6, "id": 101, "title": "A title", "body": "A body"}. Quel est le statut HTTP renvoyé par le serveur?
Solution
comment = {"userId": 6, "id": 101, "title": "A title", "body": "A body"}
r = requests.post(url + "/posts", json=comment)
r.status_code
Le serveur renvoie le code 201 qui signifie “Created”: le message de blog a été crée (fictivement)
Construire un serveur REST
On cherche maintenant à construire un serveur capable de fournir une API REST à des clients. Plusieurs modules Python existent pour cela, notamment: Flask, Django et Fast API. Nous utiliserons ce dernier pour sa simplicité.
Chargement du fichier CSV
Nous allons programmer un serveur REST permettant d’accéder au données du fichier countries.csv. Pour lire ce fichier, nous utiliserons le module csv. La méthode csv.DictReader() retourne un objet itérable, dont chaque élément est un dictionnaire dont les noms de champs correspondent aux noms des colonnes définis en première ligne du fichier countries.csv.
import csv
def load_csv(filename):
with open(filename, mode ='r') as f:
data = csv.DictReader(f)
return list(data)
countries = load_csv('countries.csv')
Le programme ci-dessus crée une variable globale countries dont le contenu est celui du fichier countries.csv.
Exécuter le code ci-dessus dans l’interface REPL. Afficher la valeur de la variable countries ainsi que son type.
Un serveur Fast API minimal
Le code ci-dessous étend notre programme précédent:
- il importe
FastAPIetHTTPExceptiondu modulefastapi - et il crée une application Fast API dans la variable globale
app
import csv
from fastapi import FastAPI, HTTPException
def load_csv(filename):
with open(filename, mode ='r') as f:
data = csv.DictReader(f)
return list(data)
countries = load_csv('countries.csv')
app = FastAPI()
Copier-coller le code ci-dessus dans le fichier countries_server.py. Le serveur Fast API se lance par la commande:
uvicorn countries_server:app --reload
L’option --reload indique que le serveur doit être relancé à chaque modification du fichier countries_server.py. Au lancement, uvicorn affiche l’URL du serveur, par exemple: Uvicorn running on http://localhost:8000.
Un premier point d’accès /countries
Nous allons maintenant ajouter un point d’accès permettant d’obtenir la liste des noms de pays contenus dans le fichier countries.csv. Il suffit pour cela d’ajouter le code suivant à la fin du fichier countries_server.py.
@app.get("/countries")
async def get_countries():
return [c['Name'] for c in countries]
Nous pouvons maintenant lancer une requête GET sur le point d’accès /countries, et ainsi obtenir la liste des noms de pays.
curl -X GET http://localhost:8000/countries | jq
La fonction get_countries() ci-dessus calcule la liste des noms de pays depuis la variable globale countries qui contient le fichier countries.csv. Nous remarquons deux nouveautés sur cette fonction:
- l’annotation
@app.get("/countries")indique à Fast API que la fonctionget_countries()doit être appelée pour répondre à une requèteGETsur le point d’accès/countries. - le mot clé
asyncindique que la fonctionget_countries()peut être exécutée de manière asynchrone. C’est à dire que notre serveur Fast API va lancer la fonctionget_countries(), mais il ne va pas attendre la terminaison de celle-ci. Il pourra exécuter une autre fonction, et donc répondre à une autre requête, tandis queget_countries()s’exécute. Lorsque la fonction termine, le serveur renvoie le résultat produit au client. L’exécution asynchrone est utile (voire indispensable) lorsque la réponse à une requête peut demander un long calcul: cela évite de bloquer le serveur.
Ajout d’un point d’accès avec paramètre /countries/{name}
Ajouter un point d’accès /countries/{name} qui permet d’accéder aux informations du pays de nom name. Notons que name est un paramètre de la requête, et donc de la fonction qui la traite:
@app.get("/countries/{name}")
async def get_country(name : str):
...
Par exemple, une requête GET sur le point d’accès /countries/South%20Korea retourne le JSON suivant ci-dessous qui contient les informations concernant la Corée du Sud dans le fichier countries.csv.
{
'Name': 'South Korea',
'NativeName': '대한민국',
'CallingCode': '82',
'Iso3166P1Alpha2Code': 'KR',
'Iso3166P1Alpha3Code': 'KOR',
'Iso3166P1NumericCode': '410',
'Isni': '0000 0001 2308 8103',
'Population, 2010': '49554112',
'Population, 2011': '49936638',
'Population, 2012': '50199853',
'Population, 2013': '50428893',
'Population, 2014': '50746659',
'Population, 2015': '51014947',
'Population, 2016': '51217803',
'Population, 2017': '51361911',
'Population, 2018': '51606633',
'Population, 2019': '51709098'
}
Vérifier que votre méthode renvoie les informations attendues à l’aide de curl et jq. Vérifier également la gestion d’erreur lorsque name ne correspond pas à un nom de pays dans le fichier countries.csv.
Solution
@app.get("/countries/{name}")
async def get_country(name : str):
for c in countries:
if c['Name'] == name:
return c
raise HTTPException(status_code=404, detail=f'Country {name} not found')
Il est possible de vérifier le code d’erreur retourné par curl en utilisant l’option -I pour obtenir uniquement l’en-tête HTTP. Par exemple, pour une requête valide, nous obenons:
$ curl -I -X GET http://localhost:8000/countries/South%20Korea
HTTP/1.1 200 OK
...
Alors qu’une requête invalide retourne un code 404 comme prévu par notre implémentation ci-dessus.
$ curl -I -X GET http://localhost:8000/countries/foo
HTTP/1.1 404 Not Found
...
Documentation et interface web
Entrez l’url http://localhost:8000/docs dans votre navigateur web (c’est à dire l’url fournie par uvicorn avec le point d’accès /docs). Il s’agit de la page de documentation créée automatiquement par Fast API.
Cette page permet également de tester les points d’accès. Essayer d’accéder à chacun des deux points d’accès directement depuis la page de documentation.
Ajout d’un point d’accès de modification /population
Ajouter un point d’accès permettant d’ajouter ou de modifier la population d’un pays name pour une année year donnée. Pour modifier uniquement le champ population, nous utilisons la méthode HTTP PATCH.
Créer un point d’accès /population pour la méthode patch. Ce point d’accès prend également deux autres paramètres: year et population. Notons que ces paramètres apparaissent dans la fonction patch_population, mais pas dans le point d’accès.
@app.patch("/population/{name}")
async def patch_population(name : str, year : int, population : int):
...
Tester le point d’accès via l’interface web http://localhost:8000/docs. Remarquer la manière dont les paramètres year et population sont passés au serveur dans la commande curl et dans la Request url générées par l’interface. Vérifier le bon fonctionnement de votre fonction patch_population à l’aide de la requête GET appropriée.
Solution
@app.patch("/population/{name}")
async def patch_population(name : str, year : int, population : int):
for c in countries:
if c['Name'] == name:
c[f'Population, {year}'] = str(population)
return {}
raise HTTPException(status_code=404, detail=f'Country {name} not found')
L’application web /docs crée par exemple la requête:
curl -X 'PATCH' \
'http://127.0.0.1:8000/population/France?year=2010&population=10' \
-H 'accept: application/json'
On vérifie avec une requête GET:
{
"Name":"France",
"NativeName":"Republique francaise",
"CallingCode":"33",
"Iso3166P1Alpha2Code":"FR",
"Iso3166P1Alpha3Code":"FRA",
"Iso3166P1NumericCode":"250",
"Isni":"",
"Population, 2010":"10",
"Population, 2011":"65342780",
"Population, 2012":"65659809",
"Population, 2013":"65998687",
"Population, 2014":"66312067",
"Population, 2015":"66548272",
"Population, 2016":"66724104",
"Population, 2017":"66864379",
"Population, 2018":"66965912",
"Population, 2019":"67055854"
}
Points d’accès multiparamètres
Un point d’accès peut contenir plusieurs paramètres. Fournir le point d’accès /population/{name}/{year}
- pour la méthode
GETqui permet d’obtenir la population du paysnamepour l’annéeyear - pour la méthode
PATCHqui permet (re)définir la population du paysnamepour l’annéeyear. Ce point d’accès prendra un autre paramètrepopulation.
Solution
@app.get("/population/{name}/{year}")
async def get_population(name : str, year : int):
for c in countries:
if c['Name'] == name and f"Population, {year}" in c:
return c[f'Population, {year}']
raise HTTPException(status_code=404, detail=f'No population defined for country {name} in year {year}')
@app.patch("/population/{name}/{year}")
async def patch_population2(name : str, year : int, population : int):
for c in countries:
if c['Name'] == name:
c[f'Population, {year}'] = str(population)
return {}
raise HTTPException(status_code=404, detail=f'Country {name} not found')
À vous de jouer
Mettre en oeuvre d’autres points d’accès permettant d’accéder et de modifier les données, en retournant différents formats: des valeurs, des listes, des dictionnaires.
Réaliser un client Python qui accède aux différents points d’accès en utilisant le module requests.
De très nombreux services proposent une API REST pour accéder à leurs données: GitHub, les Données publiques françaises, la SNCF, TikTok, ainsi que des milliers d’autres, référencés par exemple sur publicapis.io. Amusez-vous!