Waarom tik ik WebSocket-structs uit een AsyncAPI-spec nog steeds met de hand over?

Dat zou je niet moeten doen, want de AsyncAPI-spec zegt al precies hoe elk bericht eruitziet en een machine doet dat overtikwerk beter dan jij. Ik schreef gen-asyncapi zodat er aan de ene kant een AsyncAPI 3-document in gaat en er aan de andere kant één bestand met Go- of TypeScript-types, strikte decoding, validatie en een client uitkomt, rechtstreeks vanuit go generate.
Hieronder de vragen die ik zou stellen als iemand me nóg een codegen-tool probeerde aan te smeren, zo bot mogelijk beantwoord.

Wat is er eigenlijk mis met structs met de hand schrijven?

Ze lopen uit de pas. Het andere team hernoemt een veld, het frame decodeert nog steeds, en een kale json.Unmarshal geeft je een struct vol nulwaarden zonder ook maar een scheet te laten. Ik ben hele middagen kwijtgeraakt aan uitzoeken waarom “room” een lege string was terwijl nergens een fout uit kwam.
En het wordt erger:

  • Gewone decoding accepteert ontbrekende velden, extra velden en rommelwaarden.
  • Je zit uiteindelijk te raden welk bericht een frame is door aan een type-veld te snuffelen in een gigantische switch die je om 2 uur ‘s nachts schreef.
  • Je doet het twee keer, één keer voor de Go-server en één keer voor de browserclient, en die twee lopen elk op hun eigen manier uit de pas. Dubbel zoveel bugs, nul extra lol.

OpenAPI kreeg jaren geleden al zijn codegen-circus. AsyncAPI kreeg een spec, een mooie docs-renderer en een generator-ecosysteem dat vooral een Node-toolchain, een template en gebeden wil.

Hoe krijg ik dit in mijn build zonder Node-toolchain?

Het is een Go-binary. Installeren maar:

go install github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latest

Of als Go-tool in je module, zodat go tool gen-asyncapi precies de versie draait die je go.mod vastpint:

go get -tool github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latest

Zet een config.yaml en een gen.go in de directory waar de code moet komen:

# config.yaml
package: chat
output: chat.gen.go
// gen.go
package chat
//go:generate go tool gen-asyncapi --config=config.yaml ../../api/asyncapi.yaml

Draai go generate ./... en chat.gen.go verschijnt. De spec is het laatste argument. De flags -config, -language, -package en -o overschrijven de config of vervangen die volledig:

gen-asyncapi -package=chat -o=chat.gen.go asyncapi.yaml

Hoeveel config haal ik me op de hals?

Dit is alles, en dat is ongeveer zoveel ceremonie als ik bereid ben te slikken:

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]

Weigert de gegenereerde code echt een kapot frame, of boeit het de code geen reet?

De code weigert het. Verplichte velden zijn waarden, optionele zijn pointers. Een const vult zichzelf in bij het marshallen, dus je typt nooit meer Type: "join" als een of ander beest:

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"}

De decoding checkt alles wat het schema zegt: verplichte velden, patterns, lengtes, numerieke grenzen, additionalProperties: false, unieke items, het hele zootje.

Moet ik foutmeldingen greppen om te weten wat er misging?

Nee. De errors wrappen de commerr-sentinels uit ctxerrors, dus je checkt ze met errors.Is zoals een beschaafd mens:

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 property

Elk type krijgt ook Validate(), zodat je een waarde die je zelf bouwde kunt checken voordat je jezelf voor schut zet door die te versturen.

Wat gebeurt er met oneOf, anyOf en enums?

Een enum wordt een benoemd type plus constanten. Een oneOf of anyOf wordt een union met FromX, AsX, Discriminator en Value. De variant wordt gekozen via de discriminator, of via een property die elke variant vastpint met const, of, als geen van beide er is, via de enige variant die decodeert. Precies één, anders is het een error:

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 wordt samengevoegd tot één struct, constraints inbegrepen.

En de API’s die JSON in een stringveld proppen?

Ja, mensen doen dit echt, en ja, het is net zo smerig als het klinkt. contentMediaType plus contentSchema levert je MetaValue() en SetMetaValue() op, die het ding decoderen, valideren en opnieuw encoderen.

Hoe weet de code überhaupt welk bericht er in een frame zit?

Dit is het stuk dat iedereen met de hand verkloot. DecodeReceivedMessage werkt in drie rondes:

  1. Berichten waarvan alle matchers kloppen. Matchers zijn de const-waarden op de top-level properties van de payload, plus alle message-matchers die je hebt geconfigureerd.
  2. De rest, strikt gedecodeerd. Precies één bericht moet decoderen.
  3. Berichten met een ondoorzichtige payload, zoals een Avro-schema dat als ruwe JSON bewaard blijft.

Niks matcht: asyncapirt.ErrUnknownMessage, met de reden van elke kandidaat erin samengevoegd. Twee matchen: asyncapirt.ErrAmbiguousMessage. Onbekende frames gaan naar HandleUnknownMessage, dat standaard een warning logt en doorleest in plaats van je verbinding op te blazen.

En als twee berichten precies dezelfde vorm hebben?

Richt een matcher met een JSON pointer op de waarde die ze uit elkaar houdt. Een lijst betekent een van die waarden, en een lege lijst laat de generatie falen, want een lege lijst is een bug die jij schreef:

output-options:
  message-matchers:
    Notice:
      /messages/0/type: [notice, info]
    Alert:
      /messages/0/type: alert

Krijg ik een client, of alleen een berg types?

Een client. Elk bericht van een send-operatie wordt een methode die valideert, encodeert en schrijft. Berichten uit receive-operaties en replies komen in een Handler terecht. Embed UnimplementedHandler en schrijf alleen de methodes waar je echt een fuck om geeft:

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 drops

Het gorillaws-transport slikt ook WithHeader, WithQueryParam, WithSubprotocols, WithBinaryFrames, WithReadLimit en WithDialer.

En als ik geen gorilla wil, of helemaal geen client?

Alles met WriteMessage, ReadMessage en Close werkt als transport. Sla de client helemaal over en DecodeReceivedMessage(frame) plus Dispatch(ctx, h, msg) doen dezelfde twee stappen met de hand.

Plak ik server-URL’s nog steeds aan elkaar met stringconcatenatie?

Niet meer. Server-URL’s met variabelen en channel-adressen met parameters worden constanten en functies, zodat niemand ooit nog met de hand "wss://" + region + ".example.com" aan elkaar plakt:

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"

En de browserkant?

Zet language: typescript of geef -language=typescript mee en dezelfde spec wordt één .ts-bestand zonder één enkele dependency. Een TypeScript-project heeft geen Go-module nodig, alleen een geïnstalleerde Go:

go run github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latest -language=typescript -o=src/chat.gen.ts asyncapi.yaml

Of genereer beide kanten vanuit één gen.go, en daar draait het juist om. De server en de webclient kunnen niet uit de pas lopen als ze uit dezelfde spec in hetzelfde commando komen:

//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.yaml

Schema’s worden interfaces en unions, consts worden literal types zodat een oneOf met discriminator in een switch vernauwt, en elk type krijgt een validate<Type>-functie die een ValidationError gooit met de foute waarde erin en het type vernauwt als de validatie slaagt. De client neemt alles met send en close, dus een gewone browser-WebSocket werkt zoals hij is, en elke handlermethode is optioneel.

Overleeft het bestand mijn paranoïde tsconfig?

Het bestand doorstaat de type-check onder strict plus exactOptionalPropertyTypes, noUncheckedIndexedAccess, noUnusedLocals, verbatimModuleSyntax en erasableSyntaxOnly. Het blijft bij ES2020, dus het draait in browsers en rechtstreeks in Node.js.

Hoeveel van AsyncAPI kan de generator echt aan?

Zo goed als alles. Servers, variabelen, channels, parameters, send- en receive-operaties, replies, headers, correlation-ID’s, traits samengevoegd met JSON Merge Patch, lokale en bestandsrelatieve $ref‘s, recursieve schema’s. Payloads in AsyncAPI-, JSON Schema- en OpenAPI-formaat leveren types op, en nullable uit OpenAPI 3.0 werkt ook. Avro-, RAML- en Protobuf-payloads komen binnen als json.RawMessage in plaats van dat de generator doet alsof hij ze begreep.

Is de generator alleen maar op je eigen speelgoedspecs getest?

Nee. De repo bevat de 19 voorbeelden uit de officiële AsyncAPI-specrepository, ongewijzigd gegenereerd in beide talen, en CI bouwt en type-checkt ze stuk voor stuk. Er is ook een kitchensink-spec die elk AsyncAPI 3.0-object gebruikt en valideert tegen het officiële JSON Schema. Als jouw spec de generator sloopt, doet jouw spec waarschijnlijk iets geschifters dan Slack, Kraken en Gemini bij elkaar.

En als ik alleen een spec wil lezen, geen code genereren?

Pak dan het zusje, go-asyncapi. Het laden en resolven van de spec gebeurt daar. gen-asyncapi zit erbovenop: het loopt het geladen document door en schrijft code, en de gegenereerde Go-code leunt op een klein runtime-package, asyncapirt, voor matching, validatie en transport.

Kan mijn coding agent het opzetten zodat ik het niet hoef te doen?

Ja. De repo levert een skill mee die een coding agent leert de generator op te zetten en te gebruiken wat de generator schrijft, in beide talen. Claude Code en Codex installeren de skill vanuit de psyb0t/agents-marketplace, en bij elke release verschijnt de skill ook op ClawHub:

claude plugin marketplace add psyb0t/agents
claude plugin install gen-asyncapi@psyb0t

Dus waarom was je spec ooit optioneel?

Dat is de vraag die niemand stelt. Als de spec alleen documentatie is, gaat er niks stuk als hij liegt, dus liegt hij. Maak er de bron van waaruit je code gegenereerd wordt en hij kan niet meer wegrotten zonder dat je build het merkt. github.com/psyb0t/gen-asyncapi. MIT. Geef de repo een ster of zo, ik ben je baas niet.