diff --git a/validation/engine.go b/validation/engine.go index 19557593e..fe990128e 100644 --- a/validation/engine.go +++ b/validation/engine.go @@ -7,6 +7,7 @@ import ( "sort" "strings" + contractstranslation "github.com/goravel/framework/contracts/translation" contractsvalidation "github.com/goravel/framework/contracts/validation" ) @@ -18,6 +19,7 @@ type Engine struct { customRules map[string]contractsvalidation.Rule messages map[string]string attributes map[string]string + translator contractstranslation.Translator errors *Errors excludes map[string]bool distinctValues map[string]map[string]bool // For tracking distinct values @@ -29,6 +31,7 @@ type engineOptions struct { customRules map[string]contractsvalidation.Rule messages map[string]string attributes map[string]string + translator contractstranslation.Translator } // NewEngine creates a new validation engine. @@ -40,6 +43,7 @@ func NewEngine(ctx context.Context, data *DataBag, rules map[string][]ParsedRule customRules: opts.customRules, messages: opts.messages, attributes: opts.attributes, + translator: opts.translator, errors: NewErrors(), excludes: make(map[string]bool), distinctValues: make(map[string]map[string]bool), @@ -184,7 +188,7 @@ func (e *Engine) validateField(field string, fieldRules []ParsedRule, allRules m if !passed { attrType := getAttributeType(field, value, allRules) - msg := e.formatErrorMessage(field, rule, attrType) + msg := e.formatErrorMessage(field, rule, attrType, value) e.errors.Add(field, rule.Name, msg) } @@ -291,8 +295,8 @@ func (e *Engine) trackDistinct(field string, value any) bool { } // formatErrorMessage creates the error message for a rule failure. -func (e *Engine) formatErrorMessage(field string, rule ParsedRule, attrType string) string { - msg := getMessage(field, rule.Name, e.messages, attrType) +func (e *Engine) formatErrorMessage(field string, rule ParsedRule, attrType string, value any) string { + msg := getMessage(field, rule.Name, e.messages, attrType, e.translator) if _, hasFieldRuleMessage := e.messages[field+"."+rule.Name]; !hasFieldRuleMessage { if _, hasRuleMessage := e.messages[rule.Name]; !hasRuleMessage { if customRule, ok := e.customRules[rule.Name]; ok { @@ -305,6 +309,14 @@ func (e *Engine) formatErrorMessage(field string, rule ParsedRule, attrType stri ":attribute": getDisplayableAttribute(field, e.attributes), } + // Add placeholders for custom rules: :value and :option0, :option1, etc. + if _, ok := e.customRules[rule.Name]; ok { + replacements[":value"] = fmt.Sprintf("%v", value) + for i, param := range rule.Parameters { + replacements[fmt.Sprintf(":option%d", i)] = param + } + } + // Add parameter-specific replacements switch rule.Name { case "min", "min_digits": diff --git a/validation/engine_test.go b/validation/engine_test.go index 04c4f2165..5e1d0ebc6 100644 --- a/validation/engine_test.go +++ b/validation/engine_test.go @@ -445,7 +445,7 @@ func TestEngine_FormatErrorMessage(t *testing.T) { attributes: map[string]string{"name": "Full Name"}, }) - msg := engine.formatErrorMessage("name", ParsedRule{Name: "my_rule"}, "string") + msg := engine.formatErrorMessage("name", ParsedRule{Name: "my_rule"}, "string", nil) assert.Equal(t, "The Full Name is bad.", msg) }) @@ -460,7 +460,7 @@ func TestEngine_FormatErrorMessage(t *testing.T) { }, }) - msg := engine.formatErrorMessage("f", ParsedRule{Name: "custom_exists"}, "string") + msg := engine.formatErrorMessage("f", ParsedRule{Name: "custom_exists"}, "string", nil) assert.Equal(t, "custom_exists failed for f", msg) }) @@ -475,7 +475,7 @@ func TestEngine_FormatErrorMessage(t *testing.T) { }, }) - msg := engine.formatErrorMessage("f", ParsedRule{Name: "custom_exists"}, "string") + msg := engine.formatErrorMessage("f", ParsedRule{Name: "custom_exists"}, "string", nil) assert.Equal(t, "custom_exists failed", msg) }) @@ -487,7 +487,7 @@ func TestEngine_FormatErrorMessage(t *testing.T) { }, }) - msg := engine.formatErrorMessage("name", ParsedRule{Name: "required"}, "string") + msg := engine.formatErrorMessage("name", ParsedRule{Name: "required"}, "string", nil) assert.Equal(t, "Please enter your name.", msg) }) @@ -498,7 +498,7 @@ func TestEngine_FormatErrorMessage(t *testing.T) { msg := engine.formatErrorMessage("name", ParsedRule{ Name: "min", Parameters: []string{"3"}, - }, "string") + }, "string", nil) assert.Equal(t, "The name field must be at least 3 characters.", msg) }) @@ -509,7 +509,7 @@ func TestEngine_FormatErrorMessage(t *testing.T) { msg := engine.formatErrorMessage("age", ParsedRule{ Name: "between", Parameters: []string{"1", "100"}, - }, "numeric") + }, "numeric", nil) assert.Equal(t, "The age field must be between 1 and 100.", msg) }) @@ -519,9 +519,52 @@ func TestEngine_FormatErrorMessage(t *testing.T) { attributes: map[string]string{"first_name": "First Name"}, }) - msg := engine.formatErrorMessage("first_name", ParsedRule{Name: "required"}, "string") + msg := engine.formatErrorMessage("first_name", ParsedRule{Name: "required"}, "string", nil) assert.Equal(t, "The First Name field is required.", msg) }) + + t.Run("custom rule with value placeholder", func(t *testing.T) { + bag, _ := NewDataBag(map[string]any{}) + engine := NewEngine(context.Background(), bag, nil, engineOptions{ + customRules: map[string]contractsvalidation.Rule{ + "my_rule": newAlwaysFailRule("my_rule", "The :attribute value :value is invalid."), + }, + }) + + msg := engine.formatErrorMessage("name", ParsedRule{Name: "my_rule"}, "string", "hello") + assert.Equal(t, "The name value hello is invalid.", msg) + }) + + t.Run("custom rule with option placeholders", func(t *testing.T) { + bag, _ := NewDataBag(map[string]any{}) + engine := NewEngine(context.Background(), bag, nil, engineOptions{ + customRules: map[string]contractsvalidation.Rule{ + "my_rule": newAlwaysFailRule("my_rule", "The :attribute must be between :option0 and :option1."), + }, + }) + + msg := engine.formatErrorMessage("age", ParsedRule{ + Name: "my_rule", + Parameters: []string{"18", "65"}, + }, "string", 10) + assert.Equal(t, "The age must be between 18 and 65.", msg) + }) + + t.Run("custom rule with combined placeholders", func(t *testing.T) { + bag, _ := NewDataBag(map[string]any{}) + engine := NewEngine(context.Background(), bag, nil, engineOptions{ + customRules: map[string]contractsvalidation.Rule{ + "my_rule": newAlwaysFailRule("my_rule", "The :attribute got :value, expected :option0."), + }, + attributes: map[string]string{"score": "Score"}, + }) + + msg := engine.formatErrorMessage("score", ParsedRule{ + Name: "my_rule", + Parameters: []string{"100"}, + }, "string", 42) + assert.Equal(t, "The Score got 42, expected 100.", msg) + }) } func TestEngine_ExecuteRule(t *testing.T) { diff --git a/validation/lang.go b/validation/lang.go new file mode 100644 index 000000000..90bd577fb --- /dev/null +++ b/validation/lang.go @@ -0,0 +1,46 @@ +package validation + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" +) + +//go:embed lang +var LangFS embed.FS + +func init() { + data, err := fs.ReadFile(LangFS, "lang/en/validation.json") + if err != nil { + panic(fmt.Sprintf("validation: failed to read embedded lang/en/validation.json: %v", err)) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + panic(fmt.Sprintf("validation: failed to parse embedded lang/en/validation.json: %v", err)) + } + + defaultMessages = flattenMessages("", raw) +} + +// flattenMessages flattens a nested JSON map into dot-separated keys. +// e.g. {"min": {"string": "...", "numeric": "..."}} → {"min.string": "...", "min.numeric": "..."} +func flattenMessages(prefix string, m map[string]any) map[string]string { + result := make(map[string]string) + for k, v := range m { + key := k + if prefix != "" { + key = prefix + "." + k + } + switch val := v.(type) { + case string: + result[key] = val + case map[string]any: + for fk, fv := range flattenMessages(key, val) { + result[fk] = fv + } + } + } + return result +} diff --git a/validation/lang/en/validation.json b/validation/lang/en/validation.json new file mode 100644 index 000000000..74daac0df --- /dev/null +++ b/validation/lang/en/validation.json @@ -0,0 +1,176 @@ +{ + "accepted": "The :attribute field must be accepted.", + "accepted_if": "The :attribute field must be accepted when :other is :value.", + "active_url": "The :attribute field must be a valid URL.", + "after": "The :attribute field must be a date after :date.", + "after_or_equal": "The :attribute field must be a date after or equal to :date.", + "alpha": "The :attribute field must only contain letters.", + "alpha_dash": "The :attribute field must only contain letters, numbers, dashes, and underscores.", + "alpha_num": "The :attribute field must only contain letters and numbers.", + "array": "The :attribute field must be an array.", + "ascii": "The :attribute field must only contain single-byte alphanumeric characters and symbols.", + "before": "The :attribute field must be a date before :date.", + "before_or_equal": "The :attribute field must be a date before or equal to :date.", + "between": { + "array": "The :attribute field must have between :min and :max items.", + "file": "The :attribute field must be between :min and :max kilobytes.", + "numeric": "The :attribute field must be between :min and :max.", + "string": "The :attribute field must be between :min and :max characters." + }, + "bool": "The :attribute field must be true or false.", + "boolean": "The :attribute field must be true or false.", + "confirmed": "The :attribute field confirmation does not match.", + "contains": "The :attribute field is missing a required value.", + "date": "The :attribute field must be a valid date.", + "date_equals": "The :attribute field must be a date equal to :date.", + "date_format": "The :attribute field must match the format :format.", + "decimal": "The :attribute field must have :decimal decimal places.", + "declined": "The :attribute field must be declined.", + "declined_if": "The :attribute field must be declined when :other is :value.", + "different": "The :attribute field and :other must be different.", + "digits": "The :attribute field must be :digits digits.", + "digits_between": "The :attribute field must be between :min and :max digits.", + "dimensions": "The :attribute field has invalid image dimensions.", + "distinct": "The :attribute field has a duplicate value.", + "doesnt_contain": "The :attribute field must not contain any of the following: :values.", + "doesnt_end_with": "The :attribute field must not end with one of the following: :values.", + "doesnt_start_with": "The :attribute field must not start with one of the following: :values.", + "email": "The :attribute field must be a valid email address.", + "encoding": "The :attribute field must be encoded as :values.", + "ends_with": "The :attribute field must end with one of the following: :values.", + "eq": "The :attribute field must be equal to :value.", + "exclude": "The :attribute field is excluded.", + "exclude_if": "The :attribute field is excluded when :other is :value.", + "exclude_unless": "The :attribute field is excluded unless :other is :value.", + "exclude_with": "The :attribute field is excluded when :values is present.", + "exclude_without": "The :attribute field is excluded when :values is not present.", + "exists": "The :attribute does not exist.", + "extensions": "The :attribute field must have one of the following extensions: :values.", + "file": "The :attribute field must be a file.", + "filled": "The :attribute field must have a value.", + "float": "The :attribute field must be a float.", + "gt": { + "array": "The :attribute field must have more than :value items.", + "file": "The :attribute field must be greater than :value kilobytes.", + "numeric": "The :attribute field must be greater than :value.", + "string": "The :attribute field must be greater than :value characters." + }, + "gte": { + "array": "The :attribute field must have :value items or more.", + "file": "The :attribute field must be greater than or equal to :value kilobytes.", + "numeric": "The :attribute field must be greater than or equal to :value.", + "string": "The :attribute field must be greater than or equal to :value characters." + }, + "hex_color": "The :attribute field must be a valid hexadecimal color.", + "image": "The :attribute field must be an image.", + "in": "The selected :attribute is invalid.", + "in_array": "The :attribute field must exist in :other.", + "in_array_keys": "The :attribute field must contain at least one of the specified keys.", + "int": "The :attribute field must be an integer.", + "integer": "The :attribute field must be an integer.", + "ip": "The :attribute field must be a valid IP address.", + "ipv4": "The :attribute field must be a valid IPv4 address.", + "ipv6": "The :attribute field must be a valid IPv6 address.", + "json": "The :attribute field must be a valid JSON string.", + "list": "The :attribute field must be a list.", + "lowercase": "The :attribute field must be lowercase.", + "lt": { + "array": "The :attribute field must have less than :value items.", + "file": "The :attribute field must be less than :value kilobytes.", + "numeric": "The :attribute field must be less than :value.", + "string": "The :attribute field must be less than :value characters." + }, + "lte": { + "array": "The :attribute field must not have more than :value items.", + "file": "The :attribute field must be less than or equal to :value kilobytes.", + "numeric": "The :attribute field must be less than or equal to :value.", + "string": "The :attribute field must be less than or equal to :value characters." + }, + "mac": "The :attribute field must be a valid MAC address.", + "mac_address": "The :attribute field must be a valid MAC address.", + "map": "The :attribute field must be a map.", + "max": { + "array": "The :attribute field must not have more than :max items.", + "file": "The :attribute field must not be greater than :max kilobytes.", + "numeric": "The :attribute field must not be greater than :max.", + "string": "The :attribute field must not be greater than :max characters." + }, + "max_digits": "The :attribute field must not have more than :max digits.", + "mimes": "The :attribute field must be a file of type: :values.", + "mimetypes": "The :attribute field must be a file of type: :values.", + "min": { + "array": "The :attribute field must have at least :min items.", + "file": "The :attribute field must be at least :min kilobytes.", + "numeric": "The :attribute field must be at least :min.", + "string": "The :attribute field must be at least :min characters." + }, + "min_digits": "The :attribute field must have at least :min digits.", + "missing": "The :attribute field must be missing.", + "missing_if": "The :attribute field must be missing when :other is :value.", + "missing_unless": "The :attribute field must be missing unless :other is :value.", + "missing_with": "The :attribute field must be missing when :values is present.", + "missing_with_all": "The :attribute field must be missing when :values are present.", + "multiple_of": "The :attribute field must be a multiple of :value.", + "ne": "The :attribute field must not be equal to :value.", + "not_in": "The selected :attribute is invalid.", + "not_regex": "The :attribute field format is invalid.", + "numeric": "The :attribute field must be a number.", + "present": "The :attribute field must be present.", + "present_if": "The :attribute field must be present when :other is :value.", + "present_unless": "The :attribute field must be present unless :other is :value.", + "present_with": "The :attribute field must be present when :values is present.", + "present_with_all": "The :attribute field must be present when :values are present.", + "prohibited": "The :attribute field is prohibited.", + "prohibited_if": "The :attribute field is prohibited when :other is :value.", + "prohibited_if_accepted": "The :attribute field is prohibited when :other is accepted.", + "prohibited_if_declined": "The :attribute field is prohibited when :other is declined.", + "prohibited_unless": "The :attribute field is prohibited unless :other is in :values.", + "prohibits": "The :attribute field prohibits :other from being present.", + "regex": "The :attribute field format is invalid.", + "required": "The :attribute field is required.", + "required_array_keys": "The :attribute field must contain entries for: :values.", + "required_if": "The :attribute field is required when :other is :value.", + "required_if_accepted": "The :attribute field is required when :other is accepted.", + "required_if_declined": "The :attribute field is required when :other is declined.", + "required_unless": "The :attribute field is required unless :other is in :values.", + "required_with": "The :attribute field is required when :values is present.", + "required_with_all": "The :attribute field is required when :values are present.", + "required_without": "The :attribute field is required when :values is not present.", + "required_without_all": "The :attribute field is required when none of :values are present.", + "same": "The :attribute field must match :other.", + "size": { + "array": "The :attribute field must contain :size items.", + "file": "The :attribute field must be :size kilobytes.", + "numeric": "The :attribute field must be :size.", + "string": "The :attribute field must be :size characters." + }, + "slice": "The :attribute field must be a slice.", + "starts_with": "The :attribute field must start with one of the following: :values.", + "string": "The :attribute field must be a string.", + "timezone": "The :attribute field must be a valid timezone.", + "uint": "The :attribute field must be a positive integer.", + "ulid": "The :attribute field must be a valid ULID.", + "unique": "The :attribute has already been taken.", + "uppercase": "The :attribute field must be uppercase.", + "url": "The :attribute field must be a valid URL.", + "uuid": "The :attribute field must be a valid UUID.", + "uuid3": "The :attribute field must be a valid UUID v3.", + "uuid4": "The :attribute field must be a valid UUID v4.", + "uuid5": "The :attribute field must be a valid UUID v5.", + + "len": "The :attribute field must be :size characters.", + "min_len": "The :attribute field must be at least :min characters.", + "max_len": "The :attribute field must not be greater than :max characters.", + "eq_field": "The :attribute field must match :other.", + "ne_field": "The :attribute field and :other must be different.", + "gt_field": "The :attribute field must be greater than :value.", + "gte_field": "The :attribute field must be greater than or equal to :value.", + "lt_field": "The :attribute field must be less than :value.", + "lte_field": "The :attribute field must be less than or equal to :value.", + "gt_date": "The :attribute field must be a date after :date.", + "lt_date": "The :attribute field must be a date before :date.", + "gte_date": "The :attribute field must be a date after or equal to :date.", + "lte_date": "The :attribute field must be a date before or equal to :date.", + "number": "The :attribute field must be a number.", + "full_url": "The :attribute field must be a valid URL." +} diff --git a/validation/messages.go b/validation/messages.go index 8381d23eb..353915731 100644 --- a/validation/messages.go +++ b/validation/messages.go @@ -3,198 +3,14 @@ package validation import ( "sort" "strings" + + contractstranslation "github.com/goravel/framework/contracts/translation" ) // defaultMessages contains all default validation error messages. +// It is populated at init time from the embedded lang/en/validation.json file. // Size-dependent rules have type-specific variants: "rule.string", "rule.numeric", "rule.array", "rule.file". -var defaultMessages = map[string]string{ - // Existence rules - "required": "The :attribute field is required.", - "required_if": "The :attribute field is required when :other is :value.", - "required_if_accepted": "The :attribute field is required when :other is accepted.", - "required_if_declined": "The :attribute field is required when :other is declined.", - "required_unless": "The :attribute field is required unless :other is in :values.", - "required_with": "The :attribute field is required when :values is present.", - "required_with_all": "The :attribute field is required when :values are present.", - "required_without": "The :attribute field is required when :values is not present.", - "required_without_all": "The :attribute field is required when none of :values are present.", - "filled": "The :attribute field must have a value.", - "present": "The :attribute field must be present.", - "present_if": "The :attribute field must be present when :other is :value.", - "present_unless": "The :attribute field must be present unless :other is :value.", - "present_with": "The :attribute field must be present when :values is present.", - "present_with_all": "The :attribute field must be present when :values are present.", - "missing": "The :attribute field must be missing.", - "missing_if": "The :attribute field must be missing when :other is :value.", - "missing_unless": "The :attribute field must be missing unless :other is :value.", - "missing_with": "The :attribute field must be missing when :values is present.", - "missing_with_all": "The :attribute field must be missing when :values are present.", - - // Accept/Decline rules - "accepted": "The :attribute field must be accepted.", - "accepted_if": "The :attribute field must be accepted when :other is :value.", - "declined": "The :attribute field must be declined.", - "declined_if": "The :attribute field must be declined when :other is :value.", - - // Prohibition rules - "prohibited": "The :attribute field is prohibited.", - "prohibited_if": "The :attribute field is prohibited when :other is :value.", - "prohibited_if_accepted": "The :attribute field is prohibited when :other is accepted.", - "prohibited_if_declined": "The :attribute field is prohibited when :other is declined.", - "prohibited_unless": "The :attribute field is prohibited unless :other is in :values.", - "prohibits": "The :attribute field prohibits :other from being present.", - - // Type rules - "string": "The :attribute field must be a string.", - "integer": "The :attribute field must be an integer.", - "int": "The :attribute field must be an integer.", - "uint": "The :attribute field must be a positive integer.", - "numeric": "The :attribute field must be a number.", - "boolean": "The :attribute field must be true or false.", - "bool": "The :attribute field must be true or false.", - "float": "The :attribute field must be a float.", - "array": "The :attribute field must be an array.", - "list": "The :attribute field must be a list.", - "slice": "The :attribute field must be a slice.", - "map": "The :attribute field must be a map.", - - // Size rules - string variants - "size.string": "The :attribute field must be :size characters.", - "size.numeric": "The :attribute field must be :size.", - "size.array": "The :attribute field must contain :size items.", - "size.file": "The :attribute field must be :size kilobytes.", - "min.string": "The :attribute field must be at least :min characters.", - "min.numeric": "The :attribute field must be at least :min.", - "min.array": "The :attribute field must have at least :min items.", - "min.file": "The :attribute field must be at least :min kilobytes.", - "max.string": "The :attribute field must not be greater than :max characters.", - "max.numeric": "The :attribute field must not be greater than :max.", - "max.array": "The :attribute field must not have more than :max items.", - "max.file": "The :attribute field must not be greater than :max kilobytes.", - "between.string": "The :attribute field must be between :min and :max characters.", - "between.numeric": "The :attribute field must be between :min and :max.", - "between.array": "The :attribute field must have between :min and :max items.", - "between.file": "The :attribute field must be between :min and :max kilobytes.", - "gt.string": "The :attribute field must be greater than :value characters.", - "gt.numeric": "The :attribute field must be greater than :value.", - "gt.array": "The :attribute field must have more than :value items.", - "gt.file": "The :attribute field must be greater than :value kilobytes.", - "gte.string": "The :attribute field must be greater than or equal to :value characters.", - "gte.numeric": "The :attribute field must be greater than or equal to :value.", - "gte.array": "The :attribute field must have :value items or more.", - "gte.file": "The :attribute field must be greater than or equal to :value kilobytes.", - "lt.string": "The :attribute field must be less than :value characters.", - "lt.numeric": "The :attribute field must be less than :value.", - "lt.array": "The :attribute field must have less than :value items.", - "lt.file": "The :attribute field must be less than :value kilobytes.", - "lte.string": "The :attribute field must be less than or equal to :value characters.", - "lte.numeric": "The :attribute field must be less than or equal to :value.", - "lte.array": "The :attribute field must not have more than :value items.", - "lte.file": "The :attribute field must be less than or equal to :value kilobytes.", - - // Numeric rules - "digits": "The :attribute field must be :digits digits.", - "digits_between": "The :attribute field must be between :min and :max digits.", - "decimal": "The :attribute field must have :decimal decimal places.", - "multiple_of": "The :attribute field must be a multiple of :value.", - "min_digits": "The :attribute field must have at least :min digits.", - "max_digits": "The :attribute field must not have more than :max digits.", - - // String format rules - "alpha": "The :attribute field must only contain letters.", - "alpha_num": "The :attribute field must only contain letters and numbers.", - "alpha_dash": "The :attribute field must only contain letters, numbers, dashes, and underscores.", - "ascii": "The :attribute field must only contain single-byte alphanumeric characters and symbols.", - "email": "The :attribute field must be a valid email address.", - "url": "The :attribute field must be a valid URL.", - "active_url": "The :attribute field must be a valid URL.", - "ip": "The :attribute field must be a valid IP address.", - "ipv4": "The :attribute field must be a valid IPv4 address.", - "ipv6": "The :attribute field must be a valid IPv6 address.", - "mac_address": "The :attribute field must be a valid MAC address.", - "mac": "The :attribute field must be a valid MAC address.", - "json": "The :attribute field must be a valid JSON string.", - "uuid": "The :attribute field must be a valid UUID.", - "uuid3": "The :attribute field must be a valid UUID v3.", - "uuid4": "The :attribute field must be a valid UUID v4.", - "uuid5": "The :attribute field must be a valid UUID v5.", - "ulid": "The :attribute field must be a valid ULID.", - "hex_color": "The :attribute field must be a valid hexadecimal color.", - "regex": "The :attribute field format is invalid.", - "not_regex": "The :attribute field format is invalid.", - "lowercase": "The :attribute field must be lowercase.", - "uppercase": "The :attribute field must be uppercase.", - - // String content rules - "starts_with": "The :attribute field must start with one of the following: :values.", - "doesnt_start_with": "The :attribute field must not start with one of the following: :values.", - "ends_with": "The :attribute field must end with one of the following: :values.", - "doesnt_end_with": "The :attribute field must not end with one of the following: :values.", - "contains": "The :attribute field is missing a required value.", - "doesnt_contain": "The :attribute field must not contain any of the following: :values.", - "confirmed": "The :attribute field confirmation does not match.", - - // Comparison rules - "same": "The :attribute field must match :other.", - "different": "The :attribute field and :other must be different.", - "eq": "The :attribute field must be equal to :value.", - "ne": "The :attribute field must not be equal to :value.", - "in": "The selected :attribute is invalid.", - "not_in": "The selected :attribute is invalid.", - "in_array": "The :attribute field must exist in :other.", - "in_array_keys": "The :attribute field must contain at least one of the specified keys.", - - // Date rules - "date": "The :attribute field must be a valid date.", - "date_format": "The :attribute field must match the format :format.", - "date_equals": "The :attribute field must be a date equal to :date.", - "before": "The :attribute field must be a date before :date.", - "before_or_equal": "The :attribute field must be a date before or equal to :date.", - "after": "The :attribute field must be a date after :date.", - "after_or_equal": "The :attribute field must be a date after or equal to :date.", - "timezone": "The :attribute field must be a valid timezone.", - - // Exclude rules - "exclude": "The :attribute field is excluded.", - "exclude_if": "The :attribute field is excluded when :other is :value.", - "exclude_unless": "The :attribute field is excluded unless :other is :value.", - "exclude_with": "The :attribute field is excluded when :values is present.", - "exclude_without": "The :attribute field is excluded when :values is not present.", - - // File rules - "file": "The :attribute field must be a file.", - "image": "The :attribute field must be an image.", - "mimes": "The :attribute field must be a file of type: :values.", - "mimetypes": "The :attribute field must be a file of type: :values.", - "extensions": "The :attribute field must have one of the following extensions: :values.", - "dimensions": "The :attribute field has invalid image dimensions.", - "encoding": "The :attribute field must be encoded as :values.", - - // Other rules - "distinct": "The :attribute field has a duplicate value.", - "required_array_keys": "The :attribute field must contain entries for: :values.", - - // Database rules - "exists": "The :attribute does not exist.", - "unique": "The :attribute has already been taken.", - - // Deprecated: use the new names instead, will be removed in the next version. - "len": "The :attribute field must be :size characters.", - "min_len": "The :attribute field must be at least :min characters.", - "max_len": "The :attribute field must not be greater than :max characters.", - "eq_field": "The :attribute field must match :other.", - "ne_field": "The :attribute field and :other must be different.", - "gt_field": "The :attribute field must be greater than :value.", - "gte_field": "The :attribute field must be greater than or equal to :value.", - "lt_field": "The :attribute field must be less than :value.", - "lte_field": "The :attribute field must be less than or equal to :value.", - "gt_date": "The :attribute field must be a date after :date.", - "lt_date": "The :attribute field must be a date before :date.", - "gte_date": "The :attribute field must be a date after or equal to :date.", - "lte_date": "The :attribute field must be a date before or equal to :date.", - "number": "The :attribute field must be a number.", - "full_url": "The :attribute field must be a valid URL.", -} +var defaultMessages map[string]string // sizeRules are rules that have type-specific messages. var sizeRules = map[string]bool{ @@ -206,9 +22,10 @@ var sizeRules = map[string]bool{ // Priority: // 1. Custom field+rule message: customMessages["field.rule"] // 2. Custom rule message: customMessages["rule"] -// 3. Type-specific default: defaultMessages["rule.type"] (for size rules) -// 4. Generic default: defaultMessages["rule"] -func getMessage(field, rule string, customMessages map[string]string, attrType string) string { +// 3. Translated message: translator.Get("validation.rule") (if translator available) +// 4. Type-specific default: defaultMessages["rule.type"] (for size rules) +// 5. Generic default: defaultMessages["rule"] +func getMessage(field, rule string, customMessages map[string]string, attrType string, translator contractstranslation.Translator) string { // 1. Custom field+rule message if msg, ok := customMessages[field+"."+rule]; ok { return msg @@ -219,14 +36,28 @@ func getMessage(field, rule string, customMessages map[string]string, attrType s return msg } - // 3. Type-specific default for size rules + // 3. Translated message + if translator != nil { + if sizeRules[rule] { + key := "validation." + rule + "." + attrType + if translator.Has(key) { + return translator.Get(key) + } + } + key := "validation." + rule + if translator.Has(key) { + return translator.Get(key) + } + } + + // 4. Type-specific default for size rules if sizeRules[rule] { if msg, ok := defaultMessages[rule+"."+attrType]; ok { return msg } } - // 4. Generic default + // 5. Generic default if msg, ok := defaultMessages[rule]; ok { return msg } diff --git a/validation/messages_test.go b/validation/messages_test.go index a1379001b..102be8603 100644 --- a/validation/messages_test.go +++ b/validation/messages_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + mocktranslation "github.com/goravel/framework/mocks/translation" ) func TestGetMessage(t *testing.T) { @@ -12,7 +14,7 @@ func TestGetMessage(t *testing.T) { "name.required": "Name is absolutely required!", "required": "Field is required.", } - msg := getMessage("name", "required", custom, "string") + msg := getMessage("name", "required", custom, "string", nil) assert.Equal(t, "Name is absolutely required!", msg) }) @@ -20,42 +22,93 @@ func TestGetMessage(t *testing.T) { custom := map[string]string{ "required": "This field cannot be empty.", } - msg := getMessage("email", "required", custom, "string") + msg := getMessage("email", "required", custom, "string", nil) assert.Equal(t, "This field cannot be empty.", msg) }) t.Run("type-specific default for size rules", func(t *testing.T) { - msg := getMessage("name", "min", nil, "string") + msg := getMessage("name", "min", nil, "string", nil) assert.Equal(t, "The :attribute field must be at least :min characters.", msg) - msg = getMessage("age", "min", nil, "numeric") + msg = getMessage("age", "min", nil, "numeric", nil) assert.Equal(t, "The :attribute field must be at least :min.", msg) - msg = getMessage("items", "min", nil, "array") + msg = getMessage("items", "min", nil, "array", nil) assert.Equal(t, "The :attribute field must have at least :min items.", msg) - msg = getMessage("doc", "min", nil, "file") + msg = getMessage("doc", "min", nil, "file", nil) assert.Equal(t, "The :attribute field must be at least :min kilobytes.", msg) }) t.Run("generic default message", func(t *testing.T) { - msg := getMessage("email", "email", nil, "string") + msg := getMessage("email", "email", nil, "string", nil) assert.Equal(t, "The :attribute field must be a valid email address.", msg) }) t.Run("unknown rule returns fallback", func(t *testing.T) { - msg := getMessage("field", "unknown_rule_xyz", nil, "string") + msg := getMessage("field", "unknown_rule_xyz", nil, "string", nil) assert.Equal(t, "The :attribute field is invalid.", msg) }) t.Run("all size rules have type variants", func(t *testing.T) { for rule := range sizeRules { for _, typ := range []string{"string", "numeric", "array", "file"} { - msg := getMessage("field", rule, nil, typ) + msg := getMessage("field", rule, nil, typ, nil) assert.NotEqual(t, "The :attribute field is invalid.", msg, "missing message for %s.%s", rule, typ) } } }) + + t.Run("translated message overrides default", func(t *testing.T) { + translator := mocktranslation.NewTranslator(t) + translator.EXPECT().Has("validation.required").Return(true).Once() + translator.EXPECT().Get("validation.required").Return("Le champ :attribute est requis.").Once() + + msg := getMessage("name", "required", nil, "string", translator) + assert.Equal(t, "Le champ :attribute est requis.", msg) + }) + + t.Run("translated size-specific message", func(t *testing.T) { + translator := mocktranslation.NewTranslator(t) + translator.EXPECT().Has("validation.min.string").Return(true).Once() + translator.EXPECT().Get("validation.min.string").Return("Le champ :attribute doit avoir au moins :min caracteres.").Once() + + msg := getMessage("name", "min", nil, "string", translator) + assert.Equal(t, "Le champ :attribute doit avoir au moins :min caracteres.", msg) + }) + + t.Run("translated generic message when type-specific not found", func(t *testing.T) { + translator := mocktranslation.NewTranslator(t) + translator.EXPECT().Has("validation.min.string").Return(false).Once() + translator.EXPECT().Has("validation.min").Return(true).Once() + translator.EXPECT().Get("validation.min").Return("Le champ :attribute doit etre au moins :min.").Once() + + msg := getMessage("name", "min", nil, "string", translator) + assert.Equal(t, "Le champ :attribute doit etre au moins :min.", msg) + }) + + t.Run("custom message takes priority over translation", func(t *testing.T) { + translator := mocktranslation.NewTranslator(t) + + custom := map[string]string{ + "required": "Custom required message.", + } + msg := getMessage("name", "required", custom, "string", translator) + assert.Equal(t, "Custom required message.", msg) + }) + + t.Run("nil translator falls back to default", func(t *testing.T) { + msg := getMessage("name", "required", nil, "string", nil) + assert.Equal(t, "The :attribute field is required.", msg) + }) + + t.Run("translator has no translation falls back to default", func(t *testing.T) { + translator := mocktranslation.NewTranslator(t) + translator.EXPECT().Has("validation.required").Return(false).Once() + + msg := getMessage("name", "required", nil, "string", translator) + assert.Equal(t, "The :attribute field is required.", msg) + }) } func TestFormatMessage(t *testing.T) { diff --git a/validation/service_provider.go b/validation/service_provider.go index 4f438505e..01badc206 100644 --- a/validation/service_provider.go +++ b/validation/service_provider.go @@ -1,14 +1,20 @@ package validation import ( + "context" + "github.com/goravel/framework/contracts/binding" consolecontract "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/database/orm" "github.com/goravel/framework/contracts/foundation" + contractstranslation "github.com/goravel/framework/contracts/translation" "github.com/goravel/framework/validation/console" ) -var ormFacade orm.Orm +var ( + ormFacade orm.Orm + langFacade = func(ctx context.Context) contractstranslation.Translator { return nil } +) type ServiceProvider struct { } @@ -31,6 +37,11 @@ func (r *ServiceProvider) Register(app foundation.Application) { func (r *ServiceProvider) Boot(app foundation.Application) { ormFacade = app.MakeOrm() + langFacade = app.MakeLang + + app.Publishes("github.com/goravel/framework/validation", map[string]string{ + "lang": app.LangPath(), + }, "goravel-validation-lang") app.Commands([]consolecontract.Command{ &console.RuleMakeCommand{}, diff --git a/validation/validation.go b/validation/validation.go index 95d01e0c3..f07a44c3b 100644 --- a/validation/validation.go +++ b/validation/validation.go @@ -129,6 +129,7 @@ func (r *Validation) Make(ctx context.Context, data any, rules map[string]any, o customRules: customRulesMap, messages: customMessages, attributes: customAttributes, + translator: langFacade(ctx), }) errorBag := engine.Validate() validatedData := engine.ValidatedData()