# Chapitre 4 - Consommer une API en code

# Appeler une API avec requests

curl et Bruno, c'est bien pour explorer. Mais pour automatiser, il faut appeler l'API depuis du code. Les exemples sont donnés en **Python**, avec la bibliothèque `requests`.

### Prérequis

Python 3 et la bibliothèque `requests` : `pip install requests`.

<p class="callout info">Votre clé d'API n'a rien à faire dans le code (ni dans git). Passez-la par une variable d'environnement : `export API_KEY=votre-cle`.</p>

### Lire une ressource

```python
import requests

BASE = "http://api-ritif.portlatch.com/v1"

res = requests.get(BASE + "/cocktails/3")
cocktail = res.json()
print(res.status_code, cocktail["name"])
```

### Paramètres de query

```python
res = requests.get(BASE + "/cocktails", params={"alcohol_free": "true", "per_page": 5})
```

### Envoyer du JSON avec un header

```python
import os

res = requests.post(
    BASE + "/orders",
    json={"cocktail_id": 3},
    headers={"X-API-Key": os.environ["API_KEY"]},
)
print(res.status_code, res.headers.get("Location"))
```

Avec `json=`, `requests` sérialise le body et ajoute le `Content-Type` tout seul.

### Le piège : les erreurs HTTP

<p class="callout warning">`requests` ne lève **pas** d'erreur sur un `404` ou un `500` : il ne le fait que si le réseau est coupé. Votre code continue donc tranquillement… avec une réponse d'erreur.</p>

```python
res = requests.get(BASE + "/cocktails/999")
res.raise_for_status()  # lève une exception si le code est 4xx ou 5xx
```

### En résumé

<table id="bkmrk-javascript-%28fetch%29py"><thead><tr><th></th><th>Python (`requests`)</th></tr></thead><tbody><tr><td>Code de statut</td><td>`res.status_code`</td></tr><tr><td>Succès (2xx) ?</td><td>`res.ok`</td></tr><tr><td>Header</td><td>`res.headers["Location"]`</td></tr><tr><td>Body JSON</td><td>`res.json()`</td></tr><tr><td>Envoyer du JSON</td><td>`json=...`</td></tr><tr><td>Lever une erreur</td><td>`res.raise_for_status()`</td></tr></tbody></table>

# Pagination, erreurs, 401 et 429

Un script qui marche sur la première page, avec un serveur de bonne humeur, c'est facile. Un script robuste doit aussi gérer la pagination, les erreurs et un serveur qui demande de ralentir.

### La pagination

Une API ne renvoie jamais une liste de 10 000 éléments d'un coup : elle découpe en **pages**. Sur API-ritif, une liste ressemble à ceci :

```json
{
  "items": [ {"id": 1, "name": "Hello World"}, ... ],
  "page": 1,
  "per_page": 5,
  "total": 13,
  "_links": {
    "self":  {"href": "/v1/cocktails?page=1&per_page=5"},
    "next":  {"href": "/v1/cocktails?page=2&per_page=5"},
    "last":  {"href": "/v1/cocktails?page=3&per_page=5"}
  }
}
```

La méthode la plus fiable : **suivre le lien `next`** jusqu'à ce qu'il n'y en ait plus. Pas besoin de calculer les numéros de page.

```python
import pprint
from urllib.parse import urljoin

def get_all(path, headers=None):
    items = []
    url = urljoin(BASE, path)
    while url:
        res = requests.get(url, headers=headers)
        res.raise_for_status()
        data = res.json()
        items.extend(data["items"])
        next_link = data["_links"].get("next")
        url = urljoin(BASE, next_link["href"]) if next_link else None
    return items

cocktails = get_all("/v1/cocktails")

pprint.pp(cocktails)
```

<p class="callout info">Les liens sont relatifs (`/v1/...`) : `urljoin()` les combine avec l'adresse du serveur.</p>

### Lire les erreurs

API-ritif renvoie ses erreurs au format `application/problem+json` (détaillé au chapitre 5). Le champ `detail` est fait pour être affiché :

```json
{
  "type": "https://api-ritif.example/problems/out-of-stock",
  "title": "Rupture de stock",
  "status": 409,
  "detail": "Le 404 Spritz est introuvable. Comme d'habitude."
}
```

```python
if not res.ok:
    problem = res.json()
    print(problem["status"], problem["title"], ":", problem["detail"])
```

### 401 : l'authentification a échoué

Un `401` signifie que le serveur ne vous reconnaît pas : clé absente, clé fausse, ou **token expiré**. Certains tokens ne vivent que quelques minutes (c'est le cas des JWT d'API-ritif, voir chapitre 8).

Réaction attendue d'un client :

- clé absente ou fausse : inutile d'insister, il faut corriger la configuration ;
- token expiré : en redemander un nouveau, puis **rejouer** la requête une fois.

### 429 : le serveur demande de ralentir

Pour se protéger, une API limite le nombre de requêtes par client (*rate limiting*). Au-delà, elle répond `429 Too Many Requests`, souvent avec un header `Retry-After` qui indique combien de secondes attendre.

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 10
Content-Type: application/problem+json

