go-asyncapi: încarcă specificații AsyncAPI 3 în Go

go-asyncapi citește documente AsyncAPI 3 în Go și îți dă serverele, canalele, operațiile și mesajele ca struct-uri simple, cu fiecare $ref urmărit dintr-un fișier în altul, fiecare trait aplicat și totul în ordinea în care apare în document, nu în ce ordine au chef map-urile din Go azi. Nu generează cod și nu-ți judecă alegerile de viață. L-am scris pentru că orice variantă de Go pe care am găsit-o ori era blocată pe AsyncAPI 2, ori era un nenorocit de generator întreg care voia să-mi dicteze structura proiectului, când eu voiam doar partea de încărcare și atât. Generatorul stă separat în gen-asyncapi, aceeași împărțire ca la oapixconstgen pe partea de OpenAPI.


Îl instalezi, încarci un 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 refuză orice document al cărui câmp asyncapi nu începe cu 3., apoi încarcă serverele, canalele, operațiile și componentele în ordinea asta, pentru că operațiile arată spre canale și canalele trebuie să existe primele. Operation.Channel e un *Channel adevărat, nu un string pe care îl cauți mai târziu. Action e send sau receive, iar orice altceva e commerr.ErrInvalidValue. server.URL() completează fiecare {placeholder} cu valoarea lui implicită, deci nu mai măcelărești string-uri de mână.

Partea care chiar mă interesează: mesajele sunt identificate după locul unde le stă definiția. Un mesaj referențiat din trei canale și două operații e un singur *Message, nu cinci copii pe care să le deduplici ca un troglodit. Trait-urile se aplică în ordinea din listă, în stil JSON Merge Patch, și nu suprascriu niciodată ce setează deja mesajul, iar un $ref din payload-ul unui trait se rezolvă față de fișierul trait-ului, nu față de al tău.


Cum parcurgi schema unui payload

Schemele rămân brute, ca arbori *asyncapi.Object care păstrează ordinea cheilor: Keys []string plus Values map[string]any. Get, String și Object sunt nil-safe, deci dacă înlănțuiești până la o cheie care lipsește primești nil, nu panic. Ref-urile din interiorul unei scheme trec prin Resolver-ul documentului:

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 întoarce valoarea plus Location-ul ei, adică un fișier și un JSON pointer. Location e un struct comparabil, deci două ref-uri care ajung la aceeași schemă îți dau aceeași cheie de map, iar să emiți fiecare tip exact o dată nu mai e un bordel total. Doar proprietatea asta face ca un generator pus deasupra să nu fie o nebunie. Payload-urile în format AsyncAPI, JSON Schema sau OpenAPI se încarcă drept scheme; Avro, Protobuf sau orice altceva lasă Payload nil, iar UnsupportedFormat îți spune ce a fost sărit.


Nu-ți descarcă ref-urile remote

Orice $ref care conține :// e o eroare care îți spune să descarci documentul și să-l referi după cale. Nu las un loader de spec-uri să facă request-uri pe rețea pe la spatele tău. Ref-urile locale și cele spre fișiere relative se rezolvă fără probleme, fiecare fișier e încărcat o singură dată și ținut în cache după calea absolută, un lanț de ref-uri are 64 de pași până să fie declarat ciclu, iar un pointer spre nimic e commerr.ErrNotFound. Sentinelele astea vin din ctxerrors, deci errors.Is(err, commerr.ErrNotFound) prinde un ref stricat oricât context s-ar fi înfășurat în jurul lui. Dacă spec-ul tău trage jumătate din scheme de la vreun URL, descarcă mai întâi rahatul ăla local.