// Package integration provides CI validation helpers for BACKBEAT conformance testing package integration import ( "encoding/json" "fmt" "path/filepath" "strings" "github.com/xeipuuv/gojsonschema" ) // MessageValidator provides validation for BACKBEAT messages against JSON schemas type MessageValidator struct { schemaLoaders map[string]gojsonschema.JSONLoader } // MessageType constants for the three core BACKBEAT interfaces const ( BeatFrameType = "backbeat.beatframe.v1" StatusClaimType = "backbeat.statusclaim.v1" BarReportType = "backbeat.barreport.v1" ) // ValidationError represents a validation failure with context type ValidationError struct { MessageType string `json:"message_type"` Field string `json:"field"` Value string `json:"value"` Message string `json:"message"` Errors []string `json:"errors"` } func (ve ValidationError) Error() string { return fmt.Sprintf("validation failed for %s: %s", ve.MessageType, strings.Join(ve.Errors, "; ")) } // ValidationResult contains the outcome of message validation type ValidationResult struct { Valid bool `json:"valid"` MessageType string `json:"message_type"` Errors []ValidationError `json:"errors,omitempty"` SchemaVersion string `json:"schema_version"` } // NewMessageValidator creates a new validator with schema loaders func NewMessageValidator(schemaDir string) (*MessageValidator, error) { validator := &MessageValidator{ schemaLoaders: make(map[string]gojsonschema.JSONLoader), } // Load all schema files schemas := map[string]string{ BeatFrameType: "beatframe-v1.schema.json", StatusClaimType: "statusclaim-v1.schema.json", BarReportType: "barreport-v1.schema.json", } for msgType, schemaFile := range schemas { schemaPath := filepath.Join(schemaDir, schemaFile) loader := gojsonschema.NewReferenceLoader("file://" + schemaPath) validator.schemaLoaders[msgType] = loader } return validator, nil } // ValidateMessage validates a JSON message against the appropriate BACKBEAT schema func (v *MessageValidator) ValidateMessage(messageJSON []byte) (*ValidationResult, error) { // Parse message to determine type var msgMap map[string]interface{} if err := json.Unmarshal(messageJSON, &msgMap); err != nil { return nil, fmt.Errorf("failed to parse JSON: %w", err) } msgType, ok := msgMap["type"].(string) if !ok { return &ValidationResult{ Valid: false, MessageType: "unknown", Errors: []ValidationError{ { Field: "type", Message: "message type field is missing or not a string", Errors: []string{"type field is required and must be a string"}, }, }, }, nil } // Get appropriate schema loader schemaLoader, exists := v.schemaLoaders[msgType] if !exists { return &ValidationResult{ Valid: false, MessageType: msgType, Errors: []ValidationError{ { Field: "type", Value: msgType, Message: fmt.Sprintf("unsupported message type: %s", msgType), Errors: []string{fmt.Sprintf("message type %s is not supported by BACKBEAT contracts", msgType)}, }, }, }, nil } // Validate against schema docLoader := gojsonschema.NewBytesLoader(messageJSON) result, err := gojsonschema.Validate(schemaLoader, docLoader) if err != nil { return nil, fmt.Errorf("schema validation failed: %w", err) } validationResult := &ValidationResult{ Valid: result.Valid(), MessageType: msgType, SchemaVersion: getSchemaVersion(msgType), } if !result.Valid() { for _, desc := range result.Errors() { validationResult.Errors = append(validationResult.Errors, ValidationError{ MessageType: msgType, Field: desc.Field(), Value: fmt.Sprintf("%v", desc.Value()), Message: desc.Description(), Errors: []string{desc.String()}, }) } } return validationResult, nil } // ValidateMessageString validates a JSON message string func (v *MessageValidator) ValidateMessageString(messageJSON string) (*ValidationResult, error) { return v.ValidateMessage([]byte(messageJSON)) } // ValidateStruct validates a Go struct by marshaling to JSON first func (v *MessageValidator) ValidateStruct(message interface{}) (*ValidationResult, error) { jsonBytes, err := json.Marshal(message) if err != nil { return nil, fmt.Errorf("failed to marshal struct to JSON: %w", err) } return v.ValidateMessage(jsonBytes) } // BatchValidate validates multiple messages and returns aggregated results func (v *MessageValidator) BatchValidate(messages [][]byte) ([]*ValidationResult, error) { results := make([]*ValidationResult, len(messages)) for i, msg := range messages { result, err := v.ValidateMessage(msg) if err != nil { return nil, fmt.Errorf("failed to validate message %d: %w", i, err) } results[i] = result } return results, nil } // GetSupportedMessageTypes returns the list of supported BACKBEAT message types func (v *MessageValidator) GetSupportedMessageTypes() []string { types := make([]string, 0, len(v.schemaLoaders)) for msgType := range v.schemaLoaders { types = append(types, msgType) } return types } // IsMessageTypeSupported checks if a message type is supported func (v *MessageValidator) IsMessageTypeSupported(msgType string) bool { _, exists := v.schemaLoaders[msgType] return exists } // getSchemaVersion returns the version for a given message type func getSchemaVersion(msgType string) string { versions := map[string]string{ BeatFrameType: "1.0.0", StatusClaimType: "1.0.0", BarReportType: "1.0.0", } return versions[msgType] } // ValidationStats provides summary statistics for batch validation type ValidationStats struct { TotalMessages int `json:"total_messages"` ValidMessages int `json:"valid_messages"` InvalidMessages int `json:"invalid_messages"` MessageTypes map[string]int `json:"message_types"` ErrorSummary map[string]int `json:"error_summary"` ValidationRate float64 `json:"validation_rate"` } // GetValidationStats computes statistics from validation results func GetValidationStats(results []*ValidationResult) *ValidationStats { stats := &ValidationStats{ TotalMessages: len(results), MessageTypes: make(map[string]int), ErrorSummary: make(map[string]int), } for _, result := range results { // Count message types stats.MessageTypes[result.MessageType]++ if result.Valid { stats.ValidMessages++ } else { stats.InvalidMessages++ // Aggregate error types for _, err := range result.Errors { stats.ErrorSummary[err.Field]++ } } } if stats.TotalMessages > 0 { stats.ValidationRate = float64(stats.ValidMessages) / float64(stats.TotalMessages) } return stats } // ExampleMessages provides sample messages for testing and documentation func ExampleMessages() map[string]interface{} { return map[string]interface{}{ "beatframe_minimal": map[string]interface{}{ "type": BeatFrameType, "cluster_id": "test-cluster", "beat_index": 0, "downbeat": true, "phase": "plan", "hlc": "0001:0000:cafe", "deadline_at": "2025-09-05T12:00:30Z", "tempo_bpm": 2.0, "window_id": "a1b2c3d4e5f6789012345678901234ab", }, "statusclaim_minimal": map[string]interface{}{ "type": StatusClaimType, "agent_id": "test:agent", "beat_index": 100, "state": "idle", "hlc": "7ffd:0001:abcd", }, "barreport_minimal": map[string]interface{}{ "type": BarReportType, "window_id": "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5", "from_beat": 0, "to_beat": 119, "agents_reporting": 1, "on_time_reviews": 1, "help_promises_fulfilled": 0, "secret_rotations_ok": true, "tempo_drift_ms": 0.0, }, } } // PrettyPrintValidationResult formats validation results for human reading func PrettyPrintValidationResult(result *ValidationResult) string { var sb strings.Builder sb.WriteString(fmt.Sprintf("Message Type: %s\n", result.MessageType)) sb.WriteString(fmt.Sprintf("Schema Version: %s\n", result.SchemaVersion)) sb.WriteString(fmt.Sprintf("Valid: %t\n", result.Valid)) if !result.Valid && len(result.Errors) > 0 { sb.WriteString("\nValidation Errors:\n") for i, err := range result.Errors { sb.WriteString(fmt.Sprintf(" %d. Field: %s\n", i+1, err.Field)) if err.Value != "" { sb.WriteString(fmt.Sprintf(" Value: %s\n", err.Value)) } sb.WriteString(fmt.Sprintf(" Error: %s\n", err.Message)) } } return sb.String() }