package infra import ( "fmt" "reflect" "regexp" "strings" ) // emailRE is a basic email format check. var emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) // urlRE checks for http:// or https:// prefix. var urlRE = regexp.MustCompile(`^https?://`) // ConfigValidate validates a configuration struct populated with values. // It reads struct field tags to determine validation rules: // - required:"true" — field must be non-zero / non-empty // - format:"email" — field value must match email pattern // - format:"url" — field value must start with http:// or https:// // // Only exported fields are inspected. Validation errors are accumulated; all // fields are checked even if earlier ones fail. Returns a ConfigValidation // with IsValid=true when no errors are found. func ConfigValidate(cfg any) ConfigValidation { var errs []ConfigError v := reflect.ValueOf(cfg) if v.Kind() == reflect.Ptr { if v.IsNil() { return ConfigValidation{Errors: []ConfigError{{Field: "", Message: "cfg is nil", Tag: "required"}}, IsValid: false} } v = v.Elem() } if v.Kind() != reflect.Struct { return ConfigValidation{Errors: []ConfigError{{Field: "", Message: "cfg must be a struct", Tag: "required"}}, IsValid: false} } t := v.Type() for i := 0; i < t.NumField(); i++ { field := t.Field(i) if !field.IsExported() { continue } fv := v.Field(i) fieldName := field.Name // required check required := field.Tag.Get("required") if required == "true" { if isZero(fv) { errs = append(errs, ConfigError{ Field: fieldName, Message: fmt.Sprintf("field %s is required", fieldName), Tag: "required", }) continue // skip format check when value is missing } } // format check (only for string fields) format := field.Tag.Get("format") if format != "" && fv.Kind() == reflect.String { val := fv.String() switch format { case "email": if !emailRE.MatchString(val) { errs = append(errs, ConfigError{ Field: fieldName, Message: fmt.Sprintf("field %s has invalid email format: %q", fieldName, val), Tag: "format", }) } case "url": if !urlRE.MatchString(val) { errs = append(errs, ConfigError{ Field: fieldName, Message: fmt.Sprintf("field %s has invalid URL format (must start with http:// or https://): %q", fieldName, val), Tag: "format", }) } default: errs = append(errs, ConfigError{ Field: fieldName, Message: fmt.Sprintf("field %s has unknown format tag: %q", fieldName, format), Tag: "format", }) } } // min/max for numeric fields minTag := field.Tag.Get("min") maxTag := field.Tag.Get("max") if (minTag != "" || maxTag != "") && isNumeric(fv) { val := toFloat(fv) if minTag != "" { var minVal float64 fmt.Sscanf(minTag, "%f", &minVal) if val < minVal { errs = append(errs, ConfigError{ Field: fieldName, Message: fmt.Sprintf("field %s value %v is below minimum %v", fieldName, val, minVal), Tag: "min", }) } } if maxTag != "" { var maxVal float64 fmt.Sscanf(maxTag, "%f", &maxVal) if val > maxVal { errs = append(errs, ConfigError{ Field: fieldName, Message: fmt.Sprintf("field %s value %v is above maximum %v", fieldName, val, maxVal), Tag: "max", }) } } } // oneof check for string fields oneof := field.Tag.Get("oneof") if oneof != "" && fv.Kind() == reflect.String { val := fv.String() allowed := strings.Split(oneof, " ") found := false for _, a := range allowed { if a == val { found = true break } } if !found { errs = append(errs, ConfigError{ Field: fieldName, Message: fmt.Sprintf("field %s value %q is not one of: %s", fieldName, val, oneof), Tag: "oneof", }) } } } return ConfigValidation{ Errors: errs, IsValid: len(errs) == 0, } } func isZero(v reflect.Value) bool { switch v.Kind() { case reflect.String: return v.Len() == 0 case reflect.Slice, reflect.Map: return v.IsNil() || v.Len() == 0 case reflect.Ptr, reflect.Interface: return v.IsNil() case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return v.Int() == 0 case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return v.Uint() == 0 case reflect.Float32, reflect.Float64: return v.Float() == 0 case reflect.Bool: return !v.Bool() default: return false } } func isNumeric(v reflect.Value) bool { switch v.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64: return true default: return false } } func toFloat(v reflect.Value) float64 { switch v.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return float64(v.Int()) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return float64(v.Uint()) case reflect.Float32, reflect.Float64: return v.Float() default: return 0 } }