À la taverne The Falling Whale, le menu a longtemps tenu sur une ardoise.
Un plat, un prix, quelques pièces de cuivre, et les aventuriers savaient quoi commander. Les vieux clients connaissaient la formule par cœur. Certains avaient même automatisé leurs commandes avec de vieux parchemins curl, griffonnés un soir de tempête entre deux pintes.
Puis la guilde des aventuriers est arrivée avec de nouvelles exigences.
Elle ne voulait plus seulement connaître le nom du plat et son prix. Elle voulait les ingrédients, la devise, les restrictions de niveau, la composition exacte du ragoût, et probablement une clause de non-responsabilité en cas de thym de druide trop puissant.
La taverne devait évoluer.
Mais elle ne pouvait pas arracher l'ancienne ardoise du mur sans casser les habitudes des clients historiques.
C'est exactement le problème du versioning d'API : faire évoluer un contrat public sans trahir ceux qui l'utilisent déjà.
Dans cet article, nous allons faire évoluer le menu d'une API REST Quarkus en explorant quatre façons de versionner un contrat :
- la version dans le path,
- la version dans un query parameter,
- la version dans un header HTTP,
- la version dans le media type avec content negotiation.
À chaque étape, la taverne reste notre fil rouge : on ne va pas parler de versioning dans le vide, mais de la manière dont un tavernier peut servir plusieurs générations de clients sans transformer son comptoir en champ de bataille.
Si le sujet était de laisser chaque client composer lui-même la réponse exacte dont il a besoin, on pourrait regarder du côté de GraphQL avec Quarkus.
Ici, le problème est différent : la taverne expose bien une API REST, mais plusieurs contrats doivent cohabiter dans le temps.
Deux cartes pour une même taverne
La première carte est volontairement simple. Elle correspond au contrat historique, celui que les anciens clients savent déjà lire.
{
"plat": "Ragout de sanglier",
"prixPiecesCuivre": 12
}
le menu historique
Ce contrat V1 a un avantage énorme : il est stable.
Des clients l'utilisent peut-être depuis des mois. Ils s'attendent à trouver plat et prixPiecesCuivre, ni plus, ni moins.
La nouvelle carte de la guilde, elle, est plus détaillée :
{
"plat": "Ragout de sanglier aux herbes de druide",
"prix": {
"montant": 12,
"devise": "pieces-cuivre"
},
"ingredients": ["sanglier", "orge", "carottes", "thym de druide"],
"disponiblePour": "aventuriers niveau 2 et plus"
}
le menu amélioré
La V2 est plus riche, mais elle n'est pas simplement "la V1 avec des champs en plus".
Le prix n'est plus un entier plat. Il devient un objet structuré.
Pour un humain, c'est une amélioration.
Pour un client strict qui attend prixPiecesCuivre, c'est une rupture.
Voilà pourquoi versionner est nécessaire : la taverne doit pouvoir afficher deux cartes sans forcer tout le monde à lire la nouvelle.
Les DTOs : les cartes imprimées de la taverne
Dans notre code Quarkus, les cartes publiques sont représentées par des records Java.
La V1 reste compacte :
@Schema(description = "Contrat V1 historique du menu, conserve pour les anciens clients.")
public record MenuV1Response(
@Schema(examples = "Ragout de sanglier")
String plat,
@Schema(examples = "12")
int prixPiecesCuivre
) implements MenuResponse {
}
La V2 est plus expressive :
@Schema(description = "Contrat V2 enrichi du menu pour les clients de la guilde.")
public record MenuV2Response(
@Schema(examples = "Ragout de sanglier aux herbes de druide")
String plat,
PriceResponse prix,
@Schema(examples = "[\"sanglier\", \"orge\", \"carottes\", \"thym de druide\"]")
List<String> ingredients,
@Schema(examples = "aventuriers niveau 2 et plus")
String disponiblePour
) implements MenuResponse {
}
Et le prix devient une petite structure à part :
@Schema(description = "Prix structure de la V2, plus extensible que le champ V1 prixPiecesCuivre.")
public record PriceResponse(
@Schema(examples = "12")
int montant,
@Schema(examples = "pieces-cuivre")
String devise
) {
}
Ces records ne représentent pas les casseroles en cuisine.
Ils représentent ce que l'on pose sur la table du client.
C'est une distinction importante : un DTO de réponse est un contrat public. Il mérite d'être explicite, immuable, documenté, et séparé du modèle interne.
C'est le même principe que dans la documentation OpenAPI avec Quarkus : ce qui est exposé doit être lisible par les consommateurs de l'API, pas seulement par le développeur qui connaît le code.
Le grimoire scellé : sealed interface
Le tavernier possède donc deux cartes. Mais il sait aussi qu'elles appartiennent à la même famille : ce sont toutes les deux des représentations du menu.
En Java, cette idée se modélise très bien avec une sealed interface :
@Schema(description = "Famille fermee des representations versionnees du menu de la taverne.")
public sealed interface MenuResponse permits MenuV1Response, MenuV2Response {
@Schema(examples = "Ragout de sanglier")
String plat();
}
Le mot-clé sealed indique que seules certaines classes peuvent implémenter cette interface. Ici, la famille est fermée : MenuV1Response et MenuV2Response.
C'est comme un grimoire officiel de la taverne.
On sait quelles cartes existent. On évite qu'une représentation étrange apparaisse au détour d'une dépendance ou d'un refactoring distrait.
Mais attention : on ne fait pas hériter MenuV2Response de MenuV1Response.
La tentation existe.
Après tout, la V2 ressemble à une évolution de la V1.
Mais côté API, une V2 n'est pas forcément substituable à une V1.
Si un client attend prixPiecesCuivre, il ne peut pas consommer directement une V2 qui expose prix.montant.
La sealed interface donne donc le bon équilibre :
- une famille commune côté Java,
- des contrats JSON distincts côté HTTP,
- aucune fausse promesse de compatibilité.
Le service : les cuisines décident quelle carte servir
Le comptoir REST ne doit pas décider lui-même quel menu préparer.
Son rôle est d'écouter la commande HTTP, puis de la transmettre aux cuisines.
La logique de choix reste dans un service :
@ApplicationScoped
public class TavernMenuService {
public MenuV1Response serveHistoricalMenu() {
return new MenuV1Response("Ragout de sanglier", 12);
}
public MenuV2Response serveGuildMenu() {
return new MenuV2Response(
"Ragout de sanglier aux herbes de druide",
new PriceResponse(12, "pieces-cuivre"),
List.of("sanglier", "orge", "carottes", "thym de druide"),
"aventuriers niveau 2 et plus"
);
}
public MenuResponse serveMenuForVersion(String requestedVersion) {
return switch (MenuVersion.fromApiValue(requestedVersion)) {
case V1 -> serveHistoricalMenu();
case V2 -> serveGuildMenu();
};
}
}
Les méthodes racontent un usage métier :
serveHistoricalMenu()sert l'ancienne carte,serveGuildMenu()sert la carte enrichie,serveMenuForVersion(...)choisit la bonne carte selon la version demandée.
Le service ne connaît ni @QueryParam, ni @HeaderParam, ni Response.
Il ne sait pas si l'aventurier a demandé la carte en pointant une URL, en glissant un header au tavernier, ou en utilisant un media type exotique.
C'est exactement ce qu'on veut : les cuisines cuisinent, le comptoir parle HTTP.
Cette séparation rejoint l'idée défendue dans l'architecture hexagonale en Java : garder les règles et les cas d'usage loin des détails d'entrée/sortie.
Le registre des versions supportées
Dans une taverne bien tenue, les menus disponibles ne sont pas écrits sur des bouts de papier éparpillés derrière le bar.
On les centralise :
public enum MenuVersion {
V1("1"),
V2("2");
private final String apiValue;
MenuVersion(String apiValue) {
this.apiValue = apiValue;
}
public static MenuVersion fromApiValue(String apiValue) {
return switch (apiValue) {
case "1" -> V1;
case "2" -> V2;
default -> throw new UnknownMenuVersionException(apiValue);
};
}
public String apiValue() {
return apiValue;
}
}
Cet enum joue le rôle du registre officiel des grimoires.
Il évite de disperser "1" et "2" dans tout le code.
Il donne aussi un point unique pour décider ce qui se passe quand un aventurier demande une version inconnue.
Maintenant que la taverne sait produire plusieurs cartes, reste à savoir comment le client indique celle qu'il souhaite lire.
Première méthode : afficher la version sur la porte
La stratégie la plus visible consiste à mettre la version directement dans l'URL.
GET /taverne/versioning/path/v1/menu
GET /taverne/versioning/path/v2/menu
Narrativement, c'est comme avoir deux portes :
- une porte "ancienne carte",
- une porte "carte de la guilde".
L'aventurier choisit son entrée avant même d'arriver au comptoir.
Avec Quarkus, l'implémentation est très directe grâce aux annotations JAX-RS déjà croisées dans le guide des annotations Quarkus :
@GET
@Path("/path/v1/menu")
@Operation(
summary = "Lire le menu V1 via le path",
description = "Expose le contrat historique en placant la version directement dans l'URL."
)
@APIResponse(
responseCode = "200",
description = "Menu V1 retourne avec succes.",
content = @Content(schema = @Schema(implementation = MenuV1Response.class))
)
public Response menuByPathV1() {
return Response.ok(tavernMenuService.serveHistoricalMenu()).build();
}
Et pour la V2 :
@GET
@Path("/path/v2/menu")
@Operation(
summary = "Lire le menu V2 via le path",
description = "Expose le contrat enrichi en placant la version directement dans l'URL."
)
@APIResponse(
responseCode = "200",
description = "Menu V2 retourne avec succes.",
content = @Content(schema = @Schema(implementation = MenuV2Response.class))
)
public Response menuByPathV2() {
return Response.ok(tavernMenuService.serveGuildMenu()).build();
}
Le path versioning est très lisible.
On voit la version dans les logs, dans Swagger, dans les traces, dans les bookmarks, dans les captures d'écran envoyées au support. C'est une solution robuste et facile à comprendre.
Son défaut : la version devient une partie très visible de l'identité de la ressource.
On peut avoir l'impression que /v1/menu et /v2/menu sont deux ressources différentes, alors qu'il s'agit plutôt de deux représentations du même menu.
Mais pour beaucoup d'API publiques, cette simplicité vaut largement le compromis.
Deuxième méthode : glisser la version dans la commande
Une autre solution consiste à garder une même entrée dans la taverne, mais à préciser la version au moment de commander.
GET /taverne/versioning/parameter/menu?version=1
GET /taverne/versioning/parameter/menu?version=2
Ici, la version devient un query parameter :
@GET
@Path("/parameter/menu")
@Operation(
summary = "Lire le menu via un query parameter de version",
description = "Selectionne le contrat V1 ou V2 avec le parametre version. La V1 est utilisee par defaut."
)
@APIResponses({
@APIResponse(
responseCode = "200",
description = "Menu retourne avec succes.",
content = @Content(schema = @Schema(oneOf = {MenuV1Response.class, MenuV2Response.class}))
),
@APIResponse(
responseCode = "400",
description = "Version demandee inconnue.",
content = @Content(
mediaType = UnknownMenuVersionExceptionMapper.PROBLEM_JSON,
schema = @Schema(implementation = ProblemDetailsResponse.class)
)
)
})
public Response menuByParameter(
@Parameter(description = "Version du contrat de menu.", example = "2")
@QueryParam("version")
@DefaultValue("1")
String version
) {
return menuForVersion(version);
}
La route reste stable, et le client choisit sa carte avec ?version=2.
Cette approche est pratique en démonstration, en test, ou pour des APIs internes. Elle se lit vite et se manipule facilement avec un navigateur.
Mais elle a un défaut de narration HTTP : un query parameter ressemble souvent à un filtre ou à une option de recherche. Or ici, il ne filtre pas le menu. Il change le contrat de représentation.
Dans la taverne, c'est comme écrire "version 2" en petit dans la marge de la commande. Ça marche, mais ce n'est pas toujours l'endroit le plus noble pour une information aussi structurante.
Troisième méthode : murmurer la version au tavernier
Le header HTTP est plus discret.
L'URL reste la même :
GET /taverne/versioning/header/menu
X-Api-Version: 2
L'aventurier ne change pas de porte. Il glisse simplement une consigne au tavernier : "je veux la carte de la guilde".
En Quarkus :
@GET
@Path("/header/menu")
@Operation(
summary = "Lire le menu via un header de version",
description = "Selectionne le contrat V1 ou V2 avec le header X-Api-Version. La V1 est utilisee par defaut."
)
@APIResponses({
@APIResponse(
responseCode = "200",
description = "Menu retourne avec succes.",
content = @Content(schema = @Schema(oneOf = {MenuV1Response.class, MenuV2Response.class}))
),
@APIResponse(
responseCode = "400",
description = "Version demandee inconnue.",
content = @Content(
mediaType = UnknownMenuVersionExceptionMapper.PROBLEM_JSON,
schema = @Schema(implementation = ProblemDetailsResponse.class)
)
)
})
public Response menuByHeader(
@Parameter(description = "Version du contrat de menu.", example = "2")
@HeaderParam(API_VERSION_HEADER)
@DefaultValue("1")
String version
) {
return menuForVersion(version);
}
Cette stratégie garde des URLs propres.
Elle sépare mieux l'identité de la ressource et la variante du contrat.
Elle convient bien lorsque les clients sont maîtrisés : applications internes, partenaires bien documentés, SDK officiel, gateway qui impose déjà des headers.
Mais elle demande de la discipline.
Un header oublié, mal transmis par un proxy, ou non visible dans un simple copier-coller d'URL peut rendre le debug plus agaçant.
Le tavernier comprend très bien les consignes murmurées. Encore faut-il que tout le monde pense à les murmurer.
Quatrième méthode : demander une représentation précise
La dernière approche est la plus proche de l'esprit RESTful.
L'aventurier ne dit pas "je veux la version 2".
Il dit : "je veux la représentation application/vnd.tavern.menu.v2+json du menu".
GET /taverne/versioning/negotiation/menu
Accept: application/vnd.tavern.menu.v2+json
Ou pour l'ancienne carte :
GET /taverne/versioning/negotiation/menu
Accept: application/vnd.tavern.menu.v1+json
Ici, la version est portée par le media type.
Avec Jakarta REST, Quarkus sait naturellement sélectionner la bonne méthode grâce à @Produces :
@GET
@Path("/negotiation/menu")
@Produces(MENU_V1_MEDIA_TYPE)
@Operation(
summary = "Lire le menu V1 via content negotiation",
description = "Retourne la representation V1 lorsque le client demande le media type vendor V1."
)
@APIResponse(
responseCode = "200",
description = "Representation V1 retournee avec succes.",
content = @Content(
mediaType = MENU_V1_MEDIA_TYPE,
schema = @Schema(implementation = MenuV1Response.class)
)
)
public Response menuByMediaTypeV1() {
return Response.ok(tavernMenuService.serveHistoricalMenu(), MENU_V1_MEDIA_TYPE).build();
}
Et la V2 :
@GET
@Path("/negotiation/menu")
@Produces(MENU_V2_MEDIA_TYPE)
@Operation(
summary = "Lire le menu V2 via content negotiation",
description = "Retourne la representation V2 lorsque le client demande le media type vendor V2."
)
@APIResponse(
responseCode = "200",
description = "Representation V2 retournee avec succes.",
content = @Content(
mediaType = MENU_V2_MEDIA_TYPE,
schema = @Schema(implementation = MenuV2Response.class)
)
)
public Response menuByMediaTypeV2() {
return Response.ok(tavernMenuService.serveGuildMenu(), MENU_V2_MEDIA_TYPE).build();
}
C'est le cas où Quarkus se rapproche le plus d'un routage automatique par version : deux méthodes, même path, mais deux media types produits différents.
La taverne ne change pas d'adresse.
Le client demande simplement la carte dans le format exact qu'il sait lire.
C'est élégant, très HTTP, et conceptuellement solide.
Mais c'est aussi plus exigeant : tous les clients ne sont pas à l'aise avec les media types vendor du type application/vnd.tavern.menu.v2+json.
Et le version de Spring ?
Dans les versions récentes de Spring, on peut écrire quelque chose de très confortable :
@GetMapping(path = "/user", version = "4")
public UserV4 getUserV4() {
// ...
}
@GetMapping(path = "/user", version = "5")
public UserV5 getUserV5() {
// ...
}
Spring porte alors directement la notion de version dans le mapping.
Quarkus, via Jakarta REST, ne propose pas aujourd'hui d'attribut équivalent sur @GET ou @Path.
Dans notre taverne Quarkus, les options standard restent donc :
@Pathpour afficher la version dans l'URL,@QueryParampour lire une version en paramètre,@HeaderParampour lire une version dans un header,@Producespour router selon le media type demandé.
La content negotiation est l'approche la plus proche du double mapping Spring, car Quarkus peut sélectionner deux méthodes ayant le même path selon le header Accept.
Quand un aventurier demande un grimoire inconnu
Un soir, un aventurier entre et demande la version dragon.
La taverne ne connaît que les versions 1 et 2.
Elle ne doit pas répondre avec un vague "ça ne marche pas", ni laisser une exception technique fuiter jusqu'au client.
On crée donc une exception métier dédiée, dans le même esprit que l'industrialisation des erreurs avec Quarkus :
public class UnknownMenuVersionException extends RuntimeException {
private final String requestedVersion;
public UnknownMenuVersionException(String requestedVersion) {
super("Version de menu inconnue: " + requestedVersion);
this.requestedVersion = requestedVersion;
}
public String requestedVersion() {
return requestedVersion;
}
}
Puis un ExceptionMapper transforme cette erreur en réponse HTTP standardisée :
@Provider
public class UnknownMenuVersionExceptionMapper implements ExceptionMapper<UnknownMenuVersionException> {
public static final String PROBLEM_JSON = "application/problem+json";
@Override
public Response toResponse(UnknownMenuVersionException exception) {
return Response.status(Response.Status.BAD_REQUEST)
.type(PROBLEM_JSON)
.entity(new ProblemDetailsResponse(
"https://eletutour.fr/problems/api-version-unknown",
"Version d'API inconnue",
Response.Status.BAD_REQUEST.getStatusCode(),
"Le grimoire d'API ne connait que les versions 1 et 2.",
"VERSION_INCONNUE"
))
.build();
}
}
La réponse devient :
{
"type": "https://eletutour.fr/problems/api-version-unknown",
"title": "Version d'API inconnue",
"status": 400,
"detail": "Le grimoire d'API ne connait que les versions 1 et 2.",
"code": "VERSION_INCONNUE"
}
C'est le panneau d'erreur propre à l'entrée de la taverne.
L'humain comprend ce qui s'est passé.
Le client automatisé peut se baser sur code.
Et le contrôleur reste lisible, parce que la traduction exception vers HTTP est isolée.
Swagger UI : afficher clairement les cartes
Une taverne qui possède plusieurs cartes doit les afficher proprement.
Côté API, cela signifie documenter les routes, les réponses possibles, les media types, et les erreurs. C'est le prolongement naturel de la mise en place d'OpenAPI et Swagger UI dans Quarkus.
Dans les endpoints qui peuvent retourner V1 ou V2, l'annotation OpenAPI utilise oneOf :
@APIResponse(
responseCode = "200",
description = "Menu retourne avec succes.",
content = @Content(schema = @Schema(oneOf = {MenuV1Response.class, MenuV2Response.class}))
)
L'idée est simple : le client sait qu'une réponse 200 peut correspondre à l'un des deux contrats.
Pour les erreurs :
@APIResponse(
responseCode = "400",
description = "Version demandee inconnue.",
content = @Content(
mediaType = UnknownMenuVersionExceptionMapper.PROBLEM_JSON,
schema = @Schema(implementation = ProblemDetailsResponse.class)
)
)
La documentation ne sert pas seulement à faire joli dans /q/swagger-ui.
Elle est le menu accroché au mur. Si elle est floue, les clients commandent de travers.
Tester les quatre comptoirs
Une taverne peut avoir la plus belle carte du royaume : si personne ne vérifie que le tavernier sert le bon plat, l'histoire finit mal.
Les tests d'intégration Quarkus couvrent les quatre stratégies.
Pour le path :
RestAssured.given()
.when().get("/taverne/versioning/path/v1/menu")
.then()
.statusCode(200)
.body("plat", equalTo("Ragout de sanglier"))
.body("prixPiecesCuivre", equalTo(12));
Pour le query parameter :
RestAssured.given()
.queryParam("version", "2")
.when().get("/taverne/versioning/parameter/menu")
.then()
.statusCode(200)
.body("ingredients", hasItem("thym de druide"));
Pour le header :
RestAssured.given()
.header(API_VERSION_HEADER, "1")
.when().get("/taverne/versioning/header/menu")
.then()
.statusCode(200)
.body("plat", equalTo("Ragout de sanglier"));
Pour le media type :
RestAssured.given()
.accept(MENU_V2_MEDIA_TYPE)
.when().get("/taverne/versioning/negotiation/menu")
.then()
.statusCode(200)
.contentType(startsWith(MENU_V2_MEDIA_TYPE))
.body("disponiblePour", equalTo("aventuriers niveau 2 et plus"));
Et on teste aussi le cas de l'aventurier qui demande un grimoire inconnu :
RestAssured.given()
.queryParam("version", "dragon")
.when().get("/taverne/versioning/parameter/menu")
.then()
.statusCode(400)
.contentType(startsWith(PROBLEM_JSON))
.body("code", equalTo("VERSION_INCONNUE"));
Ces tests ne vérifient pas seulement que le code compile.
Ils vérifient que chaque comptoir sert bien la carte promise.
Quelle stratégie choisir pour votre taverne ?
Le versioning d'API n'a pas de réponse universelle. Chaque taverne a ses clients, ses contraintes, ses habitudes, ses routes commerciales.
| Approche | Image dans la taverne | Avantages | Limites |
|---|---|---|---|
| Path | Deux portes différentes | Simple, visible, facile à router | Version très présente dans l'URL |
| Query parameter | Une note sur la commande | Facile à tester, URL stable | Moins clair pour un contrat |
| Header | Une consigne murmurée au tavernier | URL propre, metadata dédiée | Moins visible, debug parfois moins direct |
| Media type | Une carte demandée dans un format précis | Très REST, routage naturel via @Produces |
Plus exigeant côté client |
Si vous exposez une API publique et que vous voulez quelque chose de simple à comprendre, le path versioning reste souvent le choix le plus pragmatique.
Si vos clients sont maîtrisés, le header versioning peut garder des URLs propres.
Si vous voulez coller davantage à l'esprit HTTP, la content negotiation est une option élégante.
Et si vous êtes en phase de migration ou de démonstration, le query parameter peut rendre service.
Le choix ressemble finalement à celui que l'on fait pour d'autres évolutions de contrat : dans La Taverne Polyglotte, le header Accept-Language sert à adapter la langue sans changer la ressource ; ici, Accept, X-Api-Version, le path ou le query parameter servent à adapter la représentation du menu.
Conclusion
Versionner une API, ce n'est pas seulement ajouter /v2 quelque part.
C'est accepter qu'un contrat public ait une histoire.
Des clients l'ont appris, intégré, automatisé, parfois détourné. Le changer brutalement revient à remplacer la carte de la taverne pendant que les aventuriers sont encore en train de commander.
Avec Quarkus, les briques standard suffisent pour couvrir un large spectre :
@Pathpour la version dans l'URL,@QueryParampour la version en paramètre,@HeaderParampour la version en header,@Producespour la négociation de représentation.
Le plus important reste la discipline autour de ces briques :
- des DTOs explicites,
- des contrats documentés,
- un service qui porte le choix métier,
- une erreur standardisée quand la version n'existe pas,
- des tests qui prouvent que chaque carte sert bien le bon menu.
Ainsi, les vieux aventuriers peuvent continuer à commander leur ragoût historique, pendant que la guilde consulte la nouvelle carte enrichie.
La taverne évolue.
Mais personne ne se retrouve avec un plat qu'il ne sait pas lire.
Tout le code relatif à cet article est consultable ici