Solltest du nicht, denn die AsyncAPI-Spec sagt schon genau, wie jede Nachricht aussieht, und eine Maschine erledigt das Abtippen besser als du. Ich habe gen-asyncapi geschrieben, damit vorne ein AsyncAPI-3-Dokument reingeht und hinten eine einzige Datei mit Go- oder TypeScript-Typen, striktem Decoding, Validierung und einem Client rauskommt, direkt aus go generate.
Unten stehen die Fragen, die ich stellen würde, wenn mir jemand noch ein Codegen-Tool andrehen wollte, so direkt beantwortet, wie ich nur kann.
Was ist eigentlich falsch daran, die Structs von Hand zu schreiben?
Die laufen auseinander. Das andere Team benennt ein Feld um, der Frame dekodiert trotzdem, und ein nacktes json.Unmarshal drückt dir ein Struct voller Nullwerte in die Hand, ohne auch nur einen Furz zu lassen. Ich habe ganze Nachmittage damit verbracht rauszufinden, warum “room” ein leerer String war und nirgends ein Fehler kam.
Und es wird schlimmer:
- Einfaches Decoding akzeptiert fehlende Felder, zusätzliche Felder und Müllwerte.
- Am Ende rätst du, welche Nachricht ein Frame ist, indem du an einem
type-Feld schnüffelst, in einem riesigenswitch, geschrieben um 2 Uhr nachts. - Du machst es zweimal, einmal für den Go-Server und einmal für den Browser-Client, und die beiden driften unabhängig voneinander ab. Doppelt so viele Bugs, null zusätzlicher Spaß.
OpenAPI hat seinen Codegen-Zirkus schon vor Jahren bekommen. AsyncAPI bekam eine Spec, einen hübschen Docs-Renderer und ein Generator-Ökosystem, das vor allem eine Node-Toolchain, ein Template und Beten verlangt.
Wie kriege ich das ohne Node-Toolchain in meinen Build?
Das ist ein Go-Binary. Installier es:
go install github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latestOder als Go-Tool in deinem Modul, damit go tool gen-asyncapi genau die Version ausführt, die dein go.mod festnagelt:
go get -tool github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latestLeg eine config.yaml und eine gen.go in das Verzeichnis, in dem der Code leben soll:
# config.yaml
package: chat
output: chat.gen.go// gen.go
package chat
//go:generate go tool gen-asyncapi --config=config.yaml ../../api/asyncapi.yamlFühr go generate ./... aus, und chat.gen.go taucht auf. Die Spec ist das letzte Argument. Die Flags -config, -language, -package und -o überschreiben die Config oder ersetzen sie komplett:
gen-asyncapi -package=chat -o=chat.gen.go asyncapi.yamlWie viel Config handle ich mir da ein?
Das ist alles, und das ist ungefähr so viel Zeremonie, wie ich zu ertragen bereit bin:
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]Lehnt der generierte Code einen kaputten Frame wirklich ab, oder ist ihm das scheißegal?
Er lehnt ihn ab. Pflichtfelder sind Werte, optionale sind Pointer. Ein const füllt sich beim Marshalling selbst aus, also tippst du nie wieder Type: "join" wie ein Höhlenmensch:
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"}Das Decoding prüft alles, was das Schema sagt: Pflichtfelder, Patterns, Längen, numerische Grenzen, additionalProperties: false, eindeutige Items, das volle Programm.
Muss ich Fehlermeldungen greppen, um zu wissen, was schiefging?
Nein. Die Fehler wrappen die commerr-Sentinels aus ctxerrors, also prüfst du sie mit errors.Is wie ein zivilisierter Mensch:
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 propertyJeder Typ bekommt außerdem Validate(), damit du einen selbst gebauten Wert prüfen kannst, bevor du dich blamierst, indem du ihn abschickst.
Was passiert mit oneOf, anyOf und Enums?
Aus einem enum wird ein benannter Typ plus Konstanten. Aus einem oneOf oder anyOf wird eine Union mit FromX, AsX, Discriminator und Value. Die Variante wird über den discriminator gewählt, oder über eine Property, die jede Variante per const festnagelt, oder, wenn es beides nicht gibt, über die eine Variante, die sich dekodieren lässt. Genau eine, sonst ist es ein Fehler:
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 wird zu einem einzigen Struct zusammengeführt, inklusive Constraints.
Und was ist mit APIs, die JSON in ein String-Feld stopfen?
Ja, Leute machen das, und ja, es ist genauso eklig, wie es klingt. contentMediaType plus contentSchema bringt dir MetaValue() und SetMetaValue(), die das Ding dekodieren, validieren und wieder enkodieren.
Woher weiß der Code überhaupt, welche Nachricht in einem Frame steckt?
Das ist der Teil, den jeder von Hand verkackt. DecodeReceivedMessage arbeitet in drei Durchgängen:
- Nachrichten, deren Matcher alle zutreffen. Matcher sind die
const-Werte auf den Top-Level-Properties des Payloads, plus allemessage-matchers, die du konfiguriert hast. - Der Rest, strikt dekodiert. Genau eine Nachricht muss sich dekodieren lassen.
- Nachrichten mit opakem Payload, etwa einem Avro-Schema, das als rohes JSON mitgeführt wird.
Nichts passt: asyncapirt.ErrUnknownMessage, mit den Gründen aller Kandidaten zusammengehängt. Zwei passen: asyncapirt.ErrAmbiguousMessage. Unbekannte Frames landen bei HandleUnknownMessage, das standardmäßig eine Warnung loggt und weiterliest, statt dir die Verbindung wegzusprengen.
Und wenn zwei Nachrichten exakt dieselbe Form haben?
Richte einen Matcher per JSON Pointer auf den Wert, der sie unterscheidet. Eine Liste heißt irgendeiner dieser Werte, und eine leere Liste lässt die Generierung scheitern, weil eine leere Liste ein Bug ist, den du geschrieben hast:
output-options:
message-matchers:
Notice:
/messages/0/type: [notice, info]
Alert:
/messages/0/type: alertKrieg ich einen Client oder nur einen Haufen Typen?
Einen Client. Jede Nachricht einer send-Operation wird zu einer Methode, die validiert, enkodiert und schreibt. Nachrichten aus receive-Operationen und Replies landen in einem Handler. Bette UnimplementedHandler ein und schreib nur die Methoden, die dich wirklich jucken, der Rest kann dich mal:
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 dropsDer gorillaws-Transport nimmt außerdem WithHeader, WithQueryParam, WithSubprotocols, WithBinaryFrames, WithReadLimit und WithDialer.
Und wenn ich kein gorilla will, oder gar keinen Client?
Alles mit WriteMessage, ReadMessage und Close taugt als Transport. Lass den Client komplett weg, dann erledigen DecodeReceivedMessage(frame) plus Dispatch(ctx, h, msg) dieselben zwei Schritte von Hand.
Klebe ich Server-URLs immer noch per String-Konkatenation zusammen?
Nicht mehr. Server-URLs mit Variablen und Channel-Adressen mit Parametern werden zu Konstanten und Funktionen, damit niemand jemals wieder "wss://" + region + ".example.com" von Hand zusammenstückelt:
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"Und die Browser-Seite?
Setz language: typescript oder übergib -language=typescript, und aus derselben Spec wird eine einzige .ts-Datei ohne eine einzige Dependency. Ein TypeScript-Projekt braucht kein Go-Modul, nur ein installiertes Go:
go run github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latest -language=typescript -o=src/chat.gen.ts asyncapi.yamlOder generier beide Seiten aus einer einzigen gen.go, und genau darum geht es. Server und Web-Client können nicht auseinanderlaufen, wenn sie aus derselben Spec im selben Befehl kommen:
//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.yamlSchemas werden zu Interfaces und Unions, Consts zu Literal-Typen, damit ein oneOf mit Discriminator in einem switch eingegrenzt wird, und jeder Typ bekommt eine validate<Type>-Funktion, die einen ValidationError mit dem kaputten Wert wirft und den Typ eingrenzt, wenn die Validierung durchgeht. Der Client nimmt alles mit send und close, also funktioniert ein simples Browser-WebSocket so, wie es ist, und jede Handler-Methode ist optional.
Überlebt die Datei meine paranoide tsconfig?
Die Datei besteht den Type-Check unter strict plus exactOptionalPropertyTypes, noUncheckedIndexedAccess, noUnusedLocals, verbatimModuleSyntax und erasableSyntaxOnly. Sie bleibt bei ES2020, läuft also in Browsern und direkt unter Node.js.
Wie viel von AsyncAPI deckt das Ding wirklich ab?
So ziemlich alles. Server, Variablen, Channels, Parameter, Send- und Receive-Operationen, Replies, Header, Correlation IDs, per JSON Merge Patch gemergte Traits, lokale $refs und solche auf relative Dateien, rekursive Schemas. Payloads in den Formaten AsyncAPI, JSON Schema und OpenAPI erzeugen Typen, und das nullable aus OpenAPI 3.0 funktioniert auch. Avro-, RAML- und Protobuf-Payloads kommen als json.RawMessage an, statt dass der Generator so tut, als hätte er sie verstanden.
Wurde das Ding immer nur an deinen eigenen Spielzeug-Specs getestet?
Nein. Das Repo bringt die 19 Beispiele aus dem offiziellen AsyncAPI-Spec-Repository mit, unverändert in beiden Sprachen generiert, und die CI baut und type-checkt jedes einzelne davon. Dazu gibt es eine Kitchensink-Spec, die jedes AsyncAPI-3.0-Objekt benutzt und gegen das offizielle JSON Schema validiert. Wenn deine Spec den Generator zerlegt, treibt sie wahrscheinlich was Schrägeres als Slack, Kraken und Gemini zusammen.
Und wenn ich eine Spec nur lesen will, ohne Code zu generieren?
Dann schnapp dir das Geschwisterprojekt, go-asyncapi. Laden und Auflösen der Spec passieren dort. gen-asyncapi sitzt obendrauf: Es läuft das geladene Dokument ab und schreibt Code, und der generierte Go-Code stützt sich auf ein kleines Runtime-Package, asyncapirt, für Matching, Validierung und Transport.
Kann mein Coding-Agent das einrichten, damit ich es nicht muss?
Ja. Das Repo liefert einen Skill mit, der einem Coding-Agent beibringt, den Generator einzurichten und zu benutzen, was der Generator schreibt, in beiden Sprachen. Claude Code und Codex installieren den Skill aus dem psyb0t/agents-Marketplace, und mit jedem Release landet der Skill zusätzlich auf ClawHub:
claude plugin marketplace add psyb0t/agents
claude plugin install gen-asyncapi@psyb0tUnd warum war deine Spec überhaupt jemals optional?
Das ist die Frage, die keiner stellt. Wenn die Spec nur Doku ist, geht nichts kaputt, wenn sie lügt, also lügt sie. Mach sie zu dem, woraus dein Code generiert wird, und sie kann nicht mehr verrotten, ohne dass dein Build es merkt. github.com/psyb0t/gen-asyncapi. MIT. Gib dem Repo einen Stern oder was auch immer, ich bin nicht dein Chef.