Écrire 400 lignes de YAML, qui aime ça ? Eh bien certainement pas moi! C'est un enfer à écrire et une purge à relire. Et comme à chaque fois dans ce genre de situation, il faut que je trouve une solution.
Code first
La plupart du temps lorsqu'on met en place des endpoints REST, on le fait pour un usage interne. Il y a donc rarement de la documentation sur ces endpoints. Lorsqu'on fournit ces endpoints pour une autre équipe, il arrive qu'on s'accorde sur un contrat d'interface, mais c'est rare et souvent on va simplement exposer un endpoint qui ne fait rien. Pour les endpoints publics, on va finir par publier une documentation technique pour les intégrateurs. Souvent ce sera fait après avoir implémenté les endpoints. Ce que j'ai beaucoup vu, c'est d'utiliser une lib comme Swagger. On annote les endpoints et la documentation est générée automatiquement. C'est ce qu'on appelle du code first.
Voici un exemple de ce qu'on peut faire en code first:
@RestController
@RequestMapping("/api/v1/products")
@Tag(name = "Products", description = "Product catalog API")
public class ProductController {
private final ConcurrentHashMap<Long, ProductResponse> products = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong(0);
public ProductController() {
long id = sequence.incrementAndGet();
products.put(id, new ProductResponse(id, "Dell XPS 15 Laptop", new BigDecimal("1299.99")));
}
@Operation(summary = "Get a product by id", description = "Returns the details of a single product")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Product found",
content = @Content(schema = @Schema(implementation = ProductResponse.class))
),
@ApiResponse(
responseCode = "404",
description = "Product not found",
content = @Content
)
})
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProductById(
@Parameter(description = "Product id", required = true, example = "42")
@PathVariable Long id) {
return Optional.ofNullable(products.get(id))
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@Schema(description = "A product")
public record ProductResponse(
@Schema(description = "Product id", example = "42")
Long id,
@Schema(description = "Product name", example = "Dell XPS 15 Laptop")
String name,
@Schema(description = "Unit price", example = "1299.99")
BigDecimal price
) {}
}
Et voici ce qu'on obtient en générant la documentation:
openapi: 3.0.1
info:
title: OpenAPI definition
version: v0
servers:
- url: http://localhost:8099
description: Generated server url
tags:
- name: Products
description: Product catalog API
paths:
/api/v1/products/{id}:
get:
tags:
- Products
summary: Get a product by id
description: Returns the details of a single product
operationId: getProductById
parameters:
- name: id
in: path
description: Product id
required: true
schema:
type: integer
format: int64
example: 42
responses:
"404":
description: Product not found
"200":
description: Product found
content:
'*/*':
schema:
$ref: '#/components/schemas/ProductResponse'
components:
schemas:
ProductResponse:
type: object
properties:
id:
type: integer
description: Product id
format: int64
example: 42
name:
type: string
description: Product name
example: Dell XPS 15 Laptop
price:
type: number
description: Unit price
example: 1299.99
description: A product
Ça fonctionne bien et c'est assez pratique. Maintenant je peux utiliser ce document OpenAPI où je veux. Soit dans un outil tiers (quasiment tous prennent en charge ce format), soit dans un Swagger UI. La documentation est à jour avec le code. Je peux envoyer ce document aux personnes qui vont intégrer mes endpoints. Ils pourraient ainsi commencer à les intégrer sans que mes endpoints soient en production. En revanche, si je fais des changements, je devrais leur envoyer la nouvelle version de ce document.
Le code first, c'est vraiment la méthode la plus pratique et confortable. La plupart du temps c'est sans doute la méthode la plus simple et la plus utilisée. Cependant elle requiert d'implémenter l'endpoint pour générer la documentation. Alors oui, on peut écrire des endpoints vides qui ne font rien et ainsi générer la documentation pour finir ensuite l'implémentation. En revanche, là où c'est peu pratique, c'est si on souhaite avoir des échanges sur les contrats d'interfaces avec plusieurs utilisateurs. Que ce soit une autre équipe ou des intégrateurs extérieurs. Et c'est la raison pour laquelle il existe l'autre approche : le contract first.
Contract first
C'est la méthode inverse du code first : on part des specifications (du document OpenAPI donc) et on génère le code qui correspond. L'intérêt de faire ça, je l'ai déjà évoqué un peu avant, c'est qu'il permet d'échanger sur le contrat d'interface AVANT d'engager du temps et de l'effort sur l'implémentation de l'endpoint. Le YAML, bien que je n'aime pas spécialement ce format, est "lisible" par des non devs. Un Product Owner peut le relire et y trouver des erreurs ou incohérences. L'autre intérêt est qu'on peut paralléliser la création des endpoints et ses usages. On crée la documentation, et une fois validée, on l'envoie aux équipes en charge de l'intégrer. Chacun peut donc travailler sans avoir besoin de l'autre. Un autre intérêt est qu'on peut mettre en place des tests automatisés qui vérifient que l'implémentation est bien conforme aux specs, contrairement au code first où si on change le code, les specs changent.
Et donc pour cette méthode il suffit d'écrire et valider le document OpenAPI, et ensuite générer le code. Je ne détaillerai pas ici comment faire, je vous laisse regarder cet article qui le présente très bien: API First Development with Spring Boot and OpenAPI 3.0.
Toujours pas pleinement satisfaisant
Le principal problème avec le contract first, c'est qu'il nous demande d'écrire du YAML. Alors, pour un endpoint comme celui que j'ai donné en exemple, ça va. Mais quand on commence à en avoir beaucoup, qu'on a des références un peu partout, il faut le maintenir. Je trouve que le temps gagné pour paralléliser le travail de plusieurs équipes est complètement perdu lors de l'écriture et la relecture du fameux document OpenAPI. D'autant plus que tout doit être inclus dans ce document. Les exemples de requêtes, de réponses, d'erreurs etc.
Démarche et POC
Pour les raisons que j'ai évoquées, chaque approche a ses avantages et inconvénients. Chacune répond à des besoins différents. Je pense qu'en dehors de dépendances avec d'autres équipes ou d'endpoints destinés à des intégrateurs externes, il faut privilégier le code first.
Alors je sais que certains PO vont utiliser des outils en ligne pour générer du YAML à partir d'une interface graphique. Toutefois ils ne répondent pas à tous les soucis évoqués et créent une nouvelle dépendance envers un outil tiers.
Ce que je me suis dit, c'est que l'approche contract first est, dans les cas où elle est pertinente, la bonne, mais il faut supprimer les frictions. Notamment en ce qui concerne la génération du document OpenAPI. Et donc pourquoi ne pas faire générer la documentation par du code mais seulement pour la partie contrat ? On ne fait pas de réelle implémentation, juste ce qu'il faut pour générer le document et le transmettre aux personnes concernées. L'idée serait donc non pas d'écrire un document OpenAPI en YAML mais d'écrire du code Java pour la faire générer. On pourrait donc écrire uniquement les DTOs (objets requests/responses et tout ce dont on a besoin pour les décrire) et les interfaces qui décriraient les endpoints.
Le souci, c'est que pour faire générer la doc il faut avoir des controllers REST et pas uniquement des interfaces... Je n'ai rien trouvé sur internet pour résoudre ce problème. Je me suis donc dit qu'au lieu d'implémenter les interfaces avec des controllers, j'allais faire un truc qui les implémente à la volée. Après tout ces pseudo-contrôleurs juste pour implémenter une interface et qui ne retournent rien, ça ne doit pas être compliqué à faire.
J'ai donc réalisé un POC qu'on peut trouver ici: Yagni/spec-from-skeleton
Alors comment ça fonctionne ? Je ne détaillerai pas le projet entier, vous pouvez aller le voir si ça vous intéresse. Pour la partie "magique" tout est dans la classe ApiStubAutoConfiguration qui se trouve ici. C'est un BeanDefinitionRegistryPostProcessor qui, comme son nom l'indique, démarre assez tôt dans le startup Spring, surtout avant que le context n'ait besoin des controllers. Et voici ce qu'il fait:
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
log.info("🔍 Scanning '{}' for @RequestMapping API interfaces...", CONTRACT_PACKAGE);
Set<Class<?>> apiInterfaces = findApiInterfaces();
log.info("✅ Found {} API interface(s) to register as stub controllers", apiInterfaces.size());
for (Class<?> apiInterface : apiInterfaces) {
registerStubController(registry, apiInterface);
}
}
Il va trouver toutes les interfaces déclarées et pour chacune il va créer et enregistrer le bean en question. Ainsi, pour l'application Spring Boot, le controller existe vraiment.
Concernant la génération du bean au démarrage, j'utilise ByteBuddy et je le fais à partir de l'interface:
private Class<?> createStubControllerClass(Class<?> apiInterface) {
String className = apiInterface.getSimpleName() + "StubImpl";
try (DynamicType.Unloaded<?> unloaded = new ByteBuddy()
.subclass(Object.class)
.implement(apiInterface)
.name(apiInterface.getPackage().getName() + ".generated." + className)
.annotateType(AnnotationDescription.Builder.ofType(RestController.class).build())
.annotateType(Arrays.stream(apiInterface.getAnnotations())
.map(AnnotationDescription.ForLoadedAnnotation::of)
.toArray(AnnotationDescription[]::new))
.method(ElementMatchers.any())
.intercept(InvocationHandlerAdapter.of(new StubMethodHandler()))
.make()) {
return unloaded.load(apiInterface.getClassLoader()).getLoaded();
}
}
On ne fait que créer une classe qui implémente l'interface. On lui ajoute l'annotation @RestController pour qu'elle soit bien détectée par Spring, et on lui fait hériter toutes les annotations de l'interface.
J'ai également créé le script generate-spec.sh pour générer la documentation sans devoir tout lancer à la main.
Le nouveau workflow
Ok donc le POC nous montre qu'on peut générer la documentation à partir uniquement d'interface et de DTOs. Cela ressemble beaucoup au code first à ceci près qu'on n'a pas écrit le code métier. Si on met de côté le module spec-generator (il s'agit de l'outil, on ne le crée qu'une fois), le code utilisé pour générer la documentation se trouve dans le module contract. Il ne contient que des interfaces ou DTOs (request et response principalement). On obtient donc un module qui servira de contrat d'interface entre les équipes. On peut éventuellement décider de rendre ce module public aux intégrateurs extérieurs, mais c'est un autre débat.
Ce module pourra donc être directement utilisé dans les autres projets aussi bien clients que ceux qui implémenteront réellement les interfaces. C'est une vraie différence avec le code first tel que je le décrivais plus haut : là où je devais renvoyer manuellement une nouvelle version du document OpenAPI à chaque changement, ici le contrat est une dépendance versionnée comme une autre. Un mvn dependency:update (ou équivalent) suffit pour qu'une équipe consommatrice récupère la dernière version du contrat.
Il est maintenant également le support d'échange lors de la création du contrat entre les devs et le Product Owner. Si on reprend l'exemple présent dans mon POC, en voici un extrait :
@Tag(name = "Products", description = "Product catalog API")
@RequestMapping("/api/v1/products")
public interface ProductApi {
@Operation(
summary = "List products",
description = "Returns a paginated list of all products"
)
@ApiResponse(
responseCode = "200",
description = "Product list",
content = @Content(schema = @Schema(implementation = ProductListResponse.class))
)
@GetMapping
ResponseEntity<ProductListResponse> listProducts(
@Parameter(description = "Page number (zero-based)", example = "0")
@RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size", example = "10")
@RequestParam(defaultValue = "10") int size,
@Parameter(description = "Sort criterion", example = "name")
@RequestParam(defaultValue = "name") String sort
);
}
Et son implémentation dans l'exemple fourni dans le POC:
@RestController
public class ProductController implements ProductApi {
// class fields && constructor here...
@Override
public ResponseEntity<ProductListResponse> listProducts(int page, int size, String sort) {
return ResponseEntity.ok(paginate(List.copyOf(products.values()), page, size));
}
}
Ça a un intérêt qu'on n'a pas dans le contract-first pur : ici l'implémentation dépend du contrat au sens propre du terme. Si demain on modifie l'interface ProductApi (un paramètre en plus, un type de retour différent), ProductController ne compile plus tant qu'il n'est pas mis à jour en conséquence. On obtient donc, sans rien écrire de plus, l'équivalent des tests de conformité contrat/implémentation que je citais plus haut comme un des atouts du contract-first classique, sauf que là c'est le compilateur qui s'en charge.
On peut voir également dans l'interface UserApi qu'on peut découper les différents endpoints en catégories, comme ici avec UserReadOperations et UserWriteOperations. C'est une proposition de découpage, mais on peut faire comme on veut. L'intérêt de découper est qu'on peut ensuite implémenter tous les endpoints ou seulement une partie si besoin. Sachant qu'on n'est pas obligé d'implémenter ces interfaces directement tant qu'on crée les endpoints à la fin, mais je trouve plus propre et sécurisant de faire comme ça.
Un avantage secondaire est qu'on sépare la documentation de l'implémentation, ce qui permet d'alléger grandement le RestController.
Avec les interfaces et les DTOs associés, on peut co-construire entre le dev et le PO le contrat d'interface. Une fois le contrat validé, on peut merger la Pull Request dans la branche principale et avoir le contrat disponible pour tout le monde. Pour ce qui est de publier la documentation aux intégrateurs externes, on pourra se reposer sur la CI afin de générer la documentation après tout nouveau commit. Il pourra ensuite être partagé, voire même publié directement dans la documentation publique (que ce soit automatiquement ou non).
On peut d'ailleurs pousser cette logique plus loin : la même mécanique CI peut tourner sur une branche en cours de travail, pas seulement après merge. Ça permet de déployer un environnement éphémère avec le Swagger UI à jour, accessible via un lien dans la Pull Request. Le PO peut donc relire et valider le contrat sur un rendu Swagger UI classique, exactement comme il le ferait avec du contract-first pur, sans qu'aucune ligne de code métier n'ait été écrite.
Limites ?
Ce nouveau workflow apporte pas mal d'avantages, mais il n'est pas exempt de défauts ou limitations. Voici celles que j'ai identifiées:
- Dépendance à ByteBuddy. Encore une dépendance à surveiller et mettre à jour...
- Couplage fort à l'écosystème Java/Spring. Je travaille principalement dans cet écosystème, mais j'imagine qu'on pourrait trouver une solution similaire pour les autres.
- L'implémentation de l'interface est optionnelle. Il pourra donc subsister un risque de divergence entre les specs et l'implémentation réelle. Mais peut-on vraiment empêcher les humains de faire n'importe quoi ?
- Coût de complexité pour la CI. Pour vraiment tirer parti de tout l'intérêt de ce nouveau workflow, il faut mettre en place des nouveaux pipelines pour générer et intégrer la documentation. Il faudra bien sûr la maintenir.
- Coût de maintenance du module
spec-generator. Et oui on n'oublie pas les mises à jour de sécurité! - Regrouper les APIs de plusieurs équipes dans un seul module
contractpose question : un changement cassant sur une seule API forcerait une montée de version majeure pour tout le monde, même les équipes non concernées.
Mais surtout, contrairement à ma "promesse" initiale, on n'écrit pas moins de descriptions qu'en YAML : les annotations Java restent verbeuses. Le vrai gain est ailleurs : autocomplétion IDE, refactoring sûr, erreurs détectées à la compilation et un artefact réutilisable comme dépendance plutôt qu'un simple document.
Prochaines pistes
Ce projet reste un POC, et il y a plusieurs pistes qui mériteraient d'être creusées. Découper le module contract en sous-modules par équipe ou bounded context permettrait de limiter le rayon d'impact des changements cassants évoqués plus haut. Et même si je reste concentré sur l'écosystème Spring pour l'instant, je serais curieux de voir si un mécanisme équivalent pourrait s'appliquer à d'autres frameworks. On pourrait aussi packager spec-generator comme un vrai starter réutilisable, amortissant ainsi le coût de mise en oeuvre de l'outil.