Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
213 changes: 99 additions & 114 deletions api/events/asyncapi.yaml
Original file line number Diff line number Diff line change
@@ -1,122 +1,107 @@
# 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
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:
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:
x-stream: EVIDENCE
bindingVersion: 0.1.0
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
82 changes: 82 additions & 0 deletions cmd/asyncapi-gen/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// 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",
"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 {
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")
}
49 changes: 49 additions & 0 deletions cmd/asyncapi-gen/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: Apache-2.0

// Command asyncapi-gen generates an AsyncAPI 3.0 document from annotated
// Go event structs. Run via go generate in the events package.
package 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)")
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 == "" {
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, *description, *licenseName, *contactName, *contactURL, *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))
}
Loading