Tu ne devrais pas, parce que la spec AsyncAPI dit déjà exactement à quoi ressemble chaque message, et une machine fait ce boulot mieux que toi. J’ai écrit gen-asyncapi pour qu’un document AsyncAPI 3 entre d’un côté et qu’il sorte de l’autre un seul fichier de types Go ou TypeScript, avec décodage strict, validation et un client, directement depuis go generate.
Voici les questions que je poserais si quelqu’un venait encore me vendre un outil de codegen, avec des réponses aussi cash que possible.
Qu’est-ce qui cloche vraiment quand on écrit les structs à la main ?
Ça finit par diverger. L’autre équipe renomme un champ, la frame se décode quand même, et un simple json.Unmarshal te refile une struct pleine de valeurs zéro sans même lâcher un pet. J’ai perdu des après-midi entiers à chercher pourquoi la “room” était une chaîne vide sans qu’aucune erreur ne sorte.
Et ça empire :
- Le décodage de base accepte les champs manquants, les champs en trop et les valeurs pourries.
- Tu finis par deviner quel message contient une frame en reniflant un champ
typedans unswitchgéant écrit à 2h du mat. - Tu le fais deux fois, une pour le serveur Go et une pour le client navigateur, et les deux divergent chacun dans leur coin. Deux fois plus de bugs, zéro fun en plus.
OpenAPI a eu droit à son cirque de codegen il y a des années. AsyncAPI a eu une spec, un joli rendu de doc et un écosystème de générateurs qui réclame surtout une toolchain Node, un template et des prières.
Comment je branche ça dans mon build sans toolchain Node ?
C’est un binaire Go. Installe-le :
go install github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latestOu comme Go tool dans ton module, pour que go tool gen-asyncapi lance la version épinglée par ton go.mod :
go get -tool github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latestPose un config.yaml et un gen.go dans le répertoire où le code doit vivre :
# config.yaml
package: chat
output: chat.gen.go// gen.go
package chat
//go:generate go tool gen-asyncapi --config=config.yaml ../../api/asyncapi.yamlLance go generate ./... et chat.gen.go apparaît. La spec est le dernier argument. Les flags -config, -language, -package et -o écrasent la config ou la remplacent complètement :
gen-asyncapi -package=chat -o=chat.gen.go asyncapi.yamlCombien de config je vais devoir me coltiner ?
Voilà, c’est tout, et c’est à peu près le maximum de cérémonie que je suis prêt à supporter :
language: go # go or typescript, defaults to go
package: chat # Go package name, required for Go
output: chat.gen.go # defaults to asyncapi.gen.go, or asyncapi.gen.ts
generate:
models: true # types, codecs, validation
client: true # decoding, Handler, Dispatch, Client (needs models)
output-options:
message-matchers:
Notice:
/messages/0/type: [notice, info]Le code généré rejette vraiment une frame pourrie, ou il s’en fout ?
Il la rejette. Les champs requis sont des valeurs, les optionnels des pointeurs. Un const se remplit tout seul au marshal, donc tu ne tapes plus jamais Type: "join" comme un sauvage :
type JoinRequest struct {
Type string `json:"type"`
ID string `json:"id"`
Room string `json:"room"`
Nickname *string `json:"nickname,omitempty"`
}
data, _ := json.Marshal(chat.JoinRequest{ID: "1", Room: "lobby"})
// {"type":"join","id":"1","room":"lobby"}Le décodage vérifie tout ce que dit le schéma : champs requis, patterns, longueurs, bornes numériques, additionalProperties: false, items uniques, tout le bordel.
Je dois grepper les messages d’erreur pour savoir ce qui a planté ?
Non. Les erreurs enveloppent les sentinelles commerr de ctxerrors, donc tu les testes avec errors.Is comme une personne civilisée :
err := json.Unmarshal([]byte(`{"type":"join","id":"1"}`), &req)
errors.Is(err, commerr.ErrRequiredFieldNotSet) // true, room is missing
err = json.Unmarshal([]byte(`{"type":"join","id":"1","room":"x","extra":1}`), &req)
errors.Is(err, commerr.ErrValidationFailed) // true, extra propertyChaque type a aussi droit à Validate(), pour vérifier une valeur que tu as construite toi-même avant de te ridiculiser en l’envoyant.
Et oneOf, anyOf et les enums, ça donne quoi ?
Un enum devient un type nommé plus des constantes. Un oneOf ou un anyOf devient une union avec FromX, AsX, Discriminator et Value. La variante est choisie par le discriminator, ou par une propriété que chaque variante fixe avec const, ou, à défaut des deux, par la seule variante qui se décode. Exactement une, sinon c’est une erreur :
value, err := event.Value()
if err != nil {
return err
}
switch e := value.(type) {
case chat.MessagePosted:
fmt.Println(e.Author.ID, "said", e.Text)
case chat.MemberJoined:
fmt.Println(e.Member.ID, "joined")
case chat.Ping:
// keepalive
}allOf fusionne en une seule struct, contraintes comprises.
Et les API qui fourrent du JSON dans un champ string ?
Oui, des gens font ça, et oui, c’est aussi dégueulasse que ça en a l’air. contentMediaType plus contentSchema te donnent MetaValue() et SetMetaValue(), qui décodent, valident et réencodent le machin.
Comment le code sait-il seulement quel message contient une frame ?
C’est la partie que tout le monde fout en l’air à la main. DecodeReceivedMessage procède en trois passes :
- Les messages dont tous les matchers tiennent. Les matchers sont les valeurs
constdes propriétés de premier niveau du payload, plus lesmessage-matchersque tu as configurés. - Le reste, décodé en mode strict. Un seul doit se décoder.
- Les messages à payload opaque, comme un schéma Avro gardé en JSON brut.
Rien ne matche : asyncapirt.ErrUnknownMessage, avec la raison de chaque candidat mise bout à bout. Deux matchent : asyncapirt.ErrAmbiguousMessage. Les frames inconnues partent vers HandleUnknownMessage, qui par défaut logue un warning et continue de lire au lieu d’atomiser ta connexion.
Et quand deux messages ont exactement la même forme ?
Pointe un matcher sur la valeur qui les distingue, avec un JSON pointer. Une liste veut dire n’importe laquelle de ces valeurs, et une liste vide fait échouer la génération, parce qu’une liste vide, c’est un bug que tu as écrit :
output-options:
message-matchers:
Notice:
/messages/0/type: [notice, info]
Alert:
/messages/0/type: alertJ’ai droit à un client, ou juste à un tas de types ?
Un client. Chaque message d’une opération send devient une méthode qui valide, encode et écrit. Les messages des opérations receive et les replies atterrissent dans un Handler. Embarque UnimplementedHandler et écris seulement les méthodes dont tu as quelque chose à foutre :
conn, err := gorillaws.Dial(ctx, chat.ServerProductionURL, gorillaws.WithBearerToken(token))
if err != nil {
return err
}
client := chat.NewClient(conn)
defer client.Close()
err = client.JoinRoom(ctx, chat.JoinRequest{ID: id, Room: "lobby"})
err = client.Run(ctx, handler{}) // reads until ctx ends or the connection dropsLe transport gorillaws accepte aussi WithHeader, WithQueryParam, WithSubprotocols, WithBinaryFrames, WithReadLimit et WithDialer.
Et si je ne veux pas de gorilla, ni de client du tout ?
Tout ce qui a WriteMessage, ReadMessage et Close fait l’affaire comme transport. Zappe complètement le client et DecodeReceivedMessage(frame) plus Dispatch(ctx, h, msg) font les deux mêmes étapes à la main.
Je recolle encore les URL de serveur à coups de concaténation de strings ?
Plus maintenant. Les URL de serveur avec variables et les adresses de channel avec paramètres deviennent des constantes et des fonctions, donc plus personne ne concatène à la main "wss://" + region + ".example.com", plus jamais :
kitchensink.ServerProductionURL // "wss://eu.example.com:443/v2/ws"
kitchensink.ServerProductionURLWith("us", "8443", "1") // "wss://us.example.com:8443/v1/ws"
kitchensink.CommandsAddress("acme") // "tenants/acme/commands"Et côté navigateur ?
Mets language: typescript ou passe -language=typescript et la même spec devient un seul fichier .ts sans aucune dépendance. Un projet TypeScript n’a pas besoin de module Go, juste de Go installé :
go run github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latest -language=typescript -o=src/chat.gen.ts asyncapi.yamlOu génère les deux côtés depuis un seul gen.go, et c’est tout l’intérêt. Le serveur et le client web ne peuvent pas diverger quand ils sortent de la même spec dans la même commande :
//go:generate go tool gen-asyncapi --config=config.yaml chat.yaml
//go:generate go tool gen-asyncapi --config=config.yaml -language=typescript -o=chat.gen.ts chat.yamlLes schémas deviennent des interfaces et des unions, les consts deviennent des types littéraux pour qu’un oneOf à discriminant se resserre dans un switch, et chaque type reçoit une fonction validate<Type> qui lève une ValidationError nommant la valeur fautive et resserre le type quand la validation passe. Le client prend tout ce qui a send et close, donc un WebSocket de navigateur tout bête marche tel quel, et chaque méthode du handler est optionnelle.
Est-ce que le fichier tient le coup face à mon tsconfig paranoïaque ?
Le fichier passe le type-check sous strict plus exactOptionalPropertyTypes, noUncheckedIndexedAccess, noUnusedLocals, verbatimModuleSyntax et erasableSyntaxOnly. Il s’en tient à ES2020, donc il tourne dans les navigateurs et directement sous Node.js.
Quelle part d’AsyncAPI ça gère vraiment ?
À peu près tout. Serveurs, variables, channels, paramètres, opérations send et receive, replies, headers, correlation IDs, traits fusionnés avec JSON Merge Patch, $ref locaux et vers des fichiers relatifs, schémas récursifs. Les payloads aux formats AsyncAPI, JSON Schema et OpenAPI génèrent des types, et le nullable d’OpenAPI 3.0 marche aussi. Les payloads Avro, RAML et Protobuf arrivent en json.RawMessage au lieu que le générateur fasse semblant de les avoir compris.
Ça a été testé uniquement sur tes specs jouets ?
Non. Le repo embarque les 19 exemples du dépôt officiel de la spec AsyncAPI, générés tels quels dans les deux langages, et la CI build et type-check chacun d’entre eux. Il y a aussi une spec kitchensink qui utilise chaque objet AsyncAPI 3.0 et se valide contre le JSON Schema officiel. Si ta spec fait planter le générateur, elle fait sans doute un truc plus tordu que Slack, Kraken et Gemini réunis.
Et si je veux juste lire une spec, sans générer de code ?
Alors prends le petit frère, go-asyncapi. Le chargement et la résolution de la spec vivent là-bas. gen-asyncapi se pose par-dessus : il parcourt le document chargé et écrit du code, et le Go généré s’appuie sur un petit package runtime, asyncapirt, pour le matching, la validation et le transport.
Mon agent de code peut le configurer à ma place ?
Oui. Le repo livre un skill qui apprend à un agent de code à configurer le générateur et à utiliser ce que le générateur écrit, dans les deux langages. Claude Code et Codex installent le skill depuis la marketplace psyb0t/agents, et il part aussi sur ClawHub à chaque release :
claude plugin marketplace add psyb0t/agents
claude plugin install gen-asyncapi@psyb0tAlors pourquoi ta spec a-t-elle un jour été optionnelle ?
C’est la question que personne ne pose. Si la spec n’est que de la doc, rien ne casse quand elle ment, donc elle ment. Fais-en la source de ton code généré et elle ne peut plus pourrir sans que ton build le remarque. github.com/psyb0t/gen-asyncapi. MIT. Mets-lui une étoile ou ce que tu veux, je ne suis pas ton patron.