Abbeal

Architecture

Versionner une API REST sans toucher aux URL, avec Spring 7

Pourquoi j'ai choisi la négociation de contenu plutôt qu'un /v1 dans l'URL, et comment Spring Framework 7 le met en place en trois propriétés.

6 min

Le problème : des clients qui appellent l'API en dur

Je maintiens une petite API REST personnelle sur Spring Boot 4, donc Spring Framework 7. Plusieurs clients l'appellent directement : des agents IA, des scripts, quelques intégrations. Ils appellent des routes comme /api/shorten ou /api/random telles quelles.

Aucun endpoint n'était versionné. Tant que rien ne change, ça va. Le jour où je veux changer le format d'une réponse, par exemple renvoyer un objet JSON au lieu d'une simple chaîne, je casse tous ces clients d'un coup.

Il me fallait donc un moyen de livrer une version 2 sans casser la version 1. La vraie question : comment un client choisit-il la version qu'il reçoit ?

Trois options sur la table

Le préfixe dans l'URL (/api/v1/...) est le réflexe le plus courant. Il est explicite et facile à router. Mais il casse tous les appels existants, sauf à maintenir des alias pour chaque route. Et il pousse vite à dupliquer les contrôleurs et les DTO dans des paquets v1 et v2.

Un en-tête maison (X-API-Version) ou un paramètre de requête (?version=1) garde les URL intactes. C'est mieux. Mais on invente un mécanisme alors qu'HTTP en a déjà un.

La négociation de contenu utilise justement ce mécanisme standard. Le client demande une représentation précise dans l'en-tête Accept :

http
Accept: application/json;version=1

L'URL ne bouge pas. La version fait partie du type de média demandé, ce qui est exactement son rôle.

Le choix, et pourquoi il ne coûte presque rien

J'ai retenu la négociation de contenu pour une raison simple : aucun client n'a à migrer. Une requête sans version reçoit la version 1, comme avant.

La bonne surprise, c'est Spring Framework 7. Le versioning d'API y est intégré nativement. Pas d'intercepteur à écrire, pas de constantes de chemin. Trois lignes dans application.properties :

properties
spring.mvc.apiversion.supported=1.0 spring.mvc.apiversion.default=1.0 spring.mvc.apiversion.use.media-type-parameter[application/json]=version

La première liste les versions acceptées. La deuxième sert la 1.0 quand le client ne précise rien. La troisième dit à Spring de lire la version dans le paramètre version du type application/json.

Les pages HTML (Thymeleaf) ne sont pas concernées : la règle ne porte que sur application/json.

Ce que voit le client

Côté client, une requête sans version passe normalement :

bash
curl -H "Accept: application/json" "https://api.example.com/api/random?min=1&max=6" # {"value":6,"min":1,"max":6,"timestamp":"..."}

Une version inconnue renvoie une erreur 400, au même format que les autres erreurs de l'API :

bash
curl -H "Accept: application/json;version=9" "https://api.example.com/api/random?min=1&max=6" # {"status":400,"error":"Bad Request","message":"Invalid API version: '9.0.0'.","path":"/api/random"}

Deux détails. D'abord, c'est une 400 et pas une 406 : Spring lève une InvalidApiVersionException, que mon gestionnaire d'erreurs global transforme en réponse standard. Ensuite, Spring normalise la version : 9 devient 9.0.0 dans le message. Bon à savoir avant d'écrire des tests sur le texte exact.

À quoi ressemblera la version 2

Quand un changement cassant arrivera, j'ajouterai une méthode à côté de l'existante, dans le même contrôleur :

kotlin
@GetMapping("/shorten") // version 1, implicite fun shorten(@RequestParam url: String): String = ... @GetMapping("/shorten", version = "2") // Accept: application/json;version=2 fun shortenV2(@RequestParam url: String): ShortenResponseV2 = ...

Pas de paquet v2, pas de contrôleur dupliqué. Il faudra seulement ajouter 2.0 à spring.mvc.apiversion.supported.

Le tester

Un test @WebMvcTest couvre le comportement, sur un endpoint JSON et une page HTML. Cinq cas suffisent :

  • sans version, la requête est servie en version 1
  • avec version=1, elle est servie
  • avec une version inconnue (version=9), réponse 400
  • avec une version mal formée (version=abc), réponse 400
  • une page HTML répond normalement
kotlin
@Test fun unsupportedVersionReturns400() { mockMvc.perform(get("/api/random").accept("application/json;version=9")) .andExpect(status().isBadRequest) .andExpect(jsonPath("$.status").value(400)) }

Ce qui reste à faire le jour de la v2

J'ai volontairement repoussé trois sujets, parce que rien n'en a besoin tant qu'il n'existe qu'une version.

Le cache, d'abord. Mon API est derrière un CDN. Avec deux versions sur la même URL, un cache partagé doit savoir que la réponse dépend de l'en-tête Accept. Spring n'ajoute pas Vary: Accept tout seul : il faudra le faire.

La dépréciation, ensuite. Spring 7 sait annoncer la fin d'une version avec les en-têtes Deprecation, Sunset et Link. Utile le jour où la version 1 devra partir.

OpenAPI Generator, enfin. Il gère mal plusieurs types de média pour un même chemin (issue #11000 du projet). Sans spécification publiée pour l'instant, ça ne me gêne pas. Ce sera à surveiller si j'en publie une.

Ce que j'en retiens

Le bilan est très positif pour un coût minime : trois propriétés, aucune migration, aucune URL dupliquée. Le versioning est en place avant même d'en avoir besoin, et il ne gêne personne.

Il y a deux contreparties à connaître. La version n'apparaît plus dans les URL ni dans les logs d'accès : si j'en ai besoin pour diagnostiquer, il faudra la journaliser explicitement. Et la négociation de contenu demande un peu plus de rigueur côté cache dès qu'une deuxième version existe.

Si votre API a déjà des clients et que vous êtes sur Spring Boot 4, c'est à mon avis le choix le plus simple aujourd'hui. Tout est consigné dans un ADR du projet, avec les options écartées, pour que la décision reste lisible dans six mois.

Tu as un projet qui ressemble à ça ?

Parler à un architecte