backbeat: add module sources
This commit is contained in:
206
contracts/tests/integration/Makefile
Normal file
206
contracts/tests/integration/Makefile
Normal file
@@ -0,0 +1,206 @@
|
||||
# BACKBEAT Contracts CI Integration Makefile
|
||||
|
||||
# Variables
|
||||
SCHEMA_DIR = ../../schemas
|
||||
EXAMPLES_DIR = ../examples
|
||||
CLI_TOOL = ./cmd/backbeat-validate
|
||||
BINARY_NAME = backbeat-validate
|
||||
|
||||
# Default target
|
||||
.PHONY: all
|
||||
all: build test
|
||||
|
||||
# Build the CLI validation tool
|
||||
.PHONY: build
|
||||
build:
|
||||
@echo "Building BACKBEAT validation CLI tool..."
|
||||
go build -o $(BINARY_NAME) $(CLI_TOOL)
|
||||
|
||||
# Run all tests
|
||||
.PHONY: test
|
||||
test: test-schemas test-examples test-integration
|
||||
|
||||
# Test schema files are valid
|
||||
.PHONY: test-schemas
|
||||
test-schemas:
|
||||
@echo "Testing JSON schema files..."
|
||||
@for schema in $(SCHEMA_DIR)/*.schema.json; do \
|
||||
echo "Validating schema: $$schema"; \
|
||||
python3 -c "import json; json.load(open('$$schema'))" || exit 1; \
|
||||
done
|
||||
|
||||
# Test all example files
|
||||
.PHONY: test-examples
|
||||
test-examples: build
|
||||
@echo "Testing example messages..."
|
||||
./$(BINARY_NAME) --schemas $(SCHEMA_DIR) --dir $(EXAMPLES_DIR)
|
||||
|
||||
# Run Go integration tests
|
||||
.PHONY: test-integration
|
||||
test-integration:
|
||||
@echo "Running Go integration tests..."
|
||||
go test -v ./...
|
||||
|
||||
# Validate built-in examples
|
||||
.PHONY: validate-examples
|
||||
validate-examples: build
|
||||
@echo "Validating built-in examples..."
|
||||
./$(BINARY_NAME) --schemas $(SCHEMA_DIR) --examples
|
||||
|
||||
# Validate a specific directory (for CI use)
|
||||
.PHONY: validate-dir
|
||||
validate-dir: build
|
||||
@if [ -z "$(DIR)" ]; then \
|
||||
echo "Usage: make validate-dir DIR=/path/to/messages"; \
|
||||
exit 1; \
|
||||
fi
|
||||
./$(BINARY_NAME) --schemas $(SCHEMA_DIR) --dir $(DIR) --exit-code
|
||||
|
||||
# Validate a specific file (for CI use)
|
||||
.PHONY: validate-file
|
||||
validate-file: build
|
||||
@if [ -z "$(FILE)" ]; then \
|
||||
echo "Usage: make validate-file FILE=/path/to/message.json"; \
|
||||
exit 1; \
|
||||
fi
|
||||
./$(BINARY_NAME) --schemas $(SCHEMA_DIR) --file $(FILE) --exit-code
|
||||
|
||||
# Clean build artifacts
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -f $(BINARY_NAME)
|
||||
|
||||
# Install dependencies
|
||||
.PHONY: deps
|
||||
deps:
|
||||
go mod tidy
|
||||
go mod download
|
||||
|
||||
# Format Go code
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
# Run static analysis
|
||||
.PHONY: lint
|
||||
lint:
|
||||
go vet ./...
|
||||
|
||||
# Generate CI configuration examples
|
||||
.PHONY: examples
|
||||
examples: generate-github-actions generate-gitlab-ci generate-makefile-example
|
||||
|
||||
# Generate GitHub Actions workflow
|
||||
.PHONY: generate-github-actions
|
||||
generate-github-actions:
|
||||
@echo "Generating GitHub Actions workflow..."
|
||||
@mkdir -p ci-examples
|
||||
@cat > ci-examples/github-actions.yml << 'EOF'\
|
||||
name: BACKBEAT Contract Validation\
|
||||
\
|
||||
on:\
|
||||
push:\
|
||||
branches: [ main, develop ]\
|
||||
pull_request:\
|
||||
branches: [ main ]\
|
||||
\
|
||||
jobs:\
|
||||
validate-backbeat-messages:\
|
||||
runs-on: ubuntu-latest\
|
||||
\
|
||||
steps:\
|
||||
- uses: actions/checkout@v4\
|
||||
with:\
|
||||
repository: 'chorus-services/backbeat'\
|
||||
path: 'backbeat-contracts'\
|
||||
\
|
||||
- uses: actions/checkout@v4\
|
||||
with:\
|
||||
path: 'current-repo'\
|
||||
\
|
||||
- name: Set up Go\
|
||||
uses: actions/setup-go@v4\
|
||||
with:\
|
||||
go-version: '1.22'\
|
||||
\
|
||||
- name: Build BACKBEAT validator\
|
||||
run: |\
|
||||
cd backbeat-contracts/contracts/tests/integration\
|
||||
make build\
|
||||
\
|
||||
- name: Validate BACKBEAT messages\
|
||||
run: |\
|
||||
cd backbeat-contracts/contracts/tests/integration\
|
||||
./backbeat-validate \\\
|
||||
--schemas ../../schemas \\\
|
||||
--dir ../../../current-repo/path/to/messages \\\
|
||||
--exit-code\
|
||||
EOF
|
||||
|
||||
# Generate GitLab CI configuration
|
||||
.PHONY: generate-gitlab-ci
|
||||
generate-gitlab-ci:
|
||||
@echo "Generating GitLab CI configuration..."
|
||||
@mkdir -p ci-examples
|
||||
@cat > ci-examples/gitlab-ci.yml << 'EOF'\
|
||||
validate-backbeat-contracts:\
|
||||
stage: test\
|
||||
image: golang:1.22\
|
||||
\
|
||||
before_script:\
|
||||
- git clone https://github.com/chorus-services/backbeat.git /tmp/backbeat\
|
||||
- cd /tmp/backbeat/contracts/tests/integration\
|
||||
- make deps build\
|
||||
\
|
||||
script:\
|
||||
- /tmp/backbeat/contracts/tests/integration/backbeat-validate \\\
|
||||
--schemas /tmp/backbeat/contracts/schemas \\\
|
||||
--dir $$CI_PROJECT_DIR/path/to/messages \\\
|
||||
--exit-code\
|
||||
\
|
||||
only:\
|
||||
- merge_requests\
|
||||
- main\
|
||||
- develop\
|
||||
EOF
|
||||
|
||||
# Generate example Makefile for downstream projects
|
||||
.PHONY: generate-makefile-example
|
||||
generate-makefile-example:
|
||||
@echo "Generating example Makefile for downstream projects..."
|
||||
@mkdir -p ci-examples
|
||||
@echo "# Example Makefile for BACKBEAT contract validation" > ci-examples/downstream-makefile
|
||||
@echo "" >> ci-examples/downstream-makefile
|
||||
@echo "BACKBEAT_REPO = https://github.com/chorus-services/backbeat.git" >> ci-examples/downstream-makefile
|
||||
@echo "BACKBEAT_DIR = .backbeat-contracts" >> ci-examples/downstream-makefile
|
||||
@echo "" >> ci-examples/downstream-makefile
|
||||
@echo "validate-backbeat:" >> ci-examples/downstream-makefile
|
||||
@echo " git clone \$$(BACKBEAT_REPO) \$$(BACKBEAT_DIR) 2>/dev/null || true" >> ci-examples/downstream-makefile
|
||||
@echo " cd \$$(BACKBEAT_DIR)/contracts/tests/integration && make build" >> ci-examples/downstream-makefile
|
||||
@echo " \$$(BACKBEAT_DIR)/contracts/tests/integration/backbeat-validate --schemas \$$(BACKBEAT_DIR)/contracts/schemas --dir messages --exit-code" >> ci-examples/downstream-makefile
|
||||
|
||||
# Help target
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "BACKBEAT Contracts CI Integration Makefile"
|
||||
@echo ""
|
||||
@echo "Available targets:"
|
||||
@echo " all - Build and test everything"
|
||||
@echo " build - Build the CLI validation tool"
|
||||
@echo " test - Run all tests"
|
||||
@echo " test-schemas - Validate JSON schema files"
|
||||
@echo " test-examples - Test example message files"
|
||||
@echo " test-integration - Run Go integration tests"
|
||||
@echo " validate-examples - Validate built-in examples"
|
||||
@echo " validate-dir DIR=path - Validate messages in directory"
|
||||
@echo " validate-file FILE=path - Validate single message file"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " deps - Install Go dependencies"
|
||||
@echo " fmt - Format Go code"
|
||||
@echo " lint - Run static analysis"
|
||||
@echo " examples - Generate CI configuration examples"
|
||||
@echo " help - Show this help message"
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make validate-dir DIR=../../../examples"
|
||||
@echo " make validate-file FILE=../../../examples/beatframe-valid.json"
|
||||
279
contracts/tests/integration/ci_helper.go
Normal file
279
contracts/tests/integration/ci_helper.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Package integration provides CI helper functions for BACKBEAT contract testing
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CIHelper provides utilities for continuous integration testing
|
||||
type CIHelper struct {
|
||||
validator *MessageValidator
|
||||
}
|
||||
|
||||
// NewCIHelper creates a new CI helper with a message validator
|
||||
func NewCIHelper(schemaDir string) (*CIHelper, error) {
|
||||
validator, err := NewMessageValidator(schemaDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create validator: %w", err)
|
||||
}
|
||||
|
||||
return &CIHelper{
|
||||
validator: validator,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDirectory validates all JSON files in a directory against BACKBEAT schemas
|
||||
func (ci *CIHelper) ValidateDirectory(dir string) (*DirectoryValidationResult, error) {
|
||||
result := &DirectoryValidationResult{
|
||||
Directory: dir,
|
||||
Files: make(map[string]*FileValidationResult),
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip non-JSON files
|
||||
if d.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".json") {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileResult, validateErr := ci.validateFile(path)
|
||||
if validateErr != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Failed to validate %s: %v", path, validateErr))
|
||||
} else {
|
||||
relPath, _ := filepath.Rel(dir, path)
|
||||
result.Files[relPath] = fileResult
|
||||
result.TotalFiles++
|
||||
if fileResult.AllValid {
|
||||
result.ValidFiles++
|
||||
} else {
|
||||
result.InvalidFiles++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to walk directory: %w", err)
|
||||
}
|
||||
|
||||
result.ValidationRate = float64(result.ValidFiles) / float64(result.TotalFiles)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// validateFile validates a single JSON file
|
||||
func (ci *CIHelper) validateFile(filePath string) (*FileValidationResult, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
result := &FileValidationResult{
|
||||
FilePath: filePath,
|
||||
AllValid: true,
|
||||
}
|
||||
|
||||
// Try to parse as single message first
|
||||
var singleMessage map[string]interface{}
|
||||
if err := json.Unmarshal(data, &singleMessage); err == nil {
|
||||
if msgType, hasType := singleMessage["type"].(string); hasType && ci.validator.IsMessageTypeSupported(msgType) {
|
||||
// Single BACKBEAT message
|
||||
validationResult, validateErr := ci.validator.ValidateMessage(data)
|
||||
if validateErr != nil {
|
||||
return nil, validateErr
|
||||
}
|
||||
result.Messages = []*ValidationResult{validationResult}
|
||||
result.AllValid = validationResult.Valid
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as array of messages
|
||||
var messageArray []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &messageArray); err == nil {
|
||||
for i, msg := range messageArray {
|
||||
msgBytes, marshalErr := json.Marshal(msg)
|
||||
if marshalErr != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Message %d: failed to marshal: %v", i, marshalErr))
|
||||
result.AllValid = false
|
||||
continue
|
||||
}
|
||||
|
||||
validationResult, validateErr := ci.validator.ValidateMessage(msgBytes)
|
||||
if validateErr != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Message %d: validation error: %v", i, validateErr))
|
||||
result.AllValid = false
|
||||
continue
|
||||
}
|
||||
|
||||
result.Messages = append(result.Messages, validationResult)
|
||||
if !validationResult.Valid {
|
||||
result.AllValid = false
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Try to parse as examples format (array with description and message fields)
|
||||
var examples []ExampleMessage
|
||||
if err := json.Unmarshal(data, &examples); err == nil {
|
||||
for i, example := range examples {
|
||||
msgBytes, marshalErr := json.Marshal(example.Message)
|
||||
if marshalErr != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Example %d (%s): failed to marshal: %v", i, example.Description, marshalErr))
|
||||
result.AllValid = false
|
||||
continue
|
||||
}
|
||||
|
||||
validationResult, validateErr := ci.validator.ValidateMessage(msgBytes)
|
||||
if validateErr != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Example %d (%s): validation error: %v", i, example.Description, validateErr))
|
||||
result.AllValid = false
|
||||
continue
|
||||
}
|
||||
|
||||
result.Messages = append(result.Messages, validationResult)
|
||||
if !validationResult.Valid {
|
||||
result.AllValid = false
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("file does not contain valid JSON message format")
|
||||
}
|
||||
|
||||
// ExampleMessage represents a message example with description
|
||||
type ExampleMessage struct {
|
||||
Description string `json:"description"`
|
||||
Message map[string]interface{} `json:"message"`
|
||||
}
|
||||
|
||||
// DirectoryValidationResult contains results for validating a directory
|
||||
type DirectoryValidationResult struct {
|
||||
Directory string `json:"directory"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
ValidFiles int `json:"valid_files"`
|
||||
InvalidFiles int `json:"invalid_files"`
|
||||
ValidationRate float64 `json:"validation_rate"`
|
||||
Files map[string]*FileValidationResult `json:"files"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// FileValidationResult contains results for validating a single file
|
||||
type FileValidationResult struct {
|
||||
FilePath string `json:"file_path"`
|
||||
AllValid bool `json:"all_valid"`
|
||||
Messages []*ValidationResult `json:"messages"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateCIReport generates a formatted report suitable for CI systems
|
||||
func (ci *CIHelper) GenerateCIReport(result *DirectoryValidationResult) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("BACKBEAT Contract Validation Report\n")
|
||||
sb.WriteString("===================================\n\n")
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Directory: %s\n", result.Directory))
|
||||
sb.WriteString(fmt.Sprintf("Total Files: %d\n", result.TotalFiles))
|
||||
sb.WriteString(fmt.Sprintf("Valid Files: %d\n", result.ValidFiles))
|
||||
sb.WriteString(fmt.Sprintf("Invalid Files: %d\n", result.InvalidFiles))
|
||||
sb.WriteString(fmt.Sprintf("Validation Rate: %.2f%%\n\n", result.ValidationRate*100))
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
sb.WriteString("Directory-level Errors:\n")
|
||||
for _, err := range result.Errors {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", err))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Group files by validation status
|
||||
validFiles := make([]string, 0)
|
||||
invalidFiles := make([]string, 0)
|
||||
|
||||
for filePath, fileResult := range result.Files {
|
||||
if fileResult.AllValid {
|
||||
validFiles = append(validFiles, filePath)
|
||||
} else {
|
||||
invalidFiles = append(invalidFiles, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validFiles) > 0 {
|
||||
sb.WriteString("Valid Files:\n")
|
||||
for _, file := range validFiles {
|
||||
sb.WriteString(fmt.Sprintf(" ✓ %s\n", file))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(invalidFiles) > 0 {
|
||||
sb.WriteString("Invalid Files:\n")
|
||||
for _, file := range invalidFiles {
|
||||
fileResult := result.Files[file]
|
||||
sb.WriteString(fmt.Sprintf(" ✗ %s\n", file))
|
||||
|
||||
for _, err := range fileResult.Errors {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", err))
|
||||
}
|
||||
|
||||
for i, msg := range fileResult.Messages {
|
||||
if !msg.Valid {
|
||||
sb.WriteString(fmt.Sprintf(" Message %d (%s):\n", i+1, msg.MessageType))
|
||||
for _, valErr := range msg.Errors {
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %s\n", valErr.Field, valErr.Message))
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ExitWithStatus exits the program with appropriate status code for CI
|
||||
func (ci *CIHelper) ExitWithStatus(result *DirectoryValidationResult) {
|
||||
if result.InvalidFiles > 0 || len(result.Errors) > 0 {
|
||||
fmt.Fprint(os.Stderr, ci.GenerateCIReport(result))
|
||||
os.Exit(1)
|
||||
} else {
|
||||
fmt.Print(ci.GenerateCIReport(result))
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateExamples validates the built-in example messages
|
||||
func (ci *CIHelper) ValidateExamples() ([]*ValidationResult, error) {
|
||||
examples := ExampleMessages()
|
||||
results := make([]*ValidationResult, 0, len(examples))
|
||||
|
||||
for name, example := range examples {
|
||||
result, err := ci.validator.ValidateStruct(example)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to validate example %s: %w", name, err)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetSchemaInfo returns information about loaded schemas
|
||||
func (ci *CIHelper) GetSchemaInfo() map[string]string {
|
||||
info := make(map[string]string)
|
||||
for _, msgType := range ci.validator.GetSupportedMessageTypes() {
|
||||
info[msgType] = getSchemaVersion(msgType)
|
||||
}
|
||||
return info
|
||||
}
|
||||
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