backbeat: add module sources
This commit is contained in:
283
contracts/tests/integration/validator.go
Normal file
283
contracts/tests/integration/validator.go
Normal file
@@ -0,0 +1,283 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user