347 lines
8.3 KiB
Go
347 lines
8.3 KiB
Go
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
|
||
|
|
}
|