{"title": "Too Many Requests", "status": 429, "detail": "Le barman est débordé, reviens dans 10 s"}
```

Un bon client **attend**, puis réessaie :

```python
import time

def polite_get(url, **kwargs):
    while True:
        res = requests.get(url, **kwargs)
        if res.status_code != 429:
            return res
        wait = int(res.headers.get("Retry-After", 1))
        print("429 reçu, pause de", wait, "s")
        time.sleep(wait)
```

<p class="callout success">Sans `Retry-After`, la bonne pratique est le **backoff exponentiel** : attendre 1 s, puis 2 s, puis 4 s, puis 8 s… Le serveur respire, et vous finissez par passer.</p>

<p class="callout warning">Réessayer immédiatement en boucle après un `429`, c'est comme klaxonner dans un embouteillage : ça n'avance pas plus vite, et tout le monde vous déteste.</p>

# Les machines consomment aussi vos API

Jusqu'ici, les clients de nos API étaient des programmes écrits par des humains. Depuis l'arrivée des LLM, un nouveau type de client apparaît : **l'IA elle-même**.

### Un LLM qui utilise des outils

Un modèle de langage ne fait que produire du texte. Pour agir sur le monde (lire une météo, créer un ticket, allumer une lampe), on lui donne des **outils** : des fonctions décrites par un nom, une description et des paramètres. Le modèle décide quand les appeler, et le programme qui l'héberge exécute l'appel.

```text
Utilisateur ──▶ LLM ──▶ « appelle l'outil get_weather(ville="Lyon") »
                              │
                              ▼
                   Programme hôte ──▶ GET https://api.meteo.example/v1/previsions?ville=Lyon
                              │
                              ▼
                LLM ◀── {"temperature": 14, "ciel": "nuageux"}
                 │
                 ▼
Utilisateur ◀── « Il fait 14 °C à Lyon, prenez un parapluie. »
```

Derrière la plupart des outils… il y a une API.

### MCP

Le **Model Context Protocol** (MCP), publié par Anthropic fin 2024 et adopté depuis par la plupart des acteurs, standardise cette mécanique. Un **serveur MCP** expose des outils, et n'importe quel assistant compatible (Claude, ChatGPT, Copilot, des éditeurs de code…) peut les utiliser.

Un serveur MCP est souvent une simple **surcouche** d'une API existante : il traduit les outils en appels HTTP.

### L'API, c'est le MCP du pauvre

Un agent IA capable d'exécuter des commandes (comme Claude Code) n'a même pas besoin de MCP : si on lui donne une URL, une clé et une documentation, il sait écrire les requêtes `curl` lui-même. À condition que l'API soit compréhensible.

Ce qui aide une IA à utiliser votre API, c'est exactement ce qui aide un développeur :

- des ressources et des routes **bien nommées** ;
- des **codes de statut** justes, plutôt qu'un `200` pour tout ;
- des **messages d'erreur** explicites, qui disent quoi corriger ;
- une **documentation** à jour, avec des descriptions et des exemples (OpenAPI, chapitre 6) ;
- des **liens** dans les réponses pour découvrir la suite (HATEOAS, chapitre 9).

<p class="callout success">Une API mal conçue fait perdre une heure à un développeur. Elle peut faire faire n'importe quoi à une IA, très vite et avec beaucoup d'assurance.</p>

<p class="callout warning">Donner une clé d'API à une IA, c'est lui donner les mêmes droits que vous. Préférez des clés limitées (lecture seule, périmètre réduit) et révocables.</p>

# TP : Script de statistiques

### Objectif

Pendant la chasse au trésor, toute la promo a passé des commandes au bar. Le patron d'API-ritif veut maintenant des **statistiques**, et il compte sur vous.

Vous allez compléter un script **Python** qui récupère les données par l'API et calcule des statistiques.

<p class="callout warning">Ce TP est individuel et noté.</p>

### Le fichier de départ

Copiez le code ci-dessous dans un fichier `stats.py`. La partie réseau est **déjà écrite** : la fonction `get_all` parcourt toutes les pages et gère les `429` (voir la page [Pagination, erreurs, 401 et 429](https://formation.tfrichet.fr/books/concevoir-une-api-rest/page/pagination-erreurs-401-et-429)). Votre travail porte uniquement sur les données.

Le script fonctionne dès le départ : chaque question affiche `None` tant que vous ne l'avez pas faite. Commencez par le lancer, et regardez les deux exemples affichés en haut : ils montrent les champs d'une commande et d'un cocktail.

```python
# TP2 - Script de statistiques sur API-ritif
#
# Lancement :
#   export API_KEY=ma-cle        (sous Windows PowerShell : $env:API_KEY="ma-cle")
#   python stats.py > resultat.txt
#
# Le script fonctionne dès le départ : chaque question affiche None
# tant que vous ne l'avez pas faite. Complétez les blocs « À FAIRE ».

import os
import time
from urllib.parse import urljoin

import requests

BASE = "http://api-ritif.portlatch.com/v1"

# La clé est lue dans une variable d'environnement, jamais écrite dans le code.
HEADERS = {"X-API-Key": os.environ["API_KEY"]}


# =============================================================================
# FOURNI : ne pas modifier
# =============================================================================

def polite_get(url):
    """GET qui attend et recommence tant que le barman répond 429."""
    while True:
        res = requests.get(url, headers=HEADERS)
        if res.status_code != 429:
            res.raise_for_status()
            return res.json()
        wait = int(res.headers.get("Retry-After", 1))
        print("(429 reçu, pause de", wait, "s)")
        time.sleep(wait)


def get_all(path):
    """Renvoie TOUS les éléments d'une liste paginée, en suivant le lien next."""
    items = []
    url = urljoin(BASE, path)
    while url:
        data = polite_get(url)
        items.extend(data["items"])
        next_link = data["_links"].get("next")
        url = urljoin(BASE, next_link["href"]) if next_link else None
    return items


orders = get_all("/v1/orders?scope=all&per_page=50")
cocktails = get_all("/v1/cocktails?per_page=50")

# Regardez bien ces deux exemples : ils montrent les champs disponibles.
print("Exemple de commande :", orders[0])
print("Exemple de cocktail :", cocktails[0])
print()


# =============================================================================
# QUESTIONS OBLIGATOIRES
# =============================================================================

# ----- Question 1 : le nombre total de commandes -----------------------------

total = None  # À FAIRE

print("Q1 - Nombre de commandes :", total)


# ----- Question 2 : le nombre de commandes par statut ------------------------
# EXEMPLE RÉSOLU : c'est le modèle pour compter, à réutiliser plus bas.
# On part d'un dictionnaire vide, et pour chaque commande on ajoute 1
# au compteur de son statut (get renvoie 0 si le statut n'est pas encore vu).

by_status = {}
for order in orders:
    status = order["status"]
    by_status[status] = by_status.get(status, 0) + 1

print("Q2 - Commandes par statut :", by_status)


# ----- Question 3 : le cocktail le plus commandé -----------------------------
# Indice 1 : comptez les commandes par cocktail, comme dans la question 2.
# Indice 2 : max(d, key=d.get) renvoie la clé du dictionnaire d
#            qui a la plus grande valeur.

most_ordered = None  # À FAIRE

print("Q3 - Cocktail le plus commandé :", most_ordered)


# ----- Question 4 : l'ardoise de la promo -------------------------------------
# Le montant total des commandes, SANS compter les commandes annulées
# (statut "cancelled").
# Indice : une variable qui commence à 0, une boucle, un if.

tab = None  # À FAIRE

print("Q4 - Ardoise de la promo :", tab, "€")


# =============================================================================
# BONUS
# =============================================================================

# ----- Bonus 1 : le client qui a passé le plus de commandes ------------------
# Indice : même principe que la question 3.

best_customer = None  # À FAIRE

print("B1 - Meilleur client :", best_customer)


# ----- Bonus 2 : la note moyenne de chaque cocktail ---------------------------
# Les avis d'un cocktail : get_all("/v1/cocktails/" + str(cocktail["id"]) + "/reviews")
# Moyenne = somme des notes / nombre de notes, arrondie au dixième avec round(x, 1).
# Attention : un cocktail peut n'avoir aucun avis (division par zéro !).

averages = None  # À FAIRE : un dictionnaire nom du cocktail -> note moyenne

print("B2 - Notes moyennes :", averages)


# ----- Bonus 3 : la part de cocktails sans alcool parmi les commandes --------
# Le champ alcohol_free est dans la carte (cocktails), pas dans les commandes.
# Résultat en pourcentage, arrondi au dixième.

alcohol_free_percent = None  # À FAIRE

print("B3 - Commandes sans alcool :", alcohol_free_percent, "%")
```

### Consignes

#### 1 - Questions obligatoires

1. Le nombre total de commandes.
2. Le nombre de commandes par statut : **déjà résolue**, c'est le modèle à réutiliser pour compter.
3. Le cocktail le plus commandé.
4. « L'ardoise » de la promo : le montant total des commandes, sans compter les commandes annulées.

#### 2 - Bonus

1. Le client qui a passé le plus de commandes.
2. La note moyenne de chaque cocktail, arrondie au dixième (une requête par cocktail).
3. La part de cocktails sans alcool parmi les commandes, en pourcentage.

#### 3 - Défis (non notés)

- L'heure de pointe : la tranche de 10 minutes avec le plus de commandes.
- Un classement des cocktails par note moyenne, en n'incluant que ceux qui ont au moins 3 avis.

#### Contraintes

- La clé d'API est lue dans la variable d'environnement `API_KEY`, jamais écrite dans le code.
- Aucune donnée n'est saisie à la main : tout vient de l'API.
- Ne modifiez pas la partie « FOURNI ».

### Résultat attendu

- Votre script complété (`stats.py`).
- Un fichier `resultat.txt` contenant sa sortie : `python stats.py > resultat.txt`.

Vos résultats seront comparés à ceux du formateur.