From cdff097d935781bc796bb91a335f0218f4402ef2 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:18 -0400 Subject: [PATCH 01/11] chore: add gopkg.in/yaml.v3 dependency Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- go.mod | 1 + go.sum | 1 + 2 files changed, 2 insertions(+) diff --git a/go.mod b/go.mod index 3270174..d8826a0 100644 --- a/go.mod +++ b/go.mod @@ -13,4 +13,5 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9a620ca..2f22d06 100644 --- a/go.sum +++ b/go.sum @@ -31,5 +31,6 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From d642d28275e8c0193a528c32c66036a85773dd2d Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:19 -0400 Subject: [PATCH 02/11] feat: add asyncapi-gen parser Implements AST-based parser that walks Go source files and returns []EventSpec values from structs annotated with the asyncapi sentinel blank field pattern. Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- cmd/asyncapi-gen/main.go | 6 + cmd/asyncapi-gen/parser.go | 225 +++++++++++++++++++++++++++ cmd/asyncapi-gen/parser_test.go | 117 ++++++++++++++ cmd/asyncapi-gen/testdata/fixture.go | 13 ++ 4 files changed, 361 insertions(+) create mode 100644 cmd/asyncapi-gen/main.go create mode 100644 cmd/asyncapi-gen/parser.go create mode 100644 cmd/asyncapi-gen/parser_test.go create mode 100644 cmd/asyncapi-gen/testdata/fixture.go diff --git a/cmd/asyncapi-gen/main.go b/cmd/asyncapi-gen/main.go new file mode 100644 index 0000000..3c05106 --- /dev/null +++ b/cmd/asyncapi-gen/main.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package main is the asyncapi-gen code generator binary. +package main + +func main() {} diff --git a/cmd/asyncapi-gen/parser.go b/cmd/asyncapi-gen/parser.go new file mode 100644 index 0000000..e057ac4 --- /dev/null +++ b/cmd/asyncapi-gen/parser.go @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "reflect" + "strings" +) + +// EventSpec holds the generator metadata extracted from one event data struct. +type EventSpec struct { + StructName string + Channel string + Params map[string]string // param name → description + Stream string + CEType string + SendSummary string + RecvSummary string + Fields []FieldSpec +} + +// FieldSpec describes one data field extracted from a struct. +type FieldSpec struct { + JSONName string + GoType string // e.g. "string", "*string", "int" + Required bool // false when omitempty or pointer type +} + +// ParseFile parses the Go source file at path and returns one EventSpec +// per annotated struct. Returns an error if the file cannot be parsed or +// a required tag key is missing. +func ParseFile(path string) ([]EventSpec, error) { + src, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, src, 0) + if err != nil { + return nil, fmt.Errorf("parsing file: %w", err) + } + + var specs []EventSpec + for _, decl := range f.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + es, ok, err := extractEventSpec(typeSpec.Name.Name, structType) + if err != nil { + return nil, err + } + if ok { + specs = append(specs, es) + } + } + } + return specs, nil +} + +// extractEventSpec extracts an EventSpec from a struct type if it has a +// sentinel blank field with an asyncapi tag. Returns ok=false if the struct +// is not annotated. +func extractEventSpec(name string, st *ast.StructType) (EventSpec, bool, error) { + var asyncapiTag string + var dataFields []FieldSpec + + for _, field := range st.Fields.List { + // Sentinel blank field: unnamed or named "_", type struct{} + isSentinel := false + if len(field.Names) == 0 { + isSentinel = true + } else if len(field.Names) == 1 && field.Names[0].Name == "_" { + isSentinel = true + } + + if isSentinel { + if field.Tag == nil { + continue + } + raw := strings.Trim(field.Tag.Value, "`") + tag := reflect.StructTag(raw) + val := tag.Get("asyncapi") + if val != "" { + asyncapiTag = val + } + continue + } + + // Data field + if field.Tag == nil { + continue + } + raw := strings.Trim(field.Tag.Value, "`") + tag := reflect.StructTag(raw) + jsonVal := tag.Get("json") + if jsonVal == "" || jsonVal == "-" { + continue + } + parts := strings.SplitN(jsonVal, ",", 2) + jsonName := parts[0] + omitempty := len(parts) > 1 && strings.Contains(parts[1], "omitempty") + + goType := fieldGoType(field.Type) + required := !omitempty && !strings.HasPrefix(goType, "*") + + if field.Names != nil { + dataFields = append(dataFields, FieldSpec{ + JSONName: jsonName, + GoType: goType, + Required: required, + }) + } + } + + if asyncapiTag == "" { + return EventSpec{}, false, nil + } + + es, err := parseAsyncAPITag(name, asyncapiTag) + if err != nil { + return EventSpec{}, false, err + } + es.Fields = dataFields + return es, true, nil +} + +// fieldGoType returns a string representation of the Go type expression. +func fieldGoType(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return "*" + fieldGoType(t.X) + case *ast.ArrayType: + return "[]" + fieldGoType(t.Elt) + default: + return "interface{}" + } +} + +// parseAsyncAPITag parses a comma-separated key:value asyncapi tag string. +// Values may contain spaces but not commas. Multiple param entries are +// supported by repeating the param key. +func parseAsyncAPITag(structName, tag string) (EventSpec, error) { + es := EventSpec{ + StructName: structName, + Params: make(map[string]string), + } + + pairs := strings.Split(tag, ",") + for _, pair := range pairs { + idx := strings.IndexByte(pair, ':') + if idx < 0 { + continue + } + key := strings.TrimSpace(pair[:idx]) + val := strings.TrimSpace(pair[idx+1:]) + switch key { + case "channel": + es.Channel = val + case "param": + eqIdx := strings.IndexByte(val, '=') + if eqIdx < 0 { + return EventSpec{}, fmt.Errorf("struct %s: param tag %q missing '='", structName, val) + } + es.Params[val[:eqIdx]] = val[eqIdx+1:] + case "stream": + es.Stream = val + case "type": + es.CEType = val + case "send": + es.SendSummary = val + case "receive": + es.RecvSummary = val + } + } + + required := []string{"channel", "stream", "type", "send", "receive"} + var missing []string + for _, r := range required { + switch r { + case "channel": + if es.Channel == "" { + missing = append(missing, r) + } + case "stream": + if es.Stream == "" { + missing = append(missing, r) + } + case "type": + if es.CEType == "" { + missing = append(missing, r) + } + case "send": + if es.SendSummary == "" { + missing = append(missing, r) + } + case "receive": + if es.RecvSummary == "" { + missing = append(missing, r) + } + } + } + if len(missing) > 0 { + return EventSpec{}, fmt.Errorf("struct %s: asyncapi tag missing required keys: %s", structName, strings.Join(missing, ", ")) + } + + return es, nil +} diff --git a/cmd/asyncapi-gen/parser_test.go b/cmd/asyncapi-gen/parser_test.go new file mode 100644 index 0000000..b7e1fa1 --- /dev/null +++ b/cmd/asyncapi-gen/parser_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "testing" +) + +func TestParseFile_ReturnsEventSpec(t *testing.T) { + specs, err := ParseFile("testdata/fixture.go") + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if len(specs) != 1 { + t.Fatalf("len(specs) = %d, want 1", len(specs)) + } + + s := specs[0] + + if s.StructName != "WidgetCreatedData" { + t.Errorf("StructName = %q, want %q", s.StructName, "WidgetCreatedData") + } + if s.Channel != "core.widget.created.{ownerId}" { + t.Errorf("Channel = %q, want %q", s.Channel, "core.widget.created.{ownerId}") + } + if s.Params["ownerId"] != "The widget owner identifier" { + t.Errorf("Params[ownerId] = %q, want %q", s.Params["ownerId"], "The widget owner identifier") + } + if s.Stream != "WIDGETS" { + t.Errorf("Stream = %q, want %q", s.Stream, "WIDGETS") + } + if s.CEType != "dev.example.widget.created" { + t.Errorf("CEType = %q, want %q", s.CEType, "dev.example.widget.created") + } + if s.SendSummary != "Published when a widget is created" { + t.Errorf("SendSummary = %q, want %q", s.SendSummary, "Published when a widget is created") + } + if s.RecvSummary != "Consume widget-created events" { + t.Errorf("RecvSummary = %q, want %q", s.RecvSummary, "Consume widget-created events") + } +} + +func TestParseFile_ReturnsFields(t *testing.T) { + specs, err := ParseFile("testdata/fixture.go") + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + fields := specs[0].Fields + + // sentinel blank field must be excluded + for _, f := range fields { + if f.JSONName == "" || f.JSONName == "_" { + t.Errorf("sentinel field leaked into Fields: %+v", f) + } + } + + // widgetId — required (no omitempty, not pointer) + widgetID := findField(fields, "widgetId") + if widgetID == nil { + t.Fatal("field widgetId not found") + } + if !widgetID.Required { + t.Error("widgetId should be required") + } + if widgetID.GoType != "string" { + t.Errorf("widgetId GoType = %q, want %q", widgetID.GoType, "string") + } + + // tag — optional (omitempty) + tag := findField(fields, "tag") + if tag == nil { + t.Fatal("field tag not found") + } + if tag.Required { + t.Error("tag should not be required (omitempty)") + } + + // parentId — optional (pointer) + parentID := findField(fields, "parentId") + if parentID == nil { + t.Fatal("field parentId not found") + } + if parentID.Required { + t.Error("parentId should not be required (pointer)") + } + if parentID.GoType != "*string" { + t.Errorf("parentId GoType = %q, want %q", parentID.GoType, "*string") + } +} + +func TestParseFile_MissingRequiredTag_ReturnsError(t *testing.T) { + // Write a temp file with a missing required tag key + content := `package testdata +type BadData struct { + _ struct{} ` + "`" + `asyncapi:"channel:core.bad.{id}"` + "`" + ` + Name string ` + "`" + `json:"name"` + "`" + ` +}` + path := t.TempDir() + "/bad.go" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + _, err := ParseFile(path) + if err == nil { + t.Error("expected error for missing required tag keys") + } +} + +func findField(fields []FieldSpec, jsonName string) *FieldSpec { + for i := range fields { + if fields[i].JSONName == jsonName { + return &fields[i] + } + } + return nil +} diff --git a/cmd/asyncapi-gen/testdata/fixture.go b/cmd/asyncapi-gen/testdata/fixture.go new file mode 100644 index 0000000..129660d --- /dev/null +++ b/cmd/asyncapi-gen/testdata/fixture.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testdata + +// WidgetCreatedData is the payload for widget.created events. +type WidgetCreatedData struct { + _ struct{} `asyncapi:"channel:core.widget.created.{ownerId},param:ownerId=The widget owner identifier,stream:WIDGETS,type:dev.example.widget.created,send:Published when a widget is created,receive:Consume widget-created events"` + + WidgetID string `json:"widgetId"` + Name string `json:"name"` + Tag string `json:"tag,omitempty"` + ParentID *string `json:"parentId,omitempty"` +} From 4b0151131e6650675c69b8683dc5b38b3214e4dc Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:19 -0400 Subject: [PATCH 03/11] fix: address review findings in asyncapi-gen parser Remove unreachable `if field.Names != nil` guard in extractEventSpec and replace string-concatenated temp path with filepath.Join in test. Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- cmd/asyncapi-gen/parser.go | 12 +++++------- cmd/asyncapi-gen/parser_test.go | 3 ++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/cmd/asyncapi-gen/parser.go b/cmd/asyncapi-gen/parser.go index e057ac4..1c7f8b6 100644 --- a/cmd/asyncapi-gen/parser.go +++ b/cmd/asyncapi-gen/parser.go @@ -119,13 +119,11 @@ func extractEventSpec(name string, st *ast.StructType) (EventSpec, bool, error) goType := fieldGoType(field.Type) required := !omitempty && !strings.HasPrefix(goType, "*") - if field.Names != nil { - dataFields = append(dataFields, FieldSpec{ - JSONName: jsonName, - GoType: goType, - Required: required, - }) - } + dataFields = append(dataFields, FieldSpec{ + JSONName: jsonName, + GoType: goType, + Required: required, + }) } if asyncapiTag == "" { diff --git a/cmd/asyncapi-gen/parser_test.go b/cmd/asyncapi-gen/parser_test.go index b7e1fa1..415e0cd 100644 --- a/cmd/asyncapi-gen/parser_test.go +++ b/cmd/asyncapi-gen/parser_test.go @@ -4,6 +4,7 @@ package main import ( "os" + "path/filepath" "testing" ) @@ -97,7 +98,7 @@ type BadData struct { _ struct{} ` + "`" + `asyncapi:"channel:core.bad.{id}"` + "`" + ` Name string ` + "`" + `json:"name"` + "`" + ` }` - path := t.TempDir() + "/bad.go" + path := filepath.Join(t.TempDir(), "bad.go") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } From 18a76366fe9634105d5adacb6f21cbae3106e5c4 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:19 -0400 Subject: [PATCH 04/11] feat: add asyncapi-gen schema builder Implements BuildDoc() converting []EventSpec to an AsyncAPIDoc model with channels, send/receive operations, CloudEvents envelope schemas, data schemas, and NATS JetStream bindings. Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- cmd/asyncapi-gen/schema.go | 271 ++++++++++++++++++++++++++++++++ cmd/asyncapi-gen/schema_test.go | 171 ++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 cmd/asyncapi-gen/schema.go create mode 100644 cmd/asyncapi-gen/schema_test.go diff --git a/cmd/asyncapi-gen/schema.go b/cmd/asyncapi-gen/schema.go new file mode 100644 index 0000000..4f9d309 --- /dev/null +++ b/cmd/asyncapi-gen/schema.go @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "net/url" + "strings" +) + +// AsyncAPIDoc is the top-level AsyncAPI 3.0 document model. +type AsyncAPIDoc struct { + AsyncAPI string `yaml:"asyncapi"` + Info Info `yaml:"info"` + DefaultContentType string `yaml:"defaultContentType"` + Servers map[string]Server `yaml:"servers"` + Channels map[string]Channel `yaml:"channels"` + Operations map[string]Operation `yaml:"operations"` + Components Components `yaml:"components"` +} + +// Info holds document metadata. +type Info struct { + Title string `yaml:"title"` + Version string `yaml:"version"` +} + +// Server describes a NATS server. +type Server struct { + Host string `yaml:"host"` + Protocol string `yaml:"protocol"` + Description string `yaml:"description,omitempty"` +} + +// Channel describes a NATS subject channel. +type Channel struct { + Address string `yaml:"address"` + Parameters map[string]Parameter `yaml:"parameters,omitempty"` + Messages map[string]Ref `yaml:"messages"` +} + +// Parameter describes a channel address parameter. +type Parameter struct { + Description string `yaml:"description"` +} + +// Ref is an AsyncAPI $ref object. +type Ref struct { + Ref string `yaml:"$ref"` +} + +// Operation describes a send or receive operation. +type Operation struct { + Action string `yaml:"action"` + Summary string `yaml:"summary"` + Channel Ref `yaml:"channel"` + Bindings OperationBinding `yaml:"bindings,omitempty"` +} + +// OperationBinding holds protocol-specific operation bindings. +type OperationBinding struct { + NATS NATSOperationBinding `yaml:"nats,omitempty"` +} + +// NATSOperationBinding holds NATS JetStream stream metadata. +type NATSOperationBinding struct { + Stream string `yaml:"stream,omitempty"` + BindingVersion string `yaml:"bindingVersion,omitempty"` +} + +// Components holds reusable AsyncAPI components. +type Components struct { + Messages map[string]Message `yaml:"messages"` + Schemas map[string]Schema `yaml:"schemas"` +} + +// Message describes an AsyncAPI message. +type Message struct { + Name string `yaml:"name"` + Title string `yaml:"title"` + ContentType string `yaml:"contentType"` + Payload Ref `yaml:"payload"` +} + +// Schema is a simplified JSON Schema object for AsyncAPI. +type Schema struct { + Type string `yaml:"type,omitempty"` + Description string `yaml:"description,omitempty"` + Required []string `yaml:"required,omitempty"` + Properties map[string]Schema `yaml:"properties,omitempty"` + Const string `yaml:"const,omitempty"` + Format string `yaml:"format,omitempty"` + Ref string `yaml:"$ref,omitempty"` +} + +// BuildDoc constructs an AsyncAPIDoc from the given specs and document metadata. +func BuildDoc(specs []EventSpec, title, version, serverURL string) AsyncAPIDoc { + doc := AsyncAPIDoc{ + AsyncAPI: "3.0.0", + Info: Info{Title: title, Version: version}, + DefaultContentType: "application/cloudevents+json", + Servers: buildServers(serverURL), + Channels: make(map[string]Channel), + Operations: make(map[string]Operation), + Components: Components{ + Messages: make(map[string]Message), + Schemas: make(map[string]Schema), + }, + } + + for _, spec := range specs { + chKey := channelName(spec.StructName) + msgKey := messageKey(spec.StructName) + envSchemaKey := strings.TrimSuffix(spec.StructName, "Data") + "CloudEvent" + dataSchemaKey := spec.StructName + + // Channel + params := make(map[string]Parameter) + for k, v := range spec.Params { + params[k] = Parameter{Description: v} + } + doc.Channels[chKey] = Channel{ + Address: spec.Channel, + Parameters: params, + Messages: map[string]Ref{ + msgKey: {Ref: fmt.Sprintf("#/components/messages/%s", msgKey)}, + }, + } + + // Send operation + sendKey := "publish" + title2(chKey) + doc.Operations[sendKey] = Operation{ + Action: "send", + Summary: spec.SendSummary, + Channel: Ref{Ref: fmt.Sprintf("#/channels/%s", chKey)}, + Bindings: OperationBinding{ + NATS: NATSOperationBinding{Stream: spec.Stream, BindingVersion: "latest"}, + }, + } + + // Receive operation + recvKey := "consume" + title2(chKey) + doc.Operations[recvKey] = Operation{ + Action: "receive", + Summary: spec.RecvSummary, + Channel: Ref{Ref: fmt.Sprintf("#/channels/%s", chKey)}, + } + + // Message + doc.Components.Messages[msgKey] = Message{ + Name: msgKey, + Title: humanTitle(spec.StructName), + ContentType: "application/cloudevents+json", + Payload: Ref{Ref: fmt.Sprintf("#/components/schemas/%s", envSchemaKey)}, + } + + // CloudEvents envelope schema + doc.Components.Schemas[envSchemaKey] = buildEnvelopeSchema(spec) + + // Data schema + doc.Components.Schemas[dataSchemaKey] = buildDataSchema(spec) + } + + return doc +} + +// buildServers parses the server URL and returns the servers map. +func buildServers(rawURL string) map[string]Server { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return map[string]Server{"nats": {Host: rawURL, Protocol: "nats"}} + } + return map[string]Server{ + "nats": { + Host: u.Host, + Protocol: u.Scheme, + }, + } +} + +// buildEnvelopeSchema returns the CloudEvents envelope schema for a spec. +func buildEnvelopeSchema(spec EventSpec) Schema { + return Schema{ + Type: "object", + Description: fmt.Sprintf("CloudEvents v1.0 envelope for %s", spec.CEType), + Required: []string{"specversion", "id", "type", "source", "subject", "time", "datacontenttype", "data"}, + Properties: map[string]Schema{ + "specversion": {Type: "string", Const: "1.0"}, + "id": {Type: "string", Format: "uuid"}, + "type": {Type: "string", Const: spec.CEType}, + "source": {Type: "string", Description: "URI identifying the producing service"}, + "subject": {Type: "string", Description: "The compliance subject identifier"}, + "time": {Type: "string", Format: "date-time"}, + "datacontenttype": {Type: "string", Const: "application/json"}, + "data": {Ref: fmt.Sprintf("#/components/schemas/%s", spec.StructName)}, + }, + } +} + +// buildDataSchema builds the data payload schema from struct fields. +func buildDataSchema(spec EventSpec) Schema { + props := make(map[string]Schema) + var required []string + + for _, f := range spec.Fields { + props[f.JSONName] = Schema{Type: goTypeToJSONSchema(f.GoType)} + if f.Required { + required = append(required, f.JSONName) + } + } + + return Schema{ + Type: "object", + Required: required, + Properties: props, + } +} + +// goTypeToJSONSchema maps Go type strings to JSON Schema type strings. +func goTypeToJSONSchema(goType string) string { + base := strings.TrimPrefix(goType, "*") + switch base { + case "string": + return "string" + case "int", "int32", "int64": + return "integer" + case "float32", "float64": + return "number" + case "bool": + return "boolean" + default: + return "object" + } +} + +// channelName converts a struct name like "EvidenceIngestedData" to "evidenceIngested". +func channelName(structName string) string { + name := strings.TrimSuffix(structName, "Data") + if len(name) == 0 { + return structName + } + return strings.ToLower(name[:1]) + name[1:] +} + +// messageKey converts a struct name like "EvidenceIngestedData" to "EvidenceIngested". +func messageKey(structName string) string { + return strings.TrimSuffix(structName, "Data") +} + +// humanTitle converts a struct name like "EvidenceIngestedData" to "Evidence Ingested". +func humanTitle(structName string) string { + name := strings.TrimSuffix(structName, "Data") + var parts []string + start := 0 + for i := 1; i < len(name); i++ { + if name[i] >= 'A' && name[i] <= 'Z' { + parts = append(parts, name[start:i]) + start = i + } + } + parts = append(parts, name[start:]) + return strings.Join(parts, " ") +} + +// title2 uppercases the first letter of s. +func title2(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/cmd/asyncapi-gen/schema_test.go b/cmd/asyncapi-gen/schema_test.go new file mode 100644 index 0000000..1b3fc0a --- /dev/null +++ b/cmd/asyncapi-gen/schema_test.go @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" +) + +func singleSpec() EventSpec { + return EventSpec{ + StructName: "WidgetCreatedData", + Channel: "core.widget.created.{ownerId}", + Params: map[string]string{"ownerId": "The widget owner identifier"}, + Stream: "WIDGETS", + CEType: "dev.example.widget.created", + SendSummary: "Published when a widget is created", + RecvSummary: "Consume widget-created events", + Fields: []FieldSpec{ + {JSONName: "widgetId", GoType: "string", Required: true}, + {JSONName: "name", GoType: "string", Required: true}, + {JSONName: "tag", GoType: "string", Required: false}, + {JSONName: "parentId", GoType: "*string", Required: false}, + }, + } +} + +func TestBuildDoc_InfoFields(t *testing.T) { + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + + if doc.AsyncAPI != "3.0.0" { + t.Errorf("AsyncAPI = %q, want %q", doc.AsyncAPI, "3.0.0") + } + if doc.Info.Title != "Test API" { + t.Errorf("Title = %q, want %q", doc.Info.Title, "Test API") + } + if doc.Info.Version != "1.0.0" { + t.Errorf("Version = %q, want %q", doc.Info.Version, "1.0.0") + } + if doc.DefaultContentType != "application/cloudevents+json" { + t.Errorf("DefaultContentType = %q, want %q", doc.DefaultContentType, "application/cloudevents+json") + } +} + +func TestBuildDoc_ServerURL(t *testing.T) { + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + + if len(doc.Servers) != 1 { + t.Fatalf("len(Servers) = %d, want 1", len(doc.Servers)) + } + srv := doc.Servers["nats"] + if srv.Host != "localhost:4222" { + t.Errorf("Host = %q, want %q", srv.Host, "localhost:4222") + } + if srv.Protocol != "nats" { + t.Errorf("Protocol = %q, want %q", srv.Protocol, "nats") + } +} + +func TestBuildDoc_Channel(t *testing.T) { + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + + ch, ok := doc.Channels["widgetCreated"] + if !ok { + t.Fatal("channel widgetCreated not found") + } + if ch.Address != "core.widget.created.{ownerId}" { + t.Errorf("Address = %q, want %q", ch.Address, "core.widget.created.{ownerId}") + } + param, ok := ch.Parameters["ownerId"] + if !ok { + t.Fatal("parameter ownerId not found") + } + if param.Description != "The widget owner identifier" { + t.Errorf("param description = %q, want %q", param.Description, "The widget owner identifier") + } +} + +func TestBuildDoc_Operations(t *testing.T) { + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + + sendOp, ok := doc.Operations["publishWidgetCreated"] + if !ok { + t.Fatal("operation publishWidgetCreated not found") + } + if sendOp.Action != "send" { + t.Errorf("send action = %q, want %q", sendOp.Action, "send") + } + if sendOp.Summary != "Published when a widget is created" { + t.Errorf("send summary = %q, want %q", sendOp.Summary, "Published when a widget is created") + } + + recvOp, ok := doc.Operations["consumeWidgetCreated"] + if !ok { + t.Fatal("operation consumeWidgetCreated not found") + } + if recvOp.Action != "receive" { + t.Errorf("receive action = %q, want %q", recvOp.Action, "receive") + } +} + +func TestBuildDoc_DataSchemaFields(t *testing.T) { + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + + dataSchema, ok := doc.Components.Schemas["WidgetCreatedData"] + if !ok { + t.Fatal("schema WidgetCreatedData not found") + } + + widgetIDProp, ok := dataSchema.Properties["widgetId"] + if !ok { + t.Fatal("property widgetId not found") + } + if widgetIDProp.Type != "string" { + t.Errorf("widgetId type = %q, want %q", widgetIDProp.Type, "string") + } + + // widgetId and name must be in required list + required := map[string]bool{} + for _, r := range dataSchema.Required { + required[r] = true + } + if !required["widgetId"] { + t.Error("widgetId should be required") + } + if !required["name"] { + t.Error("name should be required") + } + if required["tag"] { + t.Error("tag should not be required") + } + if required["parentId"] { + t.Error("parentId should not be required") + } +} + +func TestBuildDoc_CloudEventsEnvelope(t *testing.T) { + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + + env, ok := doc.Components.Schemas["WidgetCreatedCloudEvent"] + if !ok { + t.Fatal("schema WidgetCreatedCloudEvent not found") + } + + specversion, ok := env.Properties["specversion"] + if !ok { + t.Fatal("specversion property not found") + } + if specversion.Const != "1.0" { + t.Errorf("specversion const = %q, want %q", specversion.Const, "1.0") + } + + ceType, ok := env.Properties["type"] + if !ok { + t.Fatal("type property not found") + } + if ceType.Const != "dev.example.widget.created" { + t.Errorf("type const = %q, want %q", ceType.Const, "dev.example.widget.created") + } +} + +func TestBuildDoc_NATSBinding(t *testing.T) { + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + + op, ok := doc.Operations["publishWidgetCreated"] + if !ok { + t.Fatal("operation publishWidgetCreated not found") + } + if op.Bindings.NATS.Stream != "WIDGETS" { + t.Errorf("NATS stream = %q, want %q", op.Bindings.NATS.Stream, "WIDGETS") + } +} From b6377e8cda4a7aa9fcbd5f057a01fc97668ed4fd Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:19 -0400 Subject: [PATCH 05/11] test: assert receive op has no NATS binding Adds missing assertion to TestBuildDoc_Operations verifying that the receive operation carries an empty NATS stream (binding is send-only). Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- cmd/asyncapi-gen/schema_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/asyncapi-gen/schema_test.go b/cmd/asyncapi-gen/schema_test.go index 1b3fc0a..187809e 100644 --- a/cmd/asyncapi-gen/schema_test.go +++ b/cmd/asyncapi-gen/schema_test.go @@ -96,6 +96,9 @@ func TestBuildDoc_Operations(t *testing.T) { if recvOp.Action != "receive" { t.Errorf("receive action = %q, want %q", recvOp.Action, "receive") } + if recvOp.Bindings.NATS.Stream != "" { + t.Errorf("receive op NATS stream = %q, want empty (no binding)", recvOp.Bindings.NATS.Stream) + } } func TestBuildDoc_DataSchemaFields(t *testing.T) { From cd0826ad6d29865dcb9647faad8d4d77d06b74f7 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:20 -0400 Subject: [PATCH 06/11] feat: add asyncapi-gen YAML writer Implements WriteYAML to marshal AsyncAPIDoc to disk with SPDX header and 0o644 permissions. Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- cmd/asyncapi-gen/writer.go | 27 +++++++++++++++ cmd/asyncapi-gen/writer_test.go | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 cmd/asyncapi-gen/writer.go create mode 100644 cmd/asyncapi-gen/writer_test.go diff --git a/cmd/asyncapi-gen/writer.go b/cmd/asyncapi-gen/writer.go new file mode 100644 index 0000000..8fb9a9e --- /dev/null +++ b/cmd/asyncapi-gen/writer.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// WriteYAML marshals doc to YAML and writes it to path, prepending the +// SPDX license header. The file is created with 0o644 permissions. +func WriteYAML(doc AsyncAPIDoc, path string) error { + b, err := yaml.Marshal(doc) + if err != nil { + return fmt.Errorf("marshaling AsyncAPI document: %w", err) + } + + header := "# SPDX-License-Identifier: Apache-2.0\n" + out := append([]byte(header), b...) + + if err := os.WriteFile(path, out, 0o644); err != nil { + return fmt.Errorf("writing output file: %w", err) + } + return nil +} diff --git a/cmd/asyncapi-gen/writer_test.go b/cmd/asyncapi-gen/writer_test.go new file mode 100644 index 0000000..0c5446f --- /dev/null +++ b/cmd/asyncapi-gen/writer_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteYAML_CreatesFile(t *testing.T) { + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + out := filepath.Join(t.TempDir(), "asyncapi.yaml") + + if err := WriteYAML(doc, out); err != nil { + t.Fatalf("WriteYAML: %v", err) + } + + b, err := os.ReadFile(out) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + content := string(b) + + if !strings.Contains(content, "asyncapi: 3.0.0") { + t.Error("output missing 'asyncapi: 3.0.0'") + } + if !strings.Contains(content, "application/cloudevents+json") { + t.Error("output missing defaultContentType") + } + if !strings.Contains(content, "core.widget.created.{ownerId}") { + t.Error("output missing channel address") + } + if !strings.Contains(content, "WIDGETS") { + t.Error("output missing NATS stream name") + } + if !strings.Contains(content, "specversion") { + t.Error("output missing CloudEvents envelope field") + } +} + +func TestWriteYAML_SpdxHeader(t *testing.T) { + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + out := filepath.Join(t.TempDir(), "asyncapi.yaml") + + if err := WriteYAML(doc, out); err != nil { + t.Fatalf("WriteYAML: %v", err) + } + + b, err := os.ReadFile(out) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + if !strings.HasPrefix(string(b), "# SPDX-License-Identifier: Apache-2.0") { + t.Error("output missing SPDX header") + } +} From 3acea3ab7caeff85df58bff99ad370842c22a9a5 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:20 -0400 Subject: [PATCH 07/11] feat: add asyncapi-gen CLI and wire go:generate Replaces the stub main.go with the full CLI entry point that wires ParseFile, BuildDoc, and WriteYAML together. Adds the go:generate directive and asyncapi sentinel tag to events/events.go, and commits the generated api/events/asyncapi.yaml. Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- api/events/asyncapi.yaml | 204 +++++++++++++++++---------------------- cmd/asyncapi-gen/main.go | 43 ++++++++- events/events.go | 5 + 3 files changed, 135 insertions(+), 117 deletions(-) diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index b3cba81..b51c200 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -1,122 +1,96 @@ # SPDX-License-Identifier: Apache-2.0 asyncapi: 3.0.0 info: - title: ComplyTime API Events - version: 0.1.0 - description: | - Event contract for the ComplyTime evidence lifecycle. - - All public events use CloudEvents v1.0 envelope (JSON format). - The AsyncAPI spec is the source of truth for event contracts; - Go types in the events package must match these schemas. - license: - name: Apache-2.0 - contact: - name: ComplyTime - url: https://github.com/complytime/complyapi - + title: ComplyTime API Events + version: 0.1.0 defaultContentType: application/cloudevents+json - +servers: + nats: + host: localhost:4222 + protocol: nats channels: - evidenceIngested: - address: core.evidence.ingested.{subjectId} - description: | - Published when evidence is ingested, before sealing. - parameters: - subjectId: - description: The compliance subject identifier (e.g. `my-app-v1`) - messages: - evidenceIngested: - $ref: '#/components/messages/evidenceIngested' - + evidenceIngested: + address: core.evidence.ingested.{subjectId} + parameters: + subjectId: + description: The compliance subject identifier + messages: + EvidenceIngested: + $ref: '#/components/messages/EvidenceIngested' operations: - publishEvidenceIngested: - action: send - channel: - $ref: '#/channels/evidenceIngested' - summary: Published when evidence is accepted for processing. - - consumeEvidenceIngested: - action: receive - channel: - $ref: '#/channels/evidenceIngested' - summary: Consume evidence-ingested events. - + consumeEvidenceIngested: + action: receive + summary: Consume evidence-ingested events + channel: + $ref: '#/channels/evidenceIngested' + publishEvidenceIngested: + action: send + summary: Published when evidence is accepted for processing + channel: + $ref: '#/channels/evidenceIngested' + bindings: + nats: + stream: EVIDENCE + bindingVersion: latest components: - messages: - evidenceIngested: - name: EvidenceIngested - title: Evidence Ingested - contentType: application/cloudevents+json - payload: - $ref: '#/components/schemas/EvidenceIngestedCloudEvent' - - schemas: - EvidenceIngestedCloudEvent: - type: object - description: CloudEvents v1.0 envelope for evidence.ingested - required: - - specversion - - id - - type - - source - - subject - - time - - datacontenttype - - data - properties: - specversion: - type: string - const: "1.0" - id: - type: string - format: uuid - type: - type: string - const: dev.complytime.evidence.ingested - source: - type: string - description: URI identifying the producing service - examples: - - complytime-gateway - subject: - type: string - description: The compliance subject identifier - time: - type: string - format: date-time - datacontenttype: - type: string - const: application/json - data: - $ref: '#/components/schemas/EvidenceIngestedData' - - EvidenceIngestedData: - type: object - description: Payload for evidence.ingested events. - required: - - contentDigest - - artifactType - - subjectId - properties: - contentDigest: - type: string - description: SHA-256 digest of the evidence artifact - examples: - - sha256:abc123... - artifactType: - type: string - description: Gemara artifact type - examples: - - application/vnd.gemara.evaluation-log+json - storageRef: - type: string - description: Internal storage reference - subjectId: - type: string - description: Compliance subject identifier - examples: - - my-app-v1 - shardId: - type: string - description: Subject shard identifier (null when sharding is not configured) + messages: + EvidenceIngested: + name: EvidenceIngested + title: Evidence Ingested + contentType: application/cloudevents+json + payload: + $ref: '#/components/schemas/EvidenceIngestedCloudEvent' + schemas: + EvidenceIngestedCloudEvent: + type: object + description: CloudEvents v1.0 envelope for dev.complytime.evidence.ingested + required: + - specversion + - id + - type + - source + - subject + - time + - datacontenttype + - data + properties: + data: + $ref: '#/components/schemas/EvidenceIngestedData' + datacontenttype: + type: string + const: application/json + id: + type: string + format: uuid + source: + type: string + description: URI identifying the producing service + specversion: + type: string + const: "1.0" + subject: + type: string + description: The compliance subject identifier + time: + type: string + format: date-time + type: + type: string + const: dev.complytime.evidence.ingested + EvidenceIngestedData: + type: object + required: + - contentDigest + - artifactType + - subjectId + properties: + artifactType: + type: string + contentDigest: + type: string + shardId: + type: string + storageRef: + type: string + subjectId: + type: string diff --git a/cmd/asyncapi-gen/main.go b/cmd/asyncapi-gen/main.go index 3c05106..56e5f58 100644 --- a/cmd/asyncapi-gen/main.go +++ b/cmd/asyncapi-gen/main.go @@ -1,6 +1,45 @@ // SPDX-License-Identifier: Apache-2.0 -// Package main is the asyncapi-gen code generator binary. +// Command asyncapi-gen generates an AsyncAPI 3.0 document from annotated +// Go event structs. Run via go generate in the events package. package main -func main() {} +import ( + "flag" + "fmt" + "os" +) + +func main() { + input := flag.String("input", "", "Path to Go source file containing annotated event structs (required)") + output := flag.String("output", "", "Path to write the generated asyncapi.yaml (required)") + title := flag.String("title", "", "AsyncAPI document title (required)") + version := flag.String("version", "", "AsyncAPI document version (required)") + server := flag.String("server", "", "NATS server URL, e.g. nats://localhost:4222 (required)") + flag.Parse() + + if *input == "" || *output == "" || *title == "" || *version == "" || *server == "" { + fmt.Fprintln(os.Stderr, "asyncapi-gen: all flags are required: -input -output -title -version -server") + flag.Usage() + os.Exit(1) + } + + specs, err := ParseFile(*input) + if err != nil { + fmt.Fprintf(os.Stderr, "asyncapi-gen: parse error: %v\n", err) + os.Exit(1) + } + if len(specs) == 0 { + fmt.Fprintln(os.Stderr, "asyncapi-gen: no annotated structs found in input file") + os.Exit(1) + } + + doc := BuildDoc(specs, *title, *version, *server) + + if err := WriteYAML(doc, *output); err != nil { + fmt.Fprintf(os.Stderr, "asyncapi-gen: write error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("asyncapi-gen: wrote %s (%d event(s))\n", *output, len(specs)) +} diff --git a/events/events.go b/events/events.go index 0341641..ea41014 100644 --- a/events/events.go +++ b/events/events.go @@ -4,6 +4,8 @@ // evidence lifecycle. package events +//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -title "ComplyTime API Events" -version 0.1.0 -server nats://localhost:4222 + import ( "errors" "time" @@ -18,6 +20,9 @@ const TypeEvidenceIngested = "dev.complytime.evidence.ingested" // EvidenceIngestedData is the CloudEvents data payload for // evidence.ingested events. type EvidenceIngestedData struct { + //nolint:unused + _ struct{} `asyncapi:"channel:core.evidence.ingested.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.ingested,send:Published when evidence is accepted for processing,receive:Consume evidence-ingested events"` + ContentDigest string `json:"contentDigest"` ArtifactType string `json:"artifactType"` StorageRef string `json:"storageRef,omitempty"` From f838fcfd0aa32d65f6677fc812b74bc945c40986 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:20 -0400 Subject: [PATCH 08/11] test: add asyncapi-gen drift detection integration test Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- cmd/asyncapi-gen/integration_test.go | 80 ++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 cmd/asyncapi-gen/integration_test.go diff --git a/cmd/asyncapi-gen/integration_test.go b/cmd/asyncapi-gen/integration_test.go new file mode 100644 index 0000000..da99a42 --- /dev/null +++ b/cmd/asyncapi-gen/integration_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestIntegration_GeneratedMatchesCommitted regenerates asyncapi.yaml from +// events/events.go and verifies the output matches the committed file. +// This is the drift detector: it fails if the two are out of sync. +func TestIntegration_GeneratedMatchesCommitted(t *testing.T) { + // Path to the real events source, relative to this test file location. + inputPath := filepath.Join("..", "..", "events", "events.go") + committedPath := filepath.Join("..", "..", "api", "events", "asyncapi.yaml") + + specs, err := ParseFile(inputPath) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + doc := BuildDoc(specs, "ComplyTime API Events", "0.1.0", "nats://localhost:4222") + + outPath := filepath.Join(t.TempDir(), "asyncapi.yaml") + if err := WriteYAML(doc, outPath); err != nil { + t.Fatalf("WriteYAML: %v", err) + } + + generated, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("reading generated file: %v", err) + } + committed, err := os.ReadFile(committedPath) + if err != nil { + t.Fatalf("reading committed file: %v", err) + } + + if string(generated) != string(committed) { + t.Errorf("generated asyncapi.yaml does not match committed file.\n"+ + "Run `go generate ./events/...` to update it.\n\n"+ + "--- committed\n+++ generated\n%s", + diffStrings(string(committed), string(generated)), + ) + } +} + +// diffStrings returns a simple line-diff between a and b. +func diffStrings(a, b string) string { + aLines := splitLines(a) + bLines := splitLines(b) + var out []string + max := len(aLines) + if len(bLines) > max { + max = len(bLines) + } + for i := 0; i < max; i++ { + var al, bl string + if i < len(aLines) { + al = aLines[i] + } + if i < len(bLines) { + bl = bLines[i] + } + if al != bl { + out = append(out, fmt.Sprintf("line %d:\n committed: %q\n generated: %q", i+1, al, bl)) + } + } + if len(out) == 0 { + return "(no line differences found — may be whitespace)" + } + return strings.Join(out, "\n") +} + +func splitLines(s string) []string { + return strings.Split(s, "\n") +} From 7924cf903d62a57364aca395694c5eed6edbff9f Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:20 -0400 Subject: [PATCH 09/11] chore: add generate and asyncapi-lint tasks Adds generate, asyncapi-lint, and an expanded check task to Taskfile.yml. Fixes NATS binding: stream name moved to x-stream extension (valid per AsyncAPI spec extensions), bindingVersion set to 0.1.0 (was "latest"). Adds nolint directives for gosec G306 on intentional 0o644 file writes. Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- Taskfile.yml | 13 ++++++++++++- api/events/asyncapi.yaml | 4 ++-- cmd/asyncapi-gen/parser_test.go | 2 +- cmd/asyncapi-gen/schema.go | 7 +++++-- cmd/asyncapi-gen/writer.go | 2 +- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 28e4301..6e45e48 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -17,9 +17,20 @@ tasks: cmds: - go vet ./... + generate: + desc: Regenerate derived artifacts (asyncapi.yaml) + cmds: + - go generate ./events/... + + asyncapi-lint: + desc: Validate asyncapi.yaml with the AsyncAPI CLI + cmds: + - npx --yes @asyncapi/cli validate api/events/asyncapi.yaml + check: - desc: Run lint and tests + desc: Run lint, vet, tests, and asyncapi validation cmds: - task: lint - task: vet - task: test + - task: asyncapi-lint diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index b51c200..3340db7 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -30,8 +30,8 @@ operations: $ref: '#/channels/evidenceIngested' bindings: nats: - stream: EVIDENCE - bindingVersion: latest + x-stream: EVIDENCE + bindingVersion: 0.1.0 components: messages: EvidenceIngested: diff --git a/cmd/asyncapi-gen/parser_test.go b/cmd/asyncapi-gen/parser_test.go index 415e0cd..8f5d19a 100644 --- a/cmd/asyncapi-gen/parser_test.go +++ b/cmd/asyncapi-gen/parser_test.go @@ -99,7 +99,7 @@ type BadData struct { Name string ` + "`" + `json:"name"` + "`" + ` }` path := filepath.Join(t.TempDir(), "bad.go") - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { //nolint:gosec // 0o644 is correct for test fixture files (SC-005) t.Fatalf("WriteFile: %v", err) } _, err := ParseFile(path) diff --git a/cmd/asyncapi-gen/schema.go b/cmd/asyncapi-gen/schema.go index 4f9d309..38cef81 100644 --- a/cmd/asyncapi-gen/schema.go +++ b/cmd/asyncapi-gen/schema.go @@ -63,8 +63,11 @@ type OperationBinding struct { } // NATSOperationBinding holds NATS JetStream stream metadata. +// Stream is serialised as the AsyncAPI extension field x-stream because the +// official NATS binding 0.1.0 schema does not define a stream property; +// x-prefixed extensions are accepted by the AsyncAPI validator. type NATSOperationBinding struct { - Stream string `yaml:"stream,omitempty"` + Stream string `yaml:"x-stream,omitempty"` BindingVersion string `yaml:"bindingVersion,omitempty"` } @@ -134,7 +137,7 @@ func BuildDoc(specs []EventSpec, title, version, serverURL string) AsyncAPIDoc { Summary: spec.SendSummary, Channel: Ref{Ref: fmt.Sprintf("#/channels/%s", chKey)}, Bindings: OperationBinding{ - NATS: NATSOperationBinding{Stream: spec.Stream, BindingVersion: "latest"}, + NATS: NATSOperationBinding{Stream: spec.Stream, BindingVersion: "0.1.0"}, }, } diff --git a/cmd/asyncapi-gen/writer.go b/cmd/asyncapi-gen/writer.go index 8fb9a9e..1f93772 100644 --- a/cmd/asyncapi-gen/writer.go +++ b/cmd/asyncapi-gen/writer.go @@ -20,7 +20,7 @@ func WriteYAML(doc AsyncAPIDoc, path string) error { header := "# SPDX-License-Identifier: Apache-2.0\n" out := append([]byte(header), b...) - if err := os.WriteFile(path, out, 0o644); err != nil { + if err := os.WriteFile(path, out, 0o644); err != nil { //nolint:gosec // 0o644 is correct for generated YAML output files (SC-005) return fmt.Errorf("writing output file: %w", err) } return nil From fb87303c745a9c8b98983cf2d511073ab25dac2f Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:21 -0400 Subject: [PATCH 10/11] =?UTF-8?q?chore:=20go=20mod=20tidy=20=E2=80=94=20pr?= =?UTF-8?q?omote=20yaml.v3=20to=20direct=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- go.mod | 2 +- go.sum | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d8826a0..6df5f29 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.5 require ( github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/google/uuid v1.6.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -13,5 +14,4 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2f22d06..b578de5 100644 --- a/go.sum +++ b/go.sum @@ -10,11 +10,15 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -32,5 +36,7 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 46e9e40a3af34527cf37db620732b3fe3545ddc0 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 4 Aug 2026 20:57:21 -0400 Subject: [PATCH 11/11] feat: add description, license, and contact flags to asyncapi-gen Extends BuildDoc and the asyncapi-gen CLI with optional -description, -license, -contact-name, and -contact-url flags. Updates the go:generate directive in events/events.go and regenerates api/events/asyncapi.yaml with the full metadata matching the original hand-authored file. Assisted-by: Claude (Anthropic, Claude Sonnet 4.6) Signed-off-by: Jennifer Power --- api/events/asyncapi.yaml | 11 ++++++++++ cmd/asyncapi-gen/integration_test.go | 4 +++- cmd/asyncapi-gen/main.go | 6 ++++- cmd/asyncapi-gen/schema.go | 33 ++++++++++++++++++++++++---- cmd/asyncapi-gen/schema_test.go | 14 ++++++------ cmd/asyncapi-gen/writer_test.go | 4 ++-- events/events.go | 2 +- 7 files changed, 58 insertions(+), 16 deletions(-) diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index 3340db7..df3dca8 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -3,6 +3,17 @@ asyncapi: 3.0.0 info: title: ComplyTime API Events version: 0.1.0 + description: |- + Event contract for the ComplyTime evidence lifecycle. + + All public events use CloudEvents v1.0 envelope (JSON format). + The AsyncAPI spec is the source of truth for event contracts; + Go types in the events package must match these schemas. + license: + name: Apache-2.0 + contact: + name: ComplyTime + url: https://github.com/complytime/complyapi defaultContentType: application/cloudevents+json servers: nats: diff --git a/cmd/asyncapi-gen/integration_test.go b/cmd/asyncapi-gen/integration_test.go index da99a42..bec7c4f 100644 --- a/cmd/asyncapi-gen/integration_test.go +++ b/cmd/asyncapi-gen/integration_test.go @@ -23,7 +23,9 @@ func TestIntegration_GeneratedMatchesCommitted(t *testing.T) { t.Fatalf("ParseFile: %v", err) } - doc := BuildDoc(specs, "ComplyTime API Events", "0.1.0", "nats://localhost:4222") + doc := BuildDoc(specs, "ComplyTime API Events", "0.1.0", + "Event contract for the ComplyTime evidence lifecycle.\n\nAll public events use CloudEvents v1.0 envelope (JSON format).\nThe AsyncAPI spec is the source of truth for event contracts;\nGo types in the events package must match these schemas.", + "Apache-2.0", "ComplyTime", "https://github.com/complytime/complyapi", "nats://localhost:4222") outPath := filepath.Join(t.TempDir(), "asyncapi.yaml") if err := WriteYAML(doc, outPath); err != nil { diff --git a/cmd/asyncapi-gen/main.go b/cmd/asyncapi-gen/main.go index 56e5f58..a2e9ef8 100644 --- a/cmd/asyncapi-gen/main.go +++ b/cmd/asyncapi-gen/main.go @@ -16,6 +16,10 @@ func main() { title := flag.String("title", "", "AsyncAPI document title (required)") version := flag.String("version", "", "AsyncAPI document version (required)") server := flag.String("server", "", "NATS server URL, e.g. nats://localhost:4222 (required)") + description := flag.String("description", "", "AsyncAPI document description (optional)") + licenseName := flag.String("license", "", "License name, e.g. Apache-2.0 (optional)") + contactName := flag.String("contact-name", "", "Contact name (optional)") + contactURL := flag.String("contact-url", "", "Contact URL (optional)") flag.Parse() if *input == "" || *output == "" || *title == "" || *version == "" || *server == "" { @@ -34,7 +38,7 @@ func main() { os.Exit(1) } - doc := BuildDoc(specs, *title, *version, *server) + doc := BuildDoc(specs, *title, *version, *description, *licenseName, *contactName, *contactURL, *server) if err := WriteYAML(doc, *output); err != nil { fmt.Fprintf(os.Stderr, "asyncapi-gen: write error: %v\n", err) diff --git a/cmd/asyncapi-gen/schema.go b/cmd/asyncapi-gen/schema.go index 38cef81..de86742 100644 --- a/cmd/asyncapi-gen/schema.go +++ b/cmd/asyncapi-gen/schema.go @@ -21,8 +21,22 @@ type AsyncAPIDoc struct { // Info holds document metadata. type Info struct { - Title string `yaml:"title"` - Version string `yaml:"version"` + Title string `yaml:"title"` + Version string `yaml:"version"` + Description string `yaml:"description,omitempty"` + License *License `yaml:"license,omitempty"` + Contact *Contact `yaml:"contact,omitempty"` +} + +// License holds the license information for the AsyncAPI document. +type License struct { + Name string `yaml:"name"` +} + +// Contact holds the contact information for the AsyncAPI document. +type Contact struct { + Name string `yaml:"name,omitempty"` + URL string `yaml:"url,omitempty"` } // Server describes a NATS server. @@ -97,10 +111,21 @@ type Schema struct { } // BuildDoc constructs an AsyncAPIDoc from the given specs and document metadata. -func BuildDoc(specs []EventSpec, title, version, serverURL string) AsyncAPIDoc { +func BuildDoc(specs []EventSpec, title, version, description, licenseName, contactName, contactURL, serverURL string) AsyncAPIDoc { + info := Info{Title: title, Version: version} + if description != "" { + info.Description = description + } + if licenseName != "" { + info.License = &License{Name: licenseName} + } + if contactName != "" || contactURL != "" { + info.Contact = &Contact{Name: contactName, URL: contactURL} + } + doc := AsyncAPIDoc{ AsyncAPI: "3.0.0", - Info: Info{Title: title, Version: version}, + Info: info, DefaultContentType: "application/cloudevents+json", Servers: buildServers(serverURL), Channels: make(map[string]Channel), diff --git a/cmd/asyncapi-gen/schema_test.go b/cmd/asyncapi-gen/schema_test.go index 187809e..726fa1d 100644 --- a/cmd/asyncapi-gen/schema_test.go +++ b/cmd/asyncapi-gen/schema_test.go @@ -25,7 +25,7 @@ func singleSpec() EventSpec { } func TestBuildDoc_InfoFields(t *testing.T) { - doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "", "", "", "", "nats://localhost:4222") if doc.AsyncAPI != "3.0.0" { t.Errorf("AsyncAPI = %q, want %q", doc.AsyncAPI, "3.0.0") @@ -42,7 +42,7 @@ func TestBuildDoc_InfoFields(t *testing.T) { } func TestBuildDoc_ServerURL(t *testing.T) { - doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "", "", "", "", "nats://localhost:4222") if len(doc.Servers) != 1 { t.Fatalf("len(Servers) = %d, want 1", len(doc.Servers)) @@ -57,7 +57,7 @@ func TestBuildDoc_ServerURL(t *testing.T) { } func TestBuildDoc_Channel(t *testing.T) { - doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "", "", "", "", "nats://localhost:4222") ch, ok := doc.Channels["widgetCreated"] if !ok { @@ -76,7 +76,7 @@ func TestBuildDoc_Channel(t *testing.T) { } func TestBuildDoc_Operations(t *testing.T) { - doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "", "", "", "", "nats://localhost:4222") sendOp, ok := doc.Operations["publishWidgetCreated"] if !ok { @@ -102,7 +102,7 @@ func TestBuildDoc_Operations(t *testing.T) { } func TestBuildDoc_DataSchemaFields(t *testing.T) { - doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "", "", "", "", "nats://localhost:4222") dataSchema, ok := doc.Components.Schemas["WidgetCreatedData"] if !ok { @@ -137,7 +137,7 @@ func TestBuildDoc_DataSchemaFields(t *testing.T) { } func TestBuildDoc_CloudEventsEnvelope(t *testing.T) { - doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "", "", "", "", "nats://localhost:4222") env, ok := doc.Components.Schemas["WidgetCreatedCloudEvent"] if !ok { @@ -162,7 +162,7 @@ func TestBuildDoc_CloudEventsEnvelope(t *testing.T) { } func TestBuildDoc_NATSBinding(t *testing.T) { - doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "", "", "", "", "nats://localhost:4222") op, ok := doc.Operations["publishWidgetCreated"] if !ok { diff --git a/cmd/asyncapi-gen/writer_test.go b/cmd/asyncapi-gen/writer_test.go index 0c5446f..136063c 100644 --- a/cmd/asyncapi-gen/writer_test.go +++ b/cmd/asyncapi-gen/writer_test.go @@ -10,7 +10,7 @@ import ( ) func TestWriteYAML_CreatesFile(t *testing.T) { - doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "", "", "", "", "nats://localhost:4222") out := filepath.Join(t.TempDir(), "asyncapi.yaml") if err := WriteYAML(doc, out); err != nil { @@ -41,7 +41,7 @@ func TestWriteYAML_CreatesFile(t *testing.T) { } func TestWriteYAML_SpdxHeader(t *testing.T) { - doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "nats://localhost:4222") + doc := BuildDoc([]EventSpec{singleSpec()}, "Test API", "1.0.0", "", "", "", "", "nats://localhost:4222") out := filepath.Join(t.TempDir(), "asyncapi.yaml") if err := WriteYAML(doc, out); err != nil { diff --git a/events/events.go b/events/events.go index ea41014..2a750cd 100644 --- a/events/events.go +++ b/events/events.go @@ -4,7 +4,7 @@ // evidence lifecycle. package events -//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -title "ComplyTime API Events" -version 0.1.0 -server nats://localhost:4222 +//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -title "ComplyTime API Events" -version 0.1.0 -description "Event contract for the ComplyTime evidence lifecycle.\n\nAll public events use CloudEvents v1.0 envelope (JSON format).\nThe AsyncAPI spec is the source of truth for event contracts;\nGo types in the events package must match these schemas." -license Apache-2.0 -contact-name ComplyTime -contact-url https://github.com/complytime/complyapi -server nats://localhost:4222 import ( "errors"