Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions validator/range.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,34 @@ func Each(validatorFunc ValidatorFunc) ValidatorFunc {
return nil
}
}

// EachWithOptions applies a set of validation options to each element in a slice or array, returning the first error
func EachWithOptions(options []ValidationOption) ValidatorFunc {
return func(value interface{}) error {
if value == nil {
return fmt.Errorf("value must be a non-nil slice or array")
}
v := reflect.ValueOf(value)
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return fmt.Errorf("value must be a slice or array, got %T", value)
}
if v.Len() == 0 {
return nil
}
for i := 0; i < v.Len(); i++ {
elem := v.Index(i).Interface()
nestedBody, ok := elem.(map[string]interface{})
if !ok {
if reflect.TypeOf(elem).Kind() == reflect.Struct {
nestedBody = StructToMap(elem)
} else {
return fmt.Errorf("element at index %d must be an object, got %T", i, elem)
}
}
if err := Validate(nestedBody, options); err != nil {
return fmt.Errorf("element at index %d: %v", i, err)
}
}
return nil
}
}
111 changes: 111 additions & 0 deletions validator/range_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,114 @@ func TestEach(t *testing.T) {
require.EqualError(t, err, "value must be a slice or array")
})
}

func TestEachWithOptions(t *testing.T) {
nestedOptions := []ValidationOption{
{
Key: "name",
IsOptional: false,
Validators: []Validator{
CreateValidator(IsNotEmpty, "name is required"),
CreateValidator(IsString, "value must be a string"),
},
},
{
Key: "value",
IsOptional: false,
Validators: []Validator{
CreateValidator(IsNotEmpty, "value is required"),
},
},
}

tests := []struct {
name string
input interface{}
expected error
}{
{
name: "valid array of maps",
input: []interface{}{
map[string]interface{}{"name": "color", "value": "red"},
map[string]interface{}{"name": "size", "value": "M"},
},
expected: nil,
},
{
name: "invalid array - missing required field",
input: []interface{}{
map[string]interface{}{"name": "color", "value": "red"},
map[string]interface{}{"value": "M"},
},
expected: errors.New("element at index 1: name is required"),
},
{
name: "invalid array - wrong type",
input: []interface{}{
map[string]interface{}{"name": "color", "value": "red"},
map[string]interface{}{"name": 123, "value": "M"},
},
expected: errors.New("element at index 1: value must be a string"),
},
{
name: "empty array",
input: []interface{}{},
expected: nil,
},
{
name: "nil array",
input: nil,
expected: errors.New("value must be a non-nil slice or array"),
},
{
name: "not an array",
input: "not an array",
expected: errors.New("value must be a slice or array, got string"),
},
{
name: "valid array of structs",
input: []interface{}{
struct {
Name string `json:"name"`
Value string `json:"value"`
}{Name: "color", Value: "red"},
struct {
Name string `json:"name"`
Value string `json:"value"`
}{Name: "size", Value: "M"},
},
expected: nil,
},
{
name: "invalid element type",
input: []interface{}{
"not a map or struct",
},
expected: errors.New("element at index 0 must be an object, got string"),
},
{
name: "json with mixed case keys",
input: []interface{}{
map[string]interface{}{
"name": "ahmed",
"Name": 123,
"value": "test",
},
},
expected: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
validator := EachWithOptions(nestedOptions)
err := validator(tt.input)
if tt.expected == nil {
require.NoError(t, err, "expected no error")
} else {
require.Error(t, err, "expected an error")
require.Equal(t, tt.expected.Error(), err.Error(), "error message mismatch")
}
})
}
}
36 changes: 35 additions & 1 deletion validator/validator.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package validator

import "fmt"
import (
"fmt"
"reflect"
"strings"
)

// ValidatorFunc is a function that validates a field and returns an error if validation fails.
type ValidatorFunc func(value interface{}) error
Expand Down Expand Up @@ -33,6 +37,11 @@ func Validate(body map[string]interface{}, options []ValidationOption) error {
continue
}

// Check if the field is required but missing *before* running validators
if !option.IsOptional && !exists {
return fmt.Errorf("%s is required", option.Key)
}
Comment on lines +40 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Code duplication concern.

This new validation check for required fields is duplicated at lines 61-63. Having multiple checks for the same condition can lead to confusion and maintenance issues.

 // Validate checks the request body against the validation options and returns the first error.
 func Validate(body map[string]interface{}, options []ValidationOption) error {
 	for _, option := range options {
 		value, exists := body[option.Key]

 		// Skip validation if the field is optional and not present
 		if option.IsOptional && !exists {
 			continue
 		}

-		// Check if the field is required but missing *before* running validators
-		if !option.IsOptional && !exists {
-			return fmt.Errorf("%s is required", option.Key)
-		}
-
 		// Apply transformations
 		if exists {
 			for _, transformer := range option.Transformers {
 				value = transformer(value)
 			}
 			body[option.Key] = value // Update the body with the transformed value
 		}

 		// Run all validators for the field (if it exists)
 		for _, validator := range option.Validators {
 			if err := validator.Func(value); err != nil {
 				return fmt.Errorf("%s", validator.Message)
 			}
 		}

 		// Check if the field is required but missing
 		if !option.IsOptional && !exists {
 			return fmt.Errorf("'%s' is required", option.Key)
 		}

Either keep only one of the checks or make their purposes clear with improved comments if they serve different purposes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check if the field is required but missing *before* running validators
if !option.IsOptional && !exists {
return fmt.Errorf("%s is required", option.Key)
}
// Validate checks the request body against the validation options and returns the first error.
func Validate(body map[string]interface{}, options []ValidationOption) error {
for _, option := range options {
value, exists := body[option.Key]
// Skip validation if the field is optional and not present
if option.IsOptional && !exists {
continue
}
// Apply transformations
if exists {
for _, transformer := range option.Transformers {
value = transformer(value)
}
body[option.Key] = value // Update the body with the transformed value
}
// Run all validators for the field (if it exists)
for _, validator := range option.Validators {
if err := validator.Func(value); err != nil {
return fmt.Errorf("%s", validator.Message)
}
}
// Check if the field is required but missing
if !option.IsOptional && !exists {
return fmt.Errorf("'%s' is required", option.Key)
}
}
return nil
}


// Apply transformations
if exists {
for _, transformer := range option.Transformers {
Expand Down Expand Up @@ -75,3 +84,28 @@ func CreateValidator(fn ValidatorFunc, message string) Validator {
Message: message,
}
}

// StructToMap converts a struct to a map[string]interface{} for validation
func StructToMap(obj interface{}) map[string]interface{} {
result := make(map[string]interface{})
v := reflect.ValueOf(obj)
t := reflect.TypeOf(obj)

for i := range v.NumField() {
field := t.Field(i)
// Use the json tag if present, otherwise fall back to field name
jsonTag := field.Tag.Get("json")
key := field.Name
if jsonTag != "" {
// Split on "," to handle options like "omitempty"
if parts := strings.Split(jsonTag, ","); len(parts) > 0 {
key = parts[0]
}
}
// Only process exported fields
if field.PkgPath == "" { // PkgPath is empty for exported fields
result[key] = v.Field(i).Interface()
}
}
return result
}
2 changes: 1 addition & 1 deletion validator/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func TestValidate(t *testing.T) {
"username": "user123",
"password": "password123",
},
errors.New("Email is required"),
errors.New("email is required"),
},
{
"invalid email",
Expand Down
Loading