283 lines
8.5 KiB
Go
283 lines
8.5 KiB
Go
// 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()
|
|
} |