Skip to content

Commit 6d686bc

Browse files
committed
feat(config): add case-insensitive value input
Accept case-insensitive input for oneof-validated fields (e.g., "DEBUG", "Docker", "Info") and normalize to correct casing from validation tags. Uses strings.EqualFold for comparison and integrates normalization into validation flow to avoid duplicate path traversal. Signed-off-by: Kim Christensen <[email protected]>
1 parent eb347eb commit 6d686bc

File tree

2 files changed

+72
-31
lines changed

2 files changed

+72
-31
lines changed

pkg/config/setter.go

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import (
1111
// Supports simple fields and nested fields like "logs.level" or "telemetry.enabled".
1212
// Uses reflection to dynamically set fields based on struct tags.
1313
func SetConfigValue(data *Data, path string, value string) error {
14-
if err := ValidateConfigValue(data, path, value); err != nil {
14+
// Validate and normalize the value (validation also normalizes case for oneof fields)
15+
normalizedValue, err := ValidateAndNormalizeConfigValue(data, path, value)
16+
if err != nil {
1517
return err
1618
}
1719

1820
parts := strings.Split(path, ".")
19-
return setFieldByPath(reflect.ValueOf(data).Elem(), parts, value)
21+
return setFieldByPath(reflect.ValueOf(data).Elem(), parts, normalizedValue)
2022
}
2123

2224
// setFieldByPath recursively navigates and sets a field using the path segments
@@ -124,12 +126,18 @@ func setFieldValue(field reflect.Value, value string) error {
124126

125127
// ValidateConfigValue validates that a config path exists and the value is valid for its type
126128
func ValidateConfigValue(data *Data, path string, value string) error {
129+
_, err := ValidateAndNormalizeConfigValue(data, path, value)
130+
return err
131+
}
132+
133+
// ValidateAndNormalizeConfigValue validates and normalizes (case) the value for oneof fields
134+
func ValidateAndNormalizeConfigValue(data *Data, path string, value string) (string, error) {
127135
if path == "" {
128-
return fmt.Errorf("config path cannot be empty")
136+
return "", fmt.Errorf("config path cannot be empty")
129137
}
130138

131139
if value == "" {
132-
return fmt.Errorf("config value cannot be empty")
140+
return "", fmt.Errorf("config value cannot be empty")
133141
}
134142

135143
parts := strings.Split(path, ".")
@@ -143,83 +151,83 @@ func ValidateConfigValue(data *Data, path string, value string) error {
143151
if err != nil {
144152
// Provide helpful context about where we are in the path
145153
if i > 0 {
146-
return fmt.Errorf("invalid config path %s: %w (at level %d)", path, err, i+1)
154+
return "", fmt.Errorf("invalid config path %s: %w (at level %d)", path, err, i+1)
147155
}
148-
return err
156+
return "", err
149157
}
150158

151-
// If this is the last part, validate the value can be set
159+
// If this is the last part, validate and normalize the value
152160
if i == len(parts)-1 {
153-
return validateValueForTypeWithTag(field, sf, value, path)
161+
return validateAndNormalizeValueForTypeWithTag(field, sf, value, path)
154162
}
155163

156164
// Navigate to nested struct
157165
if field.Kind() != reflect.Struct {
158-
return fmt.Errorf("field %s is not a struct, cannot navigate to %s", part, parts[i+1])
166+
return "", fmt.Errorf("field %s is not a struct, cannot navigate to %s", part, parts[i+1])
159167
}
160168

161169
v = field
162170
t = field.Type()
163171
}
164172

165-
return nil
173+
return value, nil
166174
}
167175

168-
// validateValueForTypeWithTag checks if a value can be converted to the field's type
169-
// and validates against any constraints defined in the validate tag
170-
func validateValueForTypeWithTag(field reflect.Value, sf reflect.StructField, value string, path string) error {
176+
// validateAndNormalizeValueForTypeWithTag validates and normalizes the value
177+
func validateAndNormalizeValueForTypeWithTag(field reflect.Value, sf reflect.StructField, value string, path string) (string, error) {
171178
// First check type conversion
172179
switch field.Kind() {
173180
case reflect.String:
174-
// Check validate tag for string constraints
175-
return validateWithTag(sf, value, path)
181+
// Check validate tag for string constraints and normalize if oneof
182+
return validateAndNormalizeWithTag(sf, value, path)
176183

177184
case reflect.Bool:
178185
if _, err := strconv.ParseBool(value); err != nil {
179-
return fmt.Errorf("invalid boolean value for %s: %s (valid values: true, false, 1, 0)", path, value)
186+
return "", fmt.Errorf("invalid boolean value for %s: %s (valid values: true, false, 1, 0)", path, value)
180187
}
181-
return nil
188+
return value, nil
182189

183190
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
184191
if _, err := strconv.ParseInt(value, 10, 64); err != nil {
185-
return fmt.Errorf("invalid integer value for %s: %s", path, value)
192+
return "", fmt.Errorf("invalid integer value for %s: %s", path, value)
186193
}
187-
return nil
194+
return value, nil
188195

189196
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
190197
if _, err := strconv.ParseUint(value, 10, 64); err != nil {
191-
return fmt.Errorf("invalid unsigned integer value for %s: %s", path, value)
198+
return "", fmt.Errorf("invalid unsigned integer value for %s: %s", path, value)
192199
}
193-
return nil
200+
return value, nil
194201

195202
case reflect.Float32, reflect.Float64:
196203
if _, err := strconv.ParseFloat(value, 64); err != nil {
197-
return fmt.Errorf("invalid float value for %s: %s", path, value)
204+
return "", fmt.Errorf("invalid float value for %s: %s", path, value)
198205
}
199-
return nil
206+
return value, nil
200207

201208
default:
202-
return fmt.Errorf("field %s has unsupported type %s (only string, bool, and numeric types can be set)", path, field.Kind())
209+
return "", fmt.Errorf("field %s has unsupported type %s (only string, bool, and numeric types can be set)", path, field.Kind())
203210
}
204211
}
205212

206-
// validateWithTag validates a value against the validate struct tag
207-
func validateWithTag(sf reflect.StructField, value string, path string) error {
213+
// validateAndNormalizeWithTag validates and normalizes a value against the validate struct tag
214+
func validateAndNormalizeWithTag(sf reflect.StructField, value string, path string) (string, error) {
208215
validateTag := sf.Tag.Get("validate")
209216
if validateTag == "" {
210-
return nil // No validation tag
217+
return value, nil // No validation tag
211218
}
212219

213220
// Parse the validate tag - currently only support "oneof=value1 value2 ..."
214221
if strings.HasPrefix(validateTag, "oneof=") {
215222
validValues := strings.Split(strings.TrimPrefix(validateTag, "oneof="), " ")
223+
// Case-insensitive comparison, return correctly-cased value from tag
216224
for _, valid := range validValues {
217-
if value == valid {
218-
return nil
225+
if strings.EqualFold(value, valid) {
226+
return valid, nil // Return normalized value
219227
}
220228
}
221-
return fmt.Errorf("invalid value for %s: %s (valid values: %s)", path, value, strings.Join(validValues, ", "))
229+
return "", fmt.Errorf("invalid value for %s: %s (valid values: %s)", path, value, strings.Join(validValues, ", "))
222230
}
223231

224-
return nil
232+
return value, nil
225233
}

pkg/config/setter_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,3 +408,36 @@ func TestSetConfigValue_NamedTypes_RealConfig(t *testing.T) {
408408
assert.Equal(t, LogLevel("debug"), data.Logs.Level)
409409
assert.IsType(t, LogLevel(""), data.Logs.Level)
410410
}
411+
412+
func TestSetConfigValue_CaseInsensitive(t *testing.T) {
413+
tests := []struct {
414+
name string
415+
path string
416+
input string
417+
expected string
418+
}{
419+
{"uppercase", "verbosity", "DEBUG", "debug"},
420+
{"mixed case", "runtime-driver", "Docker", "docker"},
421+
{"nested field uppercase", "logs.level", "INFO", "info"},
422+
}
423+
424+
for _, tt := range tests {
425+
t.Run(tt.name, func(t *testing.T) {
426+
data := &Data{}
427+
err := SetConfigValue(data, tt.path, tt.input)
428+
require.NoError(t, err)
429+
430+
// Verify the value was normalized to lowercase
431+
var actual string
432+
switch tt.path {
433+
case "verbosity":
434+
actual = data.Verbosity
435+
case "runtime-driver":
436+
actual = data.RuntimeDriver
437+
case "logs.level":
438+
actual = string(data.Logs.Level)
439+
}
440+
assert.Equal(t, tt.expected, actual)
441+
})
442+
}
443+
}

0 commit comments

Comments
 (0)