API typée avec ConnectRPC

Disponible dans 1 jour

Devenir premium
Résumé Support

Quand on développe une API autrement qu'en TypeScript, on se retrouve souvent avec un problème de synchronisation : les types existent côté serveur, mais il faut les recopier ou les maintenir à la main côté client. Dès que l'API évolue, un oubli peut rapidement créer un décalage entre les deux parties. ConnectRPC apporte une solution en permettant de construire une API gRPC compatible avec les navigateurs à partir de de définition protobuf.

Le principe de ConnectRPC

ConnectRPC s'appuie sur les fichiers Protocol Buffers pour définir le contrat de l'API. Un fichier .proto décrit à la fois :

  • les messages envoyés par le client.
  • les messages retournés par le serveur.
  • les services et les méthodes disponibles.

Ce fichier devient la source de vérité du projet et permettront de générer du code côté backend pour implémenter les handlers, mais aussi du côté frontend pour obtenir un client typé.

syntax = "proto3"; package api.v1; message GreetRequest { string first_name = 1; string last_name = 2; } message GreetResponse { string message = 1; } service GreetService { rpc Greet(GreetRequest) returns (GreetResponse); }

Dans cet exemple, on définit une méthode Greet qui reçoit une requête contenant un prénom et un nom, puis retourne une réponse contenant un message. ConnectRPC va ensuite utiliser cette définition pour générer les types et les fonctions nécessaires.

Générer le code avec Buf

Pour générer le code, ConnectRPC s'appuie sur Buf, qui permet de vérifier les fichiers .proto, de gérer les dépendances et de lancer les générateurs nécessaires.

buf config init buf dep update buf lint buf generate

Le fichier buf.gen.yml permet de préciser ce que l'on veut générer. On peut par exemple générer le code Go pour le backend, puis ajouter une étape supplémentaire pour générer le code TypeScript destiné au frontend. La configuration dépendra du langage utilisé, mais ConnectRPC supporte nativement Go, Node.js, Python, Kotlin, Swift et Dart.

Implémenter le serveur

Côté serveur, ConnectRPC génère une interface que l'on doit implémenter. Chaque méthode définie dans le fichier .proto correspond à une fonction à écrire dans un handler. La logique métier reste dans votre code, mais la signature de la méthode vient du fichier .proto. Si le contrat change, l'interface générée change aussi, et l'éditeur ou le linter peut immédiatement signaler ce qu'il faut corriger.

Une fois le handler créé, on peut l'enregistrer dans notre serveur web.

Par exemple, en Go :

package main import ( "fmt" "net/http" "tuto/gen/api/v1/apiv1connect" "tuto/handler" "connectrpc.com/connect" "connectrpc.com/validate" ) func main() { addr := "localhost:8000" p := new(http.Protocols) p.SetHTTP1(true) p.SetUnencryptedHTTP2(true) mux := http.NewServeMux() path, greetHandler := apiv1connect.NewGreetServiceHandler( handler.GreetHandler{}, // Handler qui implémente l'interface définie par le fichier Go. connect.WithInterceptors(validate.NewInterceptor()), ) mux.Handle(path, greetHandler) s := &http.Server{ Addr: addr, Handler: mux, Protocols: p, } fmt.Printf("serveur démarré sur %s", addr) err := s.ListenAndServe() }

La version Node.js suit le même principe :

import { createValidateInterceptor } from "@connectrpc/validate"; import { fastify } from "fastify"; import { fastifyConnectPlugin } from "@connectrpc/connect-fastify"; import routes from "./connect"; // Implémentation créée à partir du .proto const server = fastify(); await server.register(fastifyConnectPlugin, { interceptors: [createValidateInterceptor()], routes, }); await server.listen({ host: "localhost", port: 8080 }); console.log("server is listening at", server.addresses());

JSON et gRPC

ConnectRPC offre l'avantage de supporter plusieurs modes de communication. L'API peut être appelée en gRPC, avec un transfert binaire plus performant, mais elle peut aussi être appelée en JSON pour la rendre compatible avec les navigateur web (le gRPC classique repose sur des fonctionnalités HTTP/2 qui ne sont pas utilisables en JavaScript via fetch()).

Validation des données

ConnectRPC peut aussi s'intégrer à un système de validation basé sur les annotations dans les fichiers .proto. L'idée est d'ajouter des règles directement sur les champs, puis de les faire appliquer côté serveur avec un intercepteur.

import "buf/validate/validate.proto"; message GreetRequest { string first_name = 1 [(buf.validate.field).string.min_len = 1]; }

Les requêtes invalides seront alors rejetées automatiquement, avec une erreur structurée. Cela permet de centraliser une partie des règles dans le contrat de l'API, plutôt que de les éparpiller manuellement dans chaque handler.

Utilisation côté frontend

Côté frontend, ConnectRPC fournit les outils nécessaires pour créer un client à partir du service généré. On configure d'abord un transport, qui indique comment communiquer avec l'API, puis on crée le client.

const client = createClient( GreetService, createConnectTransport({ baseUrl: "/rpc", }), ); const response = await client.greet({ firstName: "John", lastName: "Doe", }); console.log(response.message);

Le point important ici est que greet, firstName, lastName et message sont connus grâce au code généré depuis le fichier .proto. Si le contrat change, TypeScript pourra signaler les appels à mettre à jour.

Intégration avec React Query

Si vous utilisez React, il existe aussi une intégration avec React Query via connect-query. Elle permet d'utiliser les services générés avec les hooks habituels, comme useQuery ou useMutation.

const { data, isPending } = useQuery(GreetService.method.greet, { firstName: "John", lastName: "Doe", });

On conserve alors les avantages de React Query, comme la gestion du chargement et du cache, tout en profitant des types générés depuis le fichier .proto.

Cache HTTP et méthodes GET

Par défaut, les appels RPC JSON sont tous faits en POST. Pour profiter du cache HTTP, ConnectRPC permet de déclarer certaines méthodes comme immutables afin qu'elles puissent être appelées avec la méthode GET.

service GreetService { rpc Greet(GreetRequest) returns (GreetResponse) { option idempotency_level = NO_SIDE_EFFECTS; } }

C'est utile pour les méthodes de lecture qui ne modifient pas l'état du serveur. Il faut alors ajuster la configuration côté client.

const transport = createConnectTransport({ baseUrl: "", useHttpGet: true, });

Limites et cas particuliers

ConnectRPC ne cherche pas à remplacer toute votre application HTTP. Vous pouvez très bien avoir une partie de votre API en gRPC et conserver des routes classiques à côté. C'est notamment pertinent pour des routes spéciales, comme celles qui servent à envoyer des fichiers depuis le navigateur. Il est possible de représenter un fichier sous forme binaire encodée, mais ce n'est pas forcément optimal. Une approche plus simple consiste à garder une route dédiée avec un FormData, séparée de la partie gRPC.

La partie authentification et sécurité sort aussi du cadre de ConnectRPC. C'est à vous de mettre en place les mécanismes nécessaires. Dans la plupart des cas, on passera les informations utiles via les en-têtes, avec une logique spécifique côté serveur.

Quand utiliser ConnectRPC ?

ConnectRPC est intéressant lorsque vous voulez partager les types entre un backend et un frontend, surtout si les deux parties ne sont pas écrites dans le même langage. On garde les fichiers .proto comme source de vérité, on génère le code des deux côtés, et on évite de maintenir les types à la main.

L'outil est particulièrement adapté si vous utilisez Go côté serveur et TypeScript côté client, mais il existe aussi des intégrations pour d'autres langages comme Python, Kotlin ou Dart.

Pour aller plus loin, vous pouvez consulter la documentation officielle : connectrpc.com.