You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							360 lines
						
					
					
						
							9.2 KiB
						
					
					
				
			
		
		
		
			
			
			
		
		
	
	
							360 lines
						
					
					
						
							9.2 KiB
						
					
					
				| package config | |
| 
 | |
| import ( | |
| 	"fmt" | |
| 	"reflect" | |
| 	"strings" | |
| 	"time" | |
| ) | |
| 
 | |
| // ConfigWithDefaults defines an interface for configurations that can apply their own defaults | |
| type ConfigWithDefaults interface { | |
| 	// ApplySchemaDefaults applies default values using the provided schema | |
| 	ApplySchemaDefaults(schema *Schema) error | |
| 	// Validate validates the configuration | |
| 	Validate() error | |
| } | |
| 
 | |
| // FieldType defines the type of a configuration field | |
| type FieldType string | |
| 
 | |
| const ( | |
| 	FieldTypeBool     FieldType = "bool" | |
| 	FieldTypeInt      FieldType = "int" | |
| 	FieldTypeDuration FieldType = "duration" | |
| 	FieldTypeInterval FieldType = "interval" | |
| 	FieldTypeString   FieldType = "string" | |
| 	FieldTypeFloat    FieldType = "float" | |
| ) | |
| 
 | |
| // FieldUnit defines the unit for display purposes | |
| type FieldUnit string | |
| 
 | |
| const ( | |
| 	UnitSeconds FieldUnit = "seconds" | |
| 	UnitMinutes FieldUnit = "minutes" | |
| 	UnitHours   FieldUnit = "hours" | |
| 	UnitDays    FieldUnit = "days" | |
| 	UnitCount   FieldUnit = "count" | |
| 	UnitNone    FieldUnit = "" | |
| ) | |
| 
 | |
| // Field defines a configuration field with all its metadata | |
| type Field struct { | |
| 	// Field identification | |
| 	Name     string    `json:"name"` | |
| 	JSONName string    `json:"json_name"` | |
| 	Type     FieldType `json:"type"` | |
| 
 | |
| 	// Default value and validation | |
| 	DefaultValue interface{} `json:"default_value"` | |
| 	MinValue     interface{} `json:"min_value,omitempty"` | |
| 	MaxValue     interface{} `json:"max_value,omitempty"` | |
| 	Required     bool        `json:"required"` | |
| 
 | |
| 	// UI display | |
| 	DisplayName string    `json:"display_name"` | |
| 	Description string    `json:"description"` | |
| 	HelpText    string    `json:"help_text"` | |
| 	Placeholder string    `json:"placeholder"` | |
| 	Unit        FieldUnit `json:"unit"` | |
| 
 | |
| 	// Form rendering | |
| 	InputType  string `json:"input_type"` // "checkbox", "number", "text", "interval", etc. | |
| 	CSSClasses string `json:"css_classes,omitempty"` | |
| } | |
| 
 | |
| // GetDisplayValue returns the value formatted for display in the specified unit | |
| func (f *Field) GetDisplayValue(value interface{}) interface{} { | |
| 	if (f.Type == FieldTypeDuration || f.Type == FieldTypeInterval) && f.Unit != UnitSeconds { | |
| 		if duration, ok := value.(time.Duration); ok { | |
| 			switch f.Unit { | |
| 			case UnitMinutes: | |
| 				return int(duration.Minutes()) | |
| 			case UnitHours: | |
| 				return int(duration.Hours()) | |
| 			case UnitDays: | |
| 				return int(duration.Hours() / 24) | |
| 			} | |
| 		} | |
| 		if seconds, ok := value.(int); ok { | |
| 			switch f.Unit { | |
| 			case UnitMinutes: | |
| 				return seconds / 60 | |
| 			case UnitHours: | |
| 				return seconds / 3600 | |
| 			case UnitDays: | |
| 				return seconds / (24 * 3600) | |
| 			} | |
| 		} | |
| 	} | |
| 	return value | |
| } | |
| 
 | |
| // GetIntervalDisplayValue returns the value and unit for interval fields | |
| func (f *Field) GetIntervalDisplayValue(value interface{}) (int, string) { | |
| 	if f.Type != FieldTypeInterval { | |
| 		return 0, "minutes" | |
| 	} | |
| 
 | |
| 	seconds := 0 | |
| 	if duration, ok := value.(time.Duration); ok { | |
| 		seconds = int(duration.Seconds()) | |
| 	} else if s, ok := value.(int); ok { | |
| 		seconds = s | |
| 	} | |
| 
 | |
| 	return SecondsToIntervalValueUnit(seconds) | |
| } | |
| 
 | |
| // SecondsToIntervalValueUnit converts seconds to the most appropriate interval unit | |
| func SecondsToIntervalValueUnit(totalSeconds int) (int, string) { | |
| 	if totalSeconds == 0 { | |
| 		return 0, "minutes" | |
| 	} | |
| 
 | |
| 	// Check if it's evenly divisible by days | |
| 	if totalSeconds%(24*3600) == 0 { | |
| 		return totalSeconds / (24 * 3600), "days" | |
| 	} | |
| 
 | |
| 	// Check if it's evenly divisible by hours | |
| 	if totalSeconds%3600 == 0 { | |
| 		return totalSeconds / 3600, "hours" | |
| 	} | |
| 
 | |
| 	// Default to minutes | |
| 	return totalSeconds / 60, "minutes" | |
| } | |
| 
 | |
| // IntervalValueUnitToSeconds converts interval value and unit to seconds | |
| func IntervalValueUnitToSeconds(value int, unit string) int { | |
| 	switch unit { | |
| 	case "days": | |
| 		return value * 24 * 3600 | |
| 	case "hours": | |
| 		return value * 3600 | |
| 	case "minutes": | |
| 		return value * 60 | |
| 	default: | |
| 		return value * 60 // Default to minutes | |
| 	} | |
| } | |
| 
 | |
| // ParseDisplayValue converts a display value back to the storage format | |
| func (f *Field) ParseDisplayValue(displayValue interface{}) interface{} { | |
| 	if (f.Type == FieldTypeDuration || f.Type == FieldTypeInterval) && f.Unit != UnitSeconds { | |
| 		if val, ok := displayValue.(int); ok { | |
| 			switch f.Unit { | |
| 			case UnitMinutes: | |
| 				return val * 60 | |
| 			case UnitHours: | |
| 				return val * 3600 | |
| 			case UnitDays: | |
| 				return val * 24 * 3600 | |
| 			} | |
| 		} | |
| 	} | |
| 	return displayValue | |
| } | |
| 
 | |
| // ParseIntervalFormData parses form data for interval fields (value + unit) | |
| func (f *Field) ParseIntervalFormData(valueStr, unitStr string) (int, error) { | |
| 	if f.Type != FieldTypeInterval { | |
| 		return 0, fmt.Errorf("field %s is not an interval field", f.Name) | |
| 	} | |
| 
 | |
| 	value := 0 | |
| 	if valueStr != "" { | |
| 		var err error | |
| 		value, err = fmt.Sscanf(valueStr, "%d", &value) | |
| 		if err != nil { | |
| 			return 0, fmt.Errorf("invalid interval value: %s", valueStr) | |
| 		} | |
| 	} | |
| 
 | |
| 	return IntervalValueUnitToSeconds(value, unitStr), nil | |
| } | |
| 
 | |
| // ValidateValue validates a value against the field constraints | |
| func (f *Field) ValidateValue(value interface{}) error { | |
| 	if f.Required && (value == nil || value == "" || value == 0) { | |
| 		return fmt.Errorf("%s is required", f.DisplayName) | |
| 	} | |
| 
 | |
| 	if f.MinValue != nil { | |
| 		if !f.compareValues(value, f.MinValue, ">=") { | |
| 			return fmt.Errorf("%s must be >= %v", f.DisplayName, f.MinValue) | |
| 		} | |
| 	} | |
| 
 | |
| 	if f.MaxValue != nil { | |
| 		if !f.compareValues(value, f.MaxValue, "<=") { | |
| 			return fmt.Errorf("%s must be <= %v", f.DisplayName, f.MaxValue) | |
| 		} | |
| 	} | |
| 
 | |
| 	return nil | |
| } | |
| 
 | |
| // compareValues compares two values based on the operator | |
| func (f *Field) compareValues(a, b interface{}, op string) bool { | |
| 	switch f.Type { | |
| 	case FieldTypeInt: | |
| 		aVal, aOk := a.(int) | |
| 		bVal, bOk := b.(int) | |
| 		if !aOk || !bOk { | |
| 			return false | |
| 		} | |
| 		switch op { | |
| 		case ">=": | |
| 			return aVal >= bVal | |
| 		case "<=": | |
| 			return aVal <= bVal | |
| 		} | |
| 	case FieldTypeFloat: | |
| 		aVal, aOk := a.(float64) | |
| 		bVal, bOk := b.(float64) | |
| 		if !aOk || !bOk { | |
| 			return false | |
| 		} | |
| 		switch op { | |
| 		case ">=": | |
| 			return aVal >= bVal | |
| 		case "<=": | |
| 			return aVal <= bVal | |
| 		} | |
| 	} | |
| 	return true | |
| } | |
| 
 | |
| // Schema provides common functionality for configuration schemas | |
| type Schema struct { | |
| 	Fields []*Field `json:"fields"` | |
| } | |
| 
 | |
| // GetFieldByName returns a field by its JSON name | |
| func (s *Schema) GetFieldByName(jsonName string) *Field { | |
| 	for _, field := range s.Fields { | |
| 		if field.JSONName == jsonName { | |
| 			return field | |
| 		} | |
| 	} | |
| 	return nil | |
| } | |
| 
 | |
| // ApplyDefaultsToConfig applies defaults to a configuration that implements ConfigWithDefaults | |
| func (s *Schema) ApplyDefaultsToConfig(config ConfigWithDefaults) error { | |
| 	return config.ApplySchemaDefaults(s) | |
| } | |
| 
 | |
| // ApplyDefaultsToProtobuf applies defaults to protobuf types using reflection | |
| func (s *Schema) ApplyDefaultsToProtobuf(config interface{}) error { | |
| 	return s.applyDefaultsReflection(config) | |
| } | |
| 
 | |
| // applyDefaultsReflection applies default values using reflection (internal use only) | |
| // Used for protobuf types and embedded struct handling | |
| func (s *Schema) applyDefaultsReflection(config interface{}) error { | |
| 	configValue := reflect.ValueOf(config) | |
| 	if configValue.Kind() == reflect.Ptr { | |
| 		configValue = configValue.Elem() | |
| 	} | |
| 
 | |
| 	if configValue.Kind() != reflect.Struct { | |
| 		return fmt.Errorf("config must be a struct or pointer to struct") | |
| 	} | |
| 
 | |
| 	configType := configValue.Type() | |
| 
 | |
| 	for i := 0; i < configValue.NumField(); i++ { | |
| 		field := configValue.Field(i) | |
| 		fieldType := configType.Field(i) | |
| 
 | |
| 		// Handle embedded structs recursively (before JSON tag check) | |
| 		if field.Kind() == reflect.Struct && fieldType.Anonymous { | |
| 			if !field.CanAddr() { | |
| 				return fmt.Errorf("embedded struct %s is not addressable - config must be a pointer", fieldType.Name) | |
| 			} | |
| 			err := s.applyDefaultsReflection(field.Addr().Interface()) | |
| 			if err != nil { | |
| 				return fmt.Errorf("failed to apply defaults to embedded struct %s: %v", fieldType.Name, err) | |
| 			} | |
| 			continue | |
| 		} | |
| 
 | |
| 		// Get JSON tag name | |
| 		jsonTag := fieldType.Tag.Get("json") | |
| 		if jsonTag == "" { | |
| 			continue | |
| 		} | |
| 
 | |
| 		// Remove options like ",omitempty" | |
| 		if commaIdx := strings.Index(jsonTag, ","); commaIdx >= 0 { | |
| 			jsonTag = jsonTag[:commaIdx] | |
| 		} | |
| 
 | |
| 		// Find corresponding schema field | |
| 		schemaField := s.GetFieldByName(jsonTag) | |
| 		if schemaField == nil { | |
| 			continue | |
| 		} | |
| 
 | |
| 		// Apply default if field is zero value | |
| 		if field.CanSet() && field.IsZero() { | |
| 			defaultValue := reflect.ValueOf(schemaField.DefaultValue) | |
| 			if defaultValue.Type().ConvertibleTo(field.Type()) { | |
| 				field.Set(defaultValue.Convert(field.Type())) | |
| 			} | |
| 		} | |
| 	} | |
| 
 | |
| 	return nil | |
| } | |
| 
 | |
| // ValidateConfig validates a configuration against the schema | |
| func (s *Schema) ValidateConfig(config interface{}) []error { | |
| 	var errors []error | |
| 
 | |
| 	configValue := reflect.ValueOf(config) | |
| 	if configValue.Kind() == reflect.Ptr { | |
| 		configValue = configValue.Elem() | |
| 	} | |
| 
 | |
| 	if configValue.Kind() != reflect.Struct { | |
| 		errors = append(errors, fmt.Errorf("config must be a struct or pointer to struct")) | |
| 		return errors | |
| 	} | |
| 
 | |
| 	configType := configValue.Type() | |
| 
 | |
| 	for i := 0; i < configValue.NumField(); i++ { | |
| 		field := configValue.Field(i) | |
| 		fieldType := configType.Field(i) | |
| 
 | |
| 		// Get JSON tag name | |
| 		jsonTag := fieldType.Tag.Get("json") | |
| 		if jsonTag == "" { | |
| 			continue | |
| 		} | |
| 
 | |
| 		// Remove options like ",omitempty" | |
| 		if commaIdx := strings.Index(jsonTag, ","); commaIdx > 0 { | |
| 			jsonTag = jsonTag[:commaIdx] | |
| 		} | |
| 
 | |
| 		// Find corresponding schema field | |
| 		schemaField := s.GetFieldByName(jsonTag) | |
| 		if schemaField == nil { | |
| 			continue | |
| 		} | |
| 
 | |
| 		// Validate field value | |
| 		fieldValue := field.Interface() | |
| 		if err := schemaField.ValidateValue(fieldValue); err != nil { | |
| 			errors = append(errors, err) | |
| 		} | |
| 	} | |
| 
 | |
| 	return errors | |
| }
 |