go-asyncapi: load AsyncAPI 3 specs in Go

go-asyncapi reads AsyncAPI 3 documents in Go and hands you servers, channels, operations and messages as plain structs, with every $ref followed across files, every trait merged in, and everything in the order the document lists it instead of whatever order Go’s maps feel like today. It doesn’t generate code and it doesn’t validate your life choices. I wrote it because every Go option I found was either stuck on AsyncAPI 2 or a whole fucking generator that wanted to own my project layout, and all I wanted was the loading part on its own. The generator lives separately in gen-asyncapi, same split as oapixconstgen on the OpenAPI side.


Install it, load a spec

go get github.com/psyb0t/go-asyncapi
document, err := asyncapi.Load("asyncapi.yaml")
if err != nil {
	return err
}
for _, server := range document.Servers {
	fmt.Println(server.Name, server.URL())
}
for _, operation := range document.Operations {
	for _, message := range operation.Messages {
		fmt.Println(operation.Action, operation.Channel.Address, message.Name)
	}
}

Load refuses anything whose asyncapi field doesn’t start with 3., then loads servers, channels, operations and components in that order, because operations point at channels and the channels have to exist first. Operation.Channel is a real *Channel, not a string you look up later. Action is send or receive, and anything else is a commerr.ErrInvalidValue. server.URL() fills every {placeholder} with its default, so you stop mangling strings by hand.

The part I actually care about: messages are tracked by where their definition lives. One message referenced from three channels and two operations is one *Message, not five copies you get to dedup like an animal. Traits merge in list order, JSON Merge Patch style, and never override what the message already sets, and a $ref inside a trait’s payload resolves against the trait’s own file, not yours.


Walking a payload schema

Schemas stay raw, as *asyncapi.Object trees that keep key order: Keys []string plus Values map[string]any. Get, String and Object are nil-safe, so chaining into a missing key gives you nil instead of a panic. Refs inside a schema go through the document’s Resolver:

message := document.Messages[0]
schema, at, err := document.Resolver.Deref(message.Payload, message.PayloadAt)
if err != nil {
	return err
}
object, ok := schema.(*asyncapi.Object) // false for a true/false schema
if !ok {
	return nil
}
properties := object.Object("properties")
if properties == nil {
	return nil
}
for _, name := range properties.Keys {
	fmt.Println(name)
}

Deref returns the value plus its Location, a file and a JSON pointer. Location is a comparable struct, so two refs landing on the same schema give you the same map key, and emitting each type exactly once stops being a shitshow. That one property is what makes a generator on top of this sane. Payloads in AsyncAPI, JSON Schema or OpenAPI format load as schemas; Avro, Protobuf or anything else leaves Payload nil and UnsupportedFormat tells you what got skipped.


It won’t fetch your remote refs

Any $ref with :// in it is an error telling you to download the document and reference it by path. I’m not having a spec loader make network calls behind your back. Local and relative-file refs resolve fine, each file is loaded once and cached by absolute path, a ref chain gets 64 hops before it’s called a cycle, and a pointer into nothing is commerr.ErrNotFound. Those sentinels come from ctxerrors, so errors.Is(err, commerr.ErrNotFound) catches a broken ref however much context got wrapped around it. If your spec pulls half its schemas from some URL, vendor the shit first.