Concepts API essentiels : explication détaillée
1. Vue d’ensemble
Appeler une API, c’est facile — envoyer une requête, lire du JSON, c’est tout. Mais concevoir une API que d’autres équipes, ou même vous-même 6 mois plus tard, peuvent encore utiliser sans avoir à lire le code pour deviner le comportement — c’est une autre histoire. Cet article résume les concepts API que tout ingénieur backend devrait maîtriser : des fondamentaux HTTP, aux styles d’architecture (REST, GraphQL, gRPC, webhook, WebSocket), en passant par la conception durable, la sécurité, la fiabilité et la documentation. Après la lecture, vous aurez une checklist concrète pour évaluer votre API avant de la rendre publique.
2. Fondamentaux HTTP : method, status code, request/response
Avant de parler de REST ou GraphQL, vous devez utiliser correctement la sémantique HTTP de base. C’est ici que beaucoup d’API « ressemblent à REST » mais sont en réalité complètement fausses.
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
ResponseEntity<Order> getOrder(@PathVariable Long id) {
return orderService.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
Order created = orderService.create(request);
return ResponseEntity.created(URI.create("/api/orders/" + created.id())).body(created);
}
@PutMapping("/{id}")
ResponseEntity<Order> replaceOrder(@PathVariable Long id, @RequestBody Order order) {
return ResponseEntity.ok(orderService.replace(id, order));
}
@DeleteMapping("/{id}")
ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
orderService.cancel(id);
return ResponseEntity.noContent().build();
}
}
Voici quelques pièges courants :
GETdoit être safe (ne pas modifier l’état) et idempotent. Si votre endpointGETincrémente un compteur ou écrit des logs importants, vous violez le contrat implicite avec le client (et avec les proxies/CDN).PUTremplace la ressource entière,PATCHne met à jour que les parties modifiées. UtiliserPUTpour mettre à jour un seul champ est sémantiquement incorrect, même si le code fonctionne.- Les codes de statut doivent refléter correctement le sens :
201 Createdà la création (avec l’en-têteLocation),204 No Contentlors d’une suppression réussie sans corps de réponse,409 Conflicten cas de conflit d’état — pas juste400ou500pour toute erreur.
Remarque : Retourner
200 OKpour chaque réponse avec un champ"success": falsedans le corps est un anti-pattern courant. Le client, le load balancer et les outils d’APM se fient au code de statut pour décider d’une nouvelle tentative ou d’une alerte — masquer l’erreur dans le corps casse toute la chaîne.
3. Choisir le style d’API : REST, GraphQL, gRPC, webhook, WebSocket
Chaque style d’architecture résout un problème différent. Faire un mauvais choix n’est pas une erreur technique — c’est mal comprendre le cas d’usage.
3.1. REST — par défaut pour CRUD via HTTP
Convient quand les ressources sont claires (Order, Customer, Invoice) et les clients divers (web, mobile, tiers). L’exemple de la section 2 est un REST standard.
3.2. GraphQL — quand le client a besoin de flexibilité sur les champs
type Order {
id: ID!
customer: Customer!
total: Float!
items: [OrderItem!]!
}
type Query {
order(id: ID!): Order
}
@Controller
class OrderGraphQlController {
private final OrderService orderService;
OrderGraphQlController(OrderService orderService) {
this.orderService = orderService;
}
@QueryMapping
Order order(@Argument Long id) {
return orderService.findById(id).orElse(null);
}
}
Le client choisit lui-même les champs dont il a besoin ({ order(id: 1) { total } } au lieu d’appeler 3 endpoints REST). En retour, vous perdez la mise en cache HTTP basée sur l’URL et devez gérer vous-même les requêtes N+1 (en utilisant DataLoader). GraphQL convient aux applications avec une UI complexe et plusieurs clients ayant besoin de données différentes — pas aux API simples avec peu de clients.
3.3. gRPC — quand vous avez besoin de haute performance entre services internes
service PaymentService {
rpc Charge (ChargeRequest) returns (ChargeResponse);
}
message ChargeRequest {
string order_id = 1;
int64 amount_cents = 2;
}
gRPC utilise HTTP/2 + Protobuf (binaire, plus compact que JSON), avec support du streaming bidirectionnel. Excellent pour la communication service-to-service en microservices, mais difficile à déboguer manuellement (impossible à lire avec curl classique) et inadapté aux API publiques pour les navigateurs.
3.4. Webhook — quand le destinataire doit être notifié de manière proactive
@PostMapping("/webhooks/payment-status")
ResponseEntity<Void> handlePaymentWebhook(@RequestBody PaymentStatusEvent event,
@RequestHeader("X-Signature") String signature) {
if (!signatureValidator.isValid(event, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
paymentEventHandler.handle(event);
return ResponseEntity.ok().build();
}
Le webhook inverse le sens de l’appel : le serveur de PaymentService appelle votre serveur quand un événement se produit. Vérifiez toujours la signature (HMAC) pour éviter les fausses requêtes, et retournez toujours 200 rapidement, puis traitez de manière asynchrone — l’expéditeur réessaie généralement en cas de délai d’attente ou d’erreur.
3.5. WebSocket — quand vous avez besoin de communication bidirectionnelle en temps réel
Utilisé pour le chat, le suivi en direct, les tableaux de bord à mise à jour continue. Inadapté aux simples requête-réponse car maintenir une connexion ouverte consomme plus de ressources que REST.
4. Conception durable : nommage, pagination, versioning, gestion des erreurs
4.1. Nommage cohérent
Utilisez le pluriel pour les collections (/orders, pas /order ou /getOrders), des noms imbriqués pour les relations (/customers/{id}/orders). Évitez de mélanger camelCase et snake_case dans la même API.
4.2. Pagination
@GetMapping
Page<Order> listOrders(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return orderService.findAll(PageRequest.of(page, size));
}
GET /api/orders?page=1&size=20
Offset-based (page/size) est facile à comprendre mais lent avec les grandes tables et sujet aux doublons/omissions lors d’insertions/suppressions entre les requêtes. Cursor-based (?after=order_1234) est plus stable avec les gros ensembles de données, mais plus complexe à implémenter pour « aller à la page 10 ».
4.3. Versioning
@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { /* ... */ }
Le versioning via le chemin URI est facile à lire et à router via une passerelle. Le versioning via en-tête (Accept: application/vnd.myapp.v2+json) est plus propre sur le plan de l’URL mais difficile à déboguer dans un navigateur. Peu importe le choix, tant que vous avez une stratégie de dépréciation claire : annoncer à l’avance, maintenir l’ancienne version suffisamment longtemps, renvoyer les en-têtes Deprecation et Sunset.
4.4. Réponses d’erreur standardisées
@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ProblemDetail> handleNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setProperty("orderId", ex.getOrderId());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Order 99 not found",
"orderId": 99
}
ProblemDetail (supporté nativement par Spring Boot 3.x, selon RFC 7807) produit un format d’erreur uniforme dans tout le système — le client n’a besoin de parser qu’un seul schéma, sans avoir à deviner quel champ contient le message d’erreur.
5. Sécurité : API key, OAuth2, JWT, scope
5.1. API key — simple, adapté pour service-to-service avec faible confiance et faible risque
@Component
class ApiKeyFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String apiKey = req.getHeader("X-Api-Key");
if (apiKey == null || !apiKeyRepository.isValid(apiKey)) {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
return;
}
chain.doFilter(req, res);
}
}
La clé API ne peut pas distinguer qui appelle au nom de qui — elle authentifie seulement l’app, pas l’utilisateur. Ne l’utilisez pas pour protéger les données personnelles des utilisateurs finaux.
5.2. OAuth2 + JWT — quand vous avez besoin d’une autorisation granulaire par scope
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.nonladev.com
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
return ResponseEntity.ok(orderService.create(request));
}
JWT porte les claims (user id, scope, exp) et peut être vérifié via une clé publique sans questionner le serveur d’auth à chaque requête — c’est pourquoi il scale mieux que les sessions traditionnelles. Mais JWT ne peut pas être révoqué facilement avant son expiration : si un utilisateur est bloqué, l’ancien token reste valide jusqu’à exp. Solution courante : garder exp court (5–15 min) avec un refresh token, ou vérifier une blacklist au niveau de la passerelle.
Remarque : Le scope dit uniquement « ce que ce token peut faire », il ne remplace pas la vérification des règles métier (par exemple : l’utilisateur A a
orders:writemais n’a pas le droit de modifier la commande de l’utilisateur B). Vérifiez toujours la propriété au niveau du service.
6. Fiabilité : timeout, retry, idempotence, rate limit, cache
6.1. Timeout et retry
@Bean
RestClient paymentClient() {
return RestClient.builder()
.baseUrl("https://payment.internal")
.requestFactory(ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(2))
.withReadTimeout(Duration.ofSeconds(3))
.buildRequestFactory())
.build();
}
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
@Retry(name = "paymentService", fallbackMethod = "chargeFallback")
Charge charge(ChargeRequest request) {
return paymentClient.post().uri("/charge").body(request).retrieve().body(Charge.class);
}
Ne pas définir de timeout = les threads restent bloqués indéfiniment si le service aval échoue, ce qui peut faire tomber tout votre service par effet domino. Faire des retry sans soin peut causer un orage de requêtes qui fait s’effondrer le service aval encore plus — associez toujours les retry avec exponential backoff et circuit breaker.
6.2. Clé d’idempotence pour les opérations de paiement
@PostMapping("/charges")
ResponseEntity<Charge> charge(@RequestHeader("Idempotency-Key") String key,
@RequestBody ChargeRequest request) {
return idempotencyStore.findByKey(key)
.map(existing -> ResponseEntity.ok(existing))
.orElseGet(() -> {
Charge charge = paymentService.charge(request);
idempotencyStore.save(key, charge);
return ResponseEntity.ok(charge);
});
}
Si le client renvoie la même requête (à cause d’un timeout ou d’une perturbation réseau) avec la même Idempotency-Key, il recevra le même résultat sans double charge. C’est le concept le plus important pour toute API ayant des effets secondaires financiers.
@Test
void shouldReturnSameChargeForDuplicateIdempotencyKey() {
ChargeRequest request = new ChargeRequest("order-1", 10_000L);
Charge first = client.charge("idem-key-1", request);
Charge second = client.charge("idem-key-1", request);
assertThat(second).isEqualTo(first);
assertThat(paymentGateway.chargeCallCount()).isEqualTo(1);
}
6.3. Rate limit et cache
@Bean
Bucket4jConfiguration rateLimitConfig() {
return Bucket4jConfiguration.builder()
.capacity(100)
.refillGreedy(100, Duration.ofMinutes(1))
.build();
}
@Cacheable(value = "orders", key = "#id")
Order findById(Long id) {
return orderRepository.findById(id).orElseThrow();
}
Le rate limit protège le système contre les surcharges d’un client défaillant (accidentel ou intentionnel). Le cache réduit la charge du service aval mais vous devez considérer le TTL et l’invalidation du cache — un cache stale pour les données de commande en cours peut causer une mauvaise compréhension plus grave que pas de cache du tout.
7. Documentation, observabilité et contrats
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
@Operation(summary = "Create a new order", responses = {
@ApiResponse(responseCode = "201", description = "Order created successfully")
})
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) { /* ... */ }
Les spécifications OpenAPI générées automatiquement à partir des annotations aident à éviter que la spec « s’écarte » du code réel — le problème classique quand la doc est écrite à la main puis le code change et on oublie de mettre à jour.
Les tests de contrat (Spring Cloud Contract, Pact) assurent que le consumer et le provider ne rompent pas le contrat lors d’un déploiement indépendant :
@Test
void providerShouldHonorOrderContract() {
Order order = orderService.findById(1L).orElseThrow();
assertThat(order.id()).isEqualTo(1L);
assertThat(order.status()).isIn("PENDING", "PAID", "CANCELLED");
}
Combiné avec l’observabilité (structured logging, trace ID entre services via l’en-tête traceparent, métriques de latence/taux d’erreur par endpoint), votre équipe d’exploitation a suffisamment d’informations pour déboguer en production au lieu de deviner à partir de logs fragmentés.
8. Conclusion
La conception d’API ne consiste pas à choisir le bon framework — elle réside dans une série de petites décisions : quelle méthode, quel code de statut, quel type de pagination, qui peut appeler avec quel scope, combien de fois retry avant d’arrêter. Chaque décision individuelle semble simple, mais ensemble elles déterminent si votre API est facile à utiliser ou une source de maux de tête pour toutes les équipes qui l’intègrent. Maîtriser les concepts ci-dessus — sémantique HTTP, choix architectural aligné sur le cas d’usage, sécurité en couches, patterns de fiabilité et documentation synchronisée avec le code — est la base pour concevoir une API en laquelle d’autres ont vraiment confiance et qu’ils utiliseront sur le long terme.