No deberías, porque la spec de AsyncAPI ya dice exactamente cómo es cada mensaje y una máquina hace ese curro mejor que tú. Escribí gen-asyncapi para que entre un documento AsyncAPI 3 por un lado y salga por el otro un solo archivo de tipos Go o TypeScript, con decodificación estricta, validación y un cliente, directamente desde go generate.
Abajo van las preguntas que yo haría si alguien viniera a venderme otra herramienta de codegen más, contestadas sin rodeos.
¿Qué tiene de malo escribir los structs a mano?
Se desincronizan. El otro equipo renombra un campo, el frame sigue decodificando y un json.Unmarshal a pelo te devuelve un struct lleno de valores cero sin soltar ni un pedo. He perdido tardes enteras averiguando por qué “room” era un string vacío sin que nada diera error.
Y la cosa empeora:
- La decodificación normal acepta campos que faltan, campos de más y valores basura.
- Acabas adivinando qué mensaje es cada frame olisqueando un campo
typeen unswitchgigante escrito a las 2 de la mañana. - Lo haces dos veces, una para el servidor en Go y otra para el cliente del navegador, y los dos se desincronizan cada uno por su lado. El doble de bugs, cero diversión extra.
OpenAPI tuvo su circo de codegen hace años. AsyncAPI tuvo una spec, un renderizador de docs muy mono y un ecosistema de generadores que sobre todo pide un toolchain de Node, una plantilla y rezar.
¿Cómo lo meto en mi build sin un toolchain de Node?
Es un binario de Go. Instálalo:
go install github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latestO como Go tool en tu módulo, para que go tool gen-asyncapi ejecute la versión que fije tu go.mod:
go get -tool github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latestSuelta un config.yaml y un gen.go en el directorio donde tiene que vivir el código:
# config.yaml
package: chat
output: chat.gen.go// gen.go
package chat
//go:generate go tool gen-asyncapi --config=config.yaml ../../api/asyncapi.yamlEjecuta go generate ./... y aparece chat.gen.go. La spec es el último argumento. Los flags -config, -language, -package y -o sobrescriben la config o la sustituyen del todo:
gen-asyncapi -package=chat -o=chat.gen.go asyncapi.yaml¿Cuánta config me voy a tener que comer?
Esto es todo, que es más o menos toda la ceremonia que estoy dispuesto a aguantar:
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]¿El código generado rechaza de verdad un frame roto, o se la suda?
Lo rechaza. Los campos obligatorios son valores, los opcionales son punteros. Un const se rellena solo al hacer marshal, así que nunca más escribes Type: "join" como 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"}La decodificación comprueba todo lo que dice el schema: campos obligatorios, patrones, longitudes, límites numéricos, additionalProperties: false, ítems únicos, todo el lote.
¿Tengo que hacer grep en los mensajes de error para saber qué ha fallado?
No. Los errores envuelven los sentinels de commerr de ctxerrors, así que los compruebas con errors.Is como una persona civilizada:
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 propertyCada tipo trae además Validate(), para que compruebes un valor que has montado tú antes de hacer el ridículo enviándolo.
¿Qué pasa con oneOf, anyOf y los enums?
Un enum se convierte en un tipo con nombre más constantes. Un oneOf o anyOf se convierte en una unión con FromX, AsX, Discriminator y Value. La variante se elige por el discriminator, o por una propiedad que cada variante fija con const, o, si no hay ninguna de las dos, por la única variante que decodifica. Exactamente una, o es un 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 se fusiona en un solo struct, restricciones incluidas.
¿Y las APIs que meten JSON dentro de un campo string?
Sí, hay gente que hace esto, y sí, es tan asqueroso como suena. contentMediaType más contentSchema te dan MetaValue() y SetMetaValue(), que decodifican, validan y vuelven a codificar el invento.
¿Cómo sabe el código qué mensaje lleva cada frame?
Esta es la parte que todo el mundo jode cuando la hace a mano. DecodeReceivedMessage trabaja en tres pasadas:
- Los mensajes cuyos matchers se cumplen todos. Los matchers son los valores
constde las propiedades de primer nivel del payload, más cualquiermessage-matchersque hayas configurado. - El resto, decodificado en modo estricto. Exactamente uno tiene que decodificar.
- Los mensajes con un payload opaco, como un schema de Avro guardado como JSON en crudo.
No coincide nada: asyncapirt.ErrUnknownMessage, con el motivo de cada candidato juntado. Coinciden dos: asyncapirt.ErrAmbiguousMessage. Los frames desconocidos van a HandleUnknownMessage, que por defecto loguea un warning y sigue leyendo en vez de reventarte la conexión.
¿Y cuando dos mensajes tienen exactamente la misma forma?
Apunta un matcher al valor que los distingue, con un JSON pointer. Una lista significa cualquiera de esos valores, y una lista vacía hace fallar la generación, porque una lista vacía es un bug que has escrito tú:
output-options:
message-matchers:
Notice:
/messages/0/type: [notice, info]
Alert:
/messages/0/type: alert¿Me llevo un cliente, o solo un montón de tipos?
Un cliente. Cada mensaje de una operación send se convierte en un método que valida, codifica y escribe. Los mensajes de las operaciones receive y las respuestas caen en un Handler. Embebe UnimplementedHandler y escribe solo los métodos que te importan, y a los demás que les den:
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 dropsEl transporte gorillaws también acepta WithHeader, WithQueryParam, WithSubprotocols, WithBinaryFrames, WithReadLimit y WithDialer.
¿Y si no quiero gorilla, ni ningún cliente?
Cualquier cosa con WriteMessage, ReadMessage y Close sirve como transporte. Sáltate el cliente del todo y DecodeReceivedMessage(frame) más Dispatch(ctx, h, msg) hacen los mismos dos pasos a mano.
¿Sigo pegando URLs de servidor a base de concatenar strings?
Ya no. Las URLs de servidor con variables y las direcciones de channel con parámetros se convierten en constantes y funciones, así que nadie vuelve a concatenar a mano "wss://" + region + ".example.com" nunca más:
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"¿Y el lado del navegador?
Pon language: typescript o pasa -language=typescript y la misma spec se convierte en un solo archivo .ts sin ninguna dependencia. Un proyecto TypeScript no necesita módulo de Go, solo tener Go instalado:
go run github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latest -language=typescript -o=src/chat.gen.ts asyncapi.yamlO genera los dos lados desde un solo gen.go, que es justo la gracia. El servidor y el cliente web no pueden desincronizarse si salen de la misma spec en el mismo comando:
//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.yamlLos schemas se convierten en interfaces y uniones, los consts en tipos literales para que un oneOf con discriminador se estreche en un switch, y cada tipo recibe una función validate<Type> que lanza un ValidationError nombrando el valor malo y estrecha el tipo cuando la validación pasa. El cliente acepta cualquier cosa con send y close, así que un WebSocket normal del navegador funciona tal cual, y cada método del handler es opcional.
¿El archivo sobrevivirá a mi tsconfig paranoico?
El archivo pasa el type-check con strict más exactOptionalPropertyTypes, noUncheckedIndexedAccess, noUnusedLocals, verbatimModuleSyntax y erasableSyntaxOnly. Se queda en ES2020, así que corre en navegadores y directamente en Node.js.
¿Cuánto de AsyncAPI soporta de verdad?
Prácticamente todo. Servidores, variables, channels, parámetros, operaciones send y receive, replies, headers, correlation IDs, traits fusionados con JSON Merge Patch, $ref locales y a archivos relativos, schemas recursivos. Los payloads en formato AsyncAPI, JSON Schema y OpenAPI generan tipos, y el nullable de OpenAPI 3.0 también funciona. Los payloads de Avro, RAML y Protobuf llegan como json.RawMessage en lugar de que el generador finja que los ha entendido.
¿Solo lo has probado con tus specs de juguete?
No. El repo trae los 19 ejemplos del repositorio oficial de la spec de AsyncAPI, generados tal cual en los dos lenguajes, y el CI hace build y type-check de todos y cada uno. También hay una spec kitchensink que usa cada objeto de AsyncAPI 3.0 y valida contra el JSON Schema oficial. Si tu spec rompe el generador, probablemente está haciendo algo más raro que Slack, Kraken y Gemini juntos.
¿Y si solo quiero leer una spec, no generar código?
Entonces coge a su hermano, go-asyncapi. La carga y la resolución de la spec viven ahí. gen-asyncapi va encima: recorre el documento cargado y escribe código, y el Go generado se apoya en un pequeño paquete de runtime, asyncapirt, para el matching, la validación y el transporte.
¿Puede mi agente de código montarlo para que no tenga que hacerlo yo?
Sí. El repo incluye un skill que le enseña a un agente de código a configurar el generador y a usar lo que el generador escribe, en los dos lenguajes. Claude Code y Codex instalan el skill desde el marketplace psyb0t/agents, y con cada release también se publica en ClawHub:
claude plugin marketplace add psyb0t/agents
claude plugin install gen-asyncapi@psyb0tEntonces, ¿por qué tu spec fue opcional alguna vez?
Esa es la que nadie pregunta. Si la spec es solo documentación, no se rompe nada cuando miente, así que miente. Haz que tu código se genere a partir de ella y ya no puede pudrirse sin que tu build se entere. github.com/psyb0t/gen-asyncapi. MIT. Dale una estrella o lo que sea, no soy tu jefe.