Conception d’API : prendre le bon chemin après une V1

ou pourquoi il ne faut jamais faire de v2

Introduction

Imaginez.

Vous êtes une dev assez expérimentée et vous fabriquez un logiciel destiné aux bibliothèques municipales.

Par ailleurs, vous encadrez d’autres personnes plus juniors.

L’une d’entre elles (appelons-là Camille) a eu pour tâche d’exposer une API qui permet de gérer les fonds des bibliothèques.

C’est l’heure de la revue de code, et Camille vous montre une API qui ressemble à ceci :


GET /api/book/
# Récupère les informations d'un livre en fonction de son ISBN

POST /api/check-out/book/
# Enregistre un retrait de livre de la biliothèque

Comme vous avez déjà été confrontée au problème des mises à jour d’API, vous dites à Camille de rajouter un numéro de version au début de chaque route, comme ceci :


GET /api/v1/book/<isbn>
POST /api/v1/check-out/book/<isbn>

"Ainsi", expliquez-vous à Camille, "le jour où on change d'avis sur l'API, on pourra faire une v2 sans casser l'ancien code".

"Oh je comprends", répond Camille.

Quelques temps plus tard, après s'être assurée que la nouvelle API est correctement documentée, vous la déployez et la présentez aux autres équipes.

L'API a beaucoup de succès, et quelques mois plus tard c'est au moins 3 équipes différentes qui utilisent votre API régulièrement : bien joué !

La tentation de la v2

Le temps a passé, et Camille revient vous voir :

"Dis, j'ai réfléchi et j'ai eu des idées pour améliorer l'API. Par exemple, la route api/book/<isbn> n'est pas très cohérente. On documente qu'il faut passer l'ISBN directement dans le chemin de la route, mais on renvoie un JSON avec un champ id":


{
  "book": {
    "id": "9791020904409",
    "author": "Pablo Servigne",
    "title": "L'Entraide, l'autre loi de la jungle"
  }
}

"ça cause pas mal de confusion, et puis c'est déjà arrivé que les ISBN changent de format. Du coup, vu qu'on a bien pris soin de faire une v1, est-ce que ça ne serait pas l'occasion de faire une V2 ?

On aurait juste GET /api/v2/book et on pourrait passer le ISBN dans les paramètres de la query. On en profite pour remplacer le champ id par un champ isbn:"


// GET /api/v2/book?isbn=9791020904409
{
  "book": {
    "isbn": "9791020904409",
    "author": "Pablo Servigne",
    "title": "L'Entraide, l'autre loi de la jungle"
  }
}

Qu'est-ce que tu en penses?"

Spontanément, vous avez envie de dire oui : après tout, l'API V2 a l'air bien plus propre.

Mais d'un autre côté, vous hésitez. L'API V1 est déjà en production. Certes, il y a bien eu quelques bugs liés à la gestion des IDs, mais ils ont tous été corrigés depuis.

Pourquoi ne PAS faire une v2

Vous décidez de ne pas prendre de décision tout de suite, et à la place vous vous posez avec Camille pour une séance de brainstorming.

À la sortie de la réunion, vous vous êtes mises d'accord sur le fait que le scénario le plus probable est le suivant:

  1. La v2 est déployée en parallèle de la v1 (de façon à ne pas casser la prod)
  2. Les autres équipes savent qu'il faudra faire le passage de v1 à v2, mais il n'y a pas de caractère d'urgence.
  3. Chaque équipe se retrouve donc avec un ticket "mettre à jour vers la v2" dans son backlog
  4. Ces tickets considérés (à raison) par le métier comme étant purement techniques et ne sont jamais priorisés
  5. Quelques équipes (mais pas toutes) mettent à jour parce que la v2 leur apporte de vrais avantages - ou bien elles commencent à utiliser l’API directement en version 2
  6. Il y a un bug en production facile à corriger en v2 mais pas en v1 et comme vous ne pouvez pas forcer les équipes à mettre à jour, vous devez faire deux correctifs différents, les tester et les déployer sans rien casser.

Bref. Beaucoup d’ennuis en perspective …

Que faire à la place ?

Et bien, ne jamais faire de 2.0, tout simplement 😊

L'idée est de considérer qu'une fois que la 1.0 est publique, c'est votre travail de ne jamais casser la rétrocompatibilité

Dans le cas qui nous occupe, vous pouvez tout à fait garder la route "legacy" GET /api/v1/book/<isbn> en plus de la route qui prend l'id en paramètre.

Et de la même façon, vous pouvez renvoyer un JSON qui contient les deux champs:


{
  "book": {
    "isbn": "9791020904409",
    "id": "9791020904409",
    "author": "Pablo Servigne",
    "title": "L'Entraide, l'autre loi de la jungle"
  }
}

Si on imagine que le backend est en Python/Flask, cela donnerait: 


from dataclasses import dataclass, asdict
from flask import query, Flask

app = Flask(...)

# On déclare les types de réponses
@dataclass
class BookDetails:
    id: str
    isbn: str


@dataclass
class BookResponse:
    book: BookDetails

# On définit les deux routes:

@app.get("/book/{id}")
def get_book_legacy(id):
    """Première route, dépréciée"""
    response = get_book_by_id(id)
    return asdict(response)


@app.get("/book")
def get_book():
    """Nouvelle route, recommendé"""
    isbn = query.params.get("isbn")
    response = get_book_by_id(id=isbn)
    return asdict(response)


# Implémentation commune aux deux routes
def get_book_by_id(id: str) -> BookResponse:
    # On récupère une connexion à la base de données :
    db_connection = ...
    # On récupère une ligne de la base:
    row = db_connection.find_book_by_id(id)

    book_details = BookDetails(
        id=row.isbn,
        isbn=row.isbn,  # pour la rétro-compatibilité
    )

    return BookResponse(book=book_details)

Pour un client TypeScript qui ne s'est jamais mis à jour :

  
import { client } from "./apiClient";

interface Book {
  id: string;
  title: string;
  autor: string;
}

async function getBookById(id: string): Promise {
  const response = client.get(`/book/${id}`);
  return response as Book;
}
  

Et pour un client qui est passé aux API recommandées :

  
import { client } from "./apiClient";

interface Book {
  isbn: string;
  title: string;
  autor: string;
}

async function getBookById(isbn: string): Promise {
  const response = client.get(`/book/`, { params: { isbn } });
  return response as Book;
}
  

Comment tester ?

Vous pouvez utiliser des tests de contrat : 

  
def test_get_book_legacy(test_client, fake_repo):
    fake_repo.add_book(
        isbn="123456789012",
        author="Jerry Z. Muller",
        title="The Tyranny of Metrics",
    )

    json = test_client.get("/book/123456789012")

    assert json["title"] == "The Tyranny of Metrics"
    assert json["author"] == "Jerry Z. Muller"
    assert json["id"] == "123456789012"


def test_get_book_using_isbn_in_params(test_client, fake_repo):
    fake_repo.add_book(
        isbn="123456789012",
        author="Jerry Z. Muller",
        title="The Tyranny of Metrics",
    )

    json = test_client.get("/book", params={"isbn": "123456789012"})

    assert json["title"] == "The Tyranny of Metrics"
    assert json["author"] == "Jerry Z. Muller"
    assert json["isbn"] == "123456789012"
  

Notez qu'on n'utilise surtout pas les objets dataclass déclarés au niveau du back-end dans les tests, parce qu'on veutque ces tests cassent si jamais l'API devient incompatible !

Conclusion

J’espère que vous avez apprécié cet exemple. Il n’est pas si fictif que cela, on peut penser notamment à Python, dont le passage de 2 à 3 a causé a duré près de 10 ans (!) et a causé pas mal de souci à la communauté.

Et on peut aussi citer Rust et Go, qui ont sorti leur version 1.0 en 2012 et 2015 respectivement et n’ont jamais cassé leur rétrocompatibilité. 

En tout cas, j’espère que vous cela vous aura donné à réfléchir, et que vous vous poserez davantage de questions avant de proposer une mise à jour incompatible aux personnes qui utilisent vos APIs 🙂

CodeWorks, un modèle d'ESN qui agit pour plus de justice sociale.

Notre Manifeste est le garant des droits et devoirs de chaque CodeWorker et des engagements que CodeWorks a vis-à-vis de chaque membre.
Il se veut réaliste, implémenté, partagé et inscrit dans une démarche d'amélioration continue.

Rejoins-nous !

Tu veux partager tes connaissances et ton temps avec des pairs empathiques, incarner une vision commune de l'excellence logicielle et participer activement à un modèle d'entreprise alternatif, rejoins-nous.