Files
poimen-workflows/internal/routing/validator.go
T

347 lines
8.3 KiB
Go
Raw Normal View History

package routing
import (
"fmt"
"time"
)
// ValidationError represents a single validation error
type ValidationError struct {
Path string // JSONPath where error occurred
Message string // Error message
}
// ValidationResult contains all validation errors
type ValidationResult struct {
Valid bool
Errors []ValidationError
}
// Validator validates WorkflowSpec and CronWorkflowSpec
type Validator struct {
kb *KnowledgeBase
}
// NewValidator creates a new validator with knowledge base
func NewValidator(kb *KnowledgeBase) *Validator {
return &Validator{kb: kb}
}
// ValidateWorkflowSpec validates a one-time workflow spec
func (v *Validator) ValidateWorkflowSpec(spec *WorkflowSpec) *ValidationResult {
result := &ValidationResult{
Valid: true,
Errors: []ValidationError{},
}
if spec == nil {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: "spec",
Message: "workflow spec cannot be nil",
})
return result
}
// Validate name
var errs []ValidationError
if spec.Name == "" {
result.Valid = false
errs = append(errs, ValidationError{
Path: "spec.name",
Message: "workflow name is required",
})
}
// Validate states
if len(spec.States) == 0 {
result.Valid = false
errs = append(errs, ValidationError{
Path: "spec.states",
Message: "at least one state is required",
})
}
// Validate each state
stateNames := make(map[string]bool)
for i, state := range spec.States {
path := fmt.Sprintf("spec.states[%d]", i)
if state.Name == "" {
result.Valid = false
errs = append(errs, ValidationError{
Path: path + ".name",
Message: "state name is required",
})
continue
}
if stateNames[state.Name] {
result.Valid = false
errs = append(errs, ValidationError{
Path: path + ".name",
Message: fmt.Sprintf("duplicate state name: %s", state.Name),
})
}
stateNames[state.Name] = true
// Validate state type
stateErrs := v.validateState(state, path)
if len(stateErrs) > 0 {
result.Valid = false
errs = append(errs, stateErrs...)
}
}
// Validate state transitions
for i, state := range spec.States {
path := fmt.Sprintf("spec.states[%d]", i)
// Check that Next state exists (if specified)
if state.Next != "" && !stateNames[state.Next] {
result.Valid = false
errs = append(errs, ValidationError{
Path: path + ".next",
Message: fmt.Sprintf("state '%s' does not exist", state.Next),
})
}
// Check that Catch targets exist
for j, catchClause := range state.Catch {
if catchClause.Next != "" && !stateNames[catchClause.Next] {
result.Valid = false
errs = append(errs, ValidationError{
Path: path + fmt.Sprintf(".catch[%d].next", j),
Message: fmt.Sprintf("state '%s' does not exist", catchClause.Next),
})
}
}
}
result.Errors = errs
return result
}
// ValidateCronWorkflowSpec validates a scheduled workflow spec
func (v *Validator) ValidateCronWorkflowSpec(spec *CronWorkflowSpec) *ValidationResult {
result := &ValidationResult{
Valid: true,
Errors: []ValidationError{},
}
if spec == nil {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: "spec",
Message: "cron workflow spec cannot be nil",
})
return result
}
// Validate name
if spec.Name == "" {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: "spec.name",
Message: "workflow name is required",
})
}
// Validate type
if spec.Type != "CronWorkflow" {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: "spec.type",
Message: "type must be 'CronWorkflow'",
})
}
// Validate cron expression
if spec.Schedule == "" {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: "spec.schedule",
Message: "cron schedule is required",
})
} else {
if err := validateCronExpression(spec.Schedule); err != nil {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: "spec.schedule",
Message: err.Error(),
})
}
}
// Validate timezone
if spec.Timezone == "" {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: "spec.timezone",
Message: "timezone is required",
})
} else {
if _, err := time.LoadLocation(spec.Timezone); err != nil {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: "spec.timezone",
Message: fmt.Sprintf("invalid timezone: %s", spec.Timezone),
})
}
}
// Validate maxConcurrent
if spec.MaxConcurrent < 1 {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: "spec.maxConcurrent",
Message: "maxConcurrent must be >= 1",
})
}
// Validate states (same as one-time workflow)
if len(spec.States) == 0 {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: "spec.states",
Message: "at least one state is required",
})
}
stateNames := make(map[string]bool)
for i, state := range spec.States {
path := fmt.Sprintf("spec.states[%d]", i)
if state.Name == "" {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: path + ".name",
Message: "state name is required",
})
continue
}
if stateNames[state.Name] {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: path + ".name",
Message: fmt.Sprintf("duplicate state name: %s", state.Name),
})
}
stateNames[state.Name] = true
stateErrs := v.validateState(state, path)
if len(stateErrs) > 0 {
result.Valid = false
result.Errors = append(result.Errors, stateErrs...)
}
}
// Validate state transitions
for i, state := range spec.States {
path := fmt.Sprintf("spec.states[%d]", i)
if state.Next != "" && !stateNames[state.Next] {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: path + ".next",
Message: fmt.Sprintf("state '%s' does not exist", state.Next),
})
}
for j, catchClause := range state.Catch {
if catchClause.Next != "" && !stateNames[catchClause.Next] {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Path: path + fmt.Sprintf(".catch[%d].next", j),
Message: fmt.Sprintf("state '%s' does not exist", catchClause.Next),
})
}
}
}
return result
}
// validateState validates a single state
func (v *Validator) validateState(state State, path string) []ValidationError {
var errs []ValidationError
switch state.Type {
case StateTypeTask:
if state.Resource == "" {
errs = append(errs, ValidationError{
Path: path + ".resource",
Message: "resource is required for Task state",
})
} else {
// Check if activity exists in knowledge base
if v.kb != nil && !v.kb.HasActivity(state.Resource) {
errs = append(errs, ValidationError{
Path: path + ".resource",
Message: fmt.Sprintf("activity '%s' not found in knowledge base", state.Resource),
})
}
}
// Validate timeout format
if state.Timeout != "" {
if err := validateDuration(state.Timeout); err != nil {
errs = append(errs, ValidationError{
Path: path + ".timeout",
Message: err.Error(),
})
}
}
case StateTypePass:
// Pass state is valid with just a result
if state.Result == nil {
errs = append(errs, ValidationError{
Path: path + ".result",
Message: "result is required for Pass state",
})
}
case StateTypeFail:
// Fail state requires error
if state.Error == "" {
errs = append(errs, ValidationError{
Path: path + ".error",
Message: "error is required for Fail state",
})
}
default:
errs = append(errs, ValidationError{
Path: path + ".type",
Message: fmt.Sprintf("invalid state type: %s", state.Type),
})
}
return errs
}
// validateDuration validates Go duration string (e.g., "5m", "30s")
func validateDuration(dur string) error {
_, err := time.ParseDuration(dur)
if err != nil {
return fmt.Errorf("invalid duration: %s", dur)
}
return nil
}
// String returns a human-readable validation result
func (vr *ValidationResult) String() string {
if vr.Valid {
return "Valid ✓"
}
msg := fmt.Sprintf("Invalid ✗ (%d errors):\n", len(vr.Errors))
for _, err := range vr.Errors {
msg += fmt.Sprintf(" %s: %s\n", err.Path, err.Message)
}
return msg
}