De ce naiba mai scriu de mână struct-uri WebSocket dintr-un spec AsyncAPI?

N-ar trebui să mai faci asta, pentru că spec-ul AsyncAPI spune deja exact cum arată fiecare mesaj, iar o mașină face treaba asta mai bine decât tine. Am scris gen-asyncapi ca să bagi un document AsyncAPI 3 la un capăt și să iasă la celălalt un singur fișier cu tipuri Go sau TypeScript, decodare strictă, validare și un client, direct din go generate.
Mai jos sunt întrebările pe care le-aș pune eu dacă mi-ar mai vinde cineva încă o unealtă de codegen, cu răspunsuri cât de directe pot.

Ce e de fapt în neregulă cu struct-urile scrise de mână?

O iau pe arătură. Echipa cealaltă redenumește un câmp, frame-ul tot se decodează, iar un json.Unmarshal simplu îți dă un struct plin de valori zero fără să scoată măcar un pârț. Am pierdut după-amiezi întregi aflând de ce “room” era un string gol și nimic nu dădea eroare.
Și e și mai naspa:

  • Decodarea simplă acceptă câmpuri lipsă, câmpuri în plus și valori-gunoi.
  • Ajungi să ghicești ce mesaj e un frame adulmecând un câmp type într-un switch uriaș scris la 2 noaptea.
  • O faci de două ori, o dată pentru serverul Go și o dată pentru clientul din browser, iar cele două o iau razna fiecare pe cont propriu. Dublu bug-uri, zero distracție în plus.

OpenAPI și-a primit circul de codegen acum ani buni. AsyncAPI a primit un spec, un renderer de documentație drăguț și un ecosistem de generatoare care în mare vrea un toolchain Node, un template și rugăciuni.

Cum bag chestia asta în build fără un toolchain Node?

E un binar Go. Instalează-l:

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

Sau ca Go tool în modulul tău, ca go tool gen-asyncapi să ruleze exact versiunea fixată în go.mod-ul tău:

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

Pune un config.yaml și un gen.go în directorul unde trebuie să stea codul:

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

Rulezi go generate ./... și apare chat.gen.go. Spec-ul e ultimul argument. Flag-urile -config, -language, -package și -o suprascriu config-ul sau îl înlocuiesc de tot:

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

Cât config îmi iau pe cap?

Asta e tot, și cam atâta ceremonie sunt dispus să suport:

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]

Codul generat chiar respinge un frame stricat, sau se face că plouă?

Îl respinge. Câmpurile obligatorii sunt valori, cele opționale sunt pointeri. Un const se completează singur când faci marshal, deci nu mai scrii niciodată Type: "join" ca un animal:

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

Decodarea verifică tot ce zice schema: câmpuri obligatorii, pattern-uri, lungimi, limite numerice, additionalProperties: false, itemi unici, tot tacâmul.

Trebuie să dau grep prin mesajele de eroare ca să aflu ce a crăpat?

Nu. Erorile împachetează sentinelele commerr din ctxerrors, așa că faci branch pe ele cu errors.Is ca un om civilizat:

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

Fiecare tip primește și Validate(), ca să verifici o valoare construită de tine înainte să te faci de râs trimițând-o.

Ce se întâmplă cu oneOf, anyOf și enum-urile?

Un enum devine un tip cu nume plus constante. Un oneOf sau anyOf devine un union cu FromX, AsX, Discriminator și Value. Varianta e aleasă după discriminator, sau după o proprietate pe care fiecare variantă o fixează cu const, sau, dacă nu există niciuna din astea, după singura variantă care se decodează. Exact una, altfel e eroare:

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 se contopește într-un singur struct, cu constrângeri cu tot.

Și API-urile care îndeasă JSON într-un câmp string?

Da, oamenii chiar fac asta, și da, e la fel de scârbos cum sună. contentMediaType plus contentSchema îți dau MetaValue() și SetMetaValue(), care decodează, validează și re-encodează chestia.

De unde știe măcar codul ce mesaj e într-un frame?

Asta e partea pe care toată lumea o fute când o face de mână. DecodeReceivedMessage lucrează în trei treceri:

  1. Mesajele ale căror matchere se potrivesc toate. Matcherele sunt valorile const de pe proprietățile de top-level ale payload-ului, plus orice message-matchers ai configurat.
  2. Restul, decodat strict. Exact unul trebuie să se decodeze.
  3. Mesajele cu payload opac, cum ar fi o schemă Avro ținută ca JSON brut.

Nu se potrivește nimic: asyncapirt.ErrUnknownMessage, cu motivul fiecărui candidat lipit la un loc. Se potrivesc două: asyncapirt.ErrAmbiguousMessage. Frame-urile necunoscute ajung la HandleUnknownMessage, care implicit loghează un warning și citește mai departe în loc să-ți arunce conexiunea în aer.

Și când două mesaje au exact aceeași formă?

Îndrepți un matcher spre valoarea care le deosebește, cu un JSON pointer. O listă înseamnă oricare dintre valorile alea, iar o listă goală pică generarea, pentru că o listă goală e un bug scris de tine:

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

Primesc un client sau doar o grămadă de tipuri?

Un client. Fiecare mesaj dintr-o operație send devine o metodă care validează, encodează și scrie. Mesajele din operațiile receive și reply-urile aterizează într-un Handler. Înglobezi UnimplementedHandler și scrii doar metodele de care îți pasă, restul în pizda mă-sii:

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

Transportul gorillaws mai ia și WithHeader, WithQueryParam, WithSubprotocols, WithBinaryFrames, WithReadLimit și WithDialer.

Și dacă nu vreau gorilla, sau niciun client?

Orice are WriteMessage, ReadMessage și Close merge ca transport. Sari complet peste client și DecodeReceivedMessage(frame) plus Dispatch(ctx, h, msg) fac aceiași doi pași de mână.

Tot lipesc URL-urile de server prin concatenare de string-uri?

Nu mai. URL-urile de server cu variabile și adresele de channel cu parametri devin constante și funcții, ca nimeni să nu mai concateneze vreodată de mână "wss://" + region + ".example.com":

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"

Și partea de browser?

Setezi language: typescript sau dai -language=typescript și același spec devine un singur fișier .ts cu zero dependențe. Un proiect TypeScript n-are nevoie de modul Go, doar de Go instalat:

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

Sau generezi ambele părți dintr-un singur gen.go, și asta e de fapt toată ideea. Serverul și clientul web n-au cum s-o ia razna când ies din același spec în aceeași comandă:

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

Schemele devin interfețe și union-uri, const-urile devin tipuri literale ca un oneOf cu discriminator să se restrângă într-un switch, iar fiecare tip primește o funcție validate<Type> care aruncă un ValidationError ce numește valoarea proastă și restrânge tipul când validarea trece. Clientul ia orice are send și close, deci un WebSocket simplu din browser merge așa cum e, iar fiecare metodă din handler e opțională.

Rezistă fișierul generat la tsconfig-ul meu paranoic?

Fișierul trece type-check-ul sub strict plus exactOptionalPropertyTypes, noUncheckedIndexedAccess, noUnusedLocals, verbatimModuleSyntax și erasableSyntaxOnly. Rămâne la ES2020, deci rulează în browsere și direct din Node.js.

Cât din AsyncAPI acoperă de fapt?

Cam tot. Servere, variabile, channel-uri, parametri, operații send și receive, reply-uri, headere, correlation ID-uri, trait-uri combinate cu JSON Merge Patch, $ref-uri locale și spre fișiere relative, scheme recursive. Payload-urile în format AsyncAPI, JSON Schema și OpenAPI generează tipuri, iar nullable din OpenAPI 3.0 merge și el. Payload-urile Avro, RAML și Protobuf vin ca json.RawMessage, în loc ca generatorul să se prefacă că le-a înțeles.

L-ai testat vreodată pe altceva decât pe spec-urile tale de jucărie?

Da. Repo-ul cară cele 19 exemple din repository-ul oficial de spec AsyncAPI, generate așa cum sunt în ambele limbaje, iar CI-ul face build și type-check la fiecare dintre ele. Mai e și un spec kitchensink care folosește fiecare obiect AsyncAPI 3.0 și se validează contra JSON Schema-ului oficial. Dacă spec-ul tău strică generatorul, probabil face ceva mai ciudat decât Slack, Kraken și Gemini la un loc.

Și dacă vreau doar să citesc un spec, nu să generez cod?

Atunci ia-l pe frate-său, go-asyncapi. Încărcarea și rezolvarea spec-ului stau acolo. gen-asyncapi stă deasupra: parcurge documentul încărcat și scrie cod, iar codul Go generat se sprijină pe un pachet runtime mic, asyncapirt, pentru matching, validare și transport.

Poate agentul meu de coding să-l configureze, ca să nu mă chinui eu?

Da. Repo-ul vine cu un skill care învață un agent de coding să configureze generatorul și să folosească ce scrie generatorul, în ambele limbaje. Claude Code și Codex instalează skill-ul din marketplace-ul psyb0t/agents, iar la fiecare release ajunge și pe ClawHub:

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

Deci de ce a fost vreodată spec-ul tău opțional?

Asta e întrebarea pe care n-o pune nimeni. Dacă spec-ul e doar documentație, nu se strică nimic când minte, deci minte. Fă-l sursa din care ți se generează codul și nu mai poate putrezi fără să observe build-ul. github.com/psyb0t/gen-asyncapi. MIT. Dă-i o stea sau ce vrei tu, nu-s șeful tău.