From 4c72f6ca8f3a80ffced3dd1226f7afe6f7373324 Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Mon, 2 Sep 2024 14:40:09 +0900 Subject: [PATCH] Introduce i18n --- README.md | 40 ++++++++++++++++- csv.go | 27 ++++++++++-- csv_test.go | 16 +++---- errors.go | 94 ++++++++++++++++++++++++++++++++++++++- errors_test.go | 67 ++++++++++++++++++++++++++++ example_test.go | 34 +++++++++++++- go.mod | 3 ++ go.sum | 9 ++++ i18n/en.yaml | 59 +++++++++++++++++++++++++ i18n/ja.yaml | 65 +++++++++++++++++++++++++++ option.go | 12 +++++ validation.go | 108 ++++++++++++++++++++++----------------------- validation_test.go | 19 ++++++-- 13 files changed, 481 insertions(+), 72 deletions(-) create mode 100644 errors_test.go create mode 100644 i18n/en.yaml create mode 100644 i18n/ja.yaml diff --git a/README.md b/README.md index ad06108..b46369f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ ## What is csv package? The csv package is a library for performing validation when reading CSV or TSV files. Validation rules are specified using struct tags. The csv package read returns which columns of which rows do not adhere to the specified rules. + +We are implementing internationalization (i18n) for error messages to make them easier for non-engineers to understand. ## Why need csv package? @@ -26,6 +28,7 @@ Please attach the "validate:" tag to your structure and write the validation rul When using csv.Decode, please pass a pointer to a slice of structures tagged with struct tags. The csv package will perform validation based on the struct tags and save the read results to the slice of structures if there are no errors. If there are errors, it will return them as []error. +### Example: english error message ```go package main @@ -63,9 +66,44 @@ a,Yulia,25 } // Output: - // line:2 column age: target is not greater than the threshold value: threshold=24.000000, value=23.000000 + // line:2 column age: target is not greater than the threshold value: threshold=24, value=23 // line:3 column id: target is not a numeric character: value=a // line:4 column name: target is not an alphabetic character: value=Den1s +``` + +### Example: japanese error message + +```go +func ExampleCSVInJapanese() { + input := `id,name,age +1,Gina,23 +a,Yulia,25 +3,Den1s,30 +` + buf := bytes.NewBufferString(input) + c, err := csv.NewCSV(buf, csv.WithJapaneseLanguage()) // Set Japanese language option + if err != nil { + panic(err) + } + + type person struct { + ID int `validate:"numeric"` + Name string `validate:"alpha"` + Age int `validate:"gt=24"` + } + people := make([]person, 0) + + errs := c.Decode(&people) + if len(errs) != 0 { + for _, err := range errs { + fmt.Println(err.Error()) + } + } + + // Output: + // line:2 column age: ターゲットがしきい値より大きくありません: threshold=24, value=23 + // line:3 column id: ターゲットが数字ではありません: value=a + // line:4 column name: ターゲットがアルファベット文字ではありません: value=Den1s } ``` diff --git a/csv.go b/csv.go index b446fb4..fd257d6 100644 --- a/csv.go +++ b/csv.go @@ -4,13 +4,21 @@ package csv import ( + "embed" "encoding/csv" "fmt" "io" "reflect" "strconv" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" + "gopkg.in/yaml.v2" ) +//go:embed i18n/* +var LocaleFS embed.FS + // CSV is a struct that implements CSV Reader and Writer. type CSV struct { // headerless is a flag that indicates the csv file has no header. @@ -22,6 +30,12 @@ type CSV struct { // ruleSets is slice of ruleSet. // The order of the ruleSet is the same as the order of the columns in the csv. ruleSet ruleSet + // i18nBundle is the i18n bundle. It is used to translate error messages. + // The default language is English. + i18nBundle *i18n.Bundle + // i18nLocalizer is the i18n localizer. It is used to localize error messages. + // The default language is English. + i18nLocalizer *i18n.Localizer } type ( @@ -38,6 +52,15 @@ func NewCSV(r io.Reader, opts ...Option) (*CSV, error) { csv := &CSV{ reader: csv.NewReader(r), } + csv.i18nBundle = i18n.NewBundle(language.English) + csv.i18nBundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal) + if _, err := csv.i18nBundle.LoadMessageFileFS(LocaleFS, "i18n/en.yaml"); err != nil { + return nil, NewError(csv.i18nLocalizer, "ErrLoadMessageFile", err.Error()) + } + if _, err := csv.i18nBundle.LoadMessageFileFS(LocaleFS, "i18n/ja.yaml"); err != nil { + return nil, NewError(csv.i18nLocalizer, "ErrLoadMessageFile", err.Error()) + } + csv.i18nLocalizer = i18n.NewLocalizer(csv.i18nBundle, "en") for _, opt := range opts { if err := opt(csv); err != nil { @@ -49,8 +72,6 @@ func NewCSV(r io.Reader, opts ...Option) (*CSV, error) { // Decode reads the CSV and returns the columns that have syntax errors on a per-line basis. // The strutSlicePointer is a pointer to structure slice where validation rules are set in struct tags. -// -// Example: func (c *CSV) Decode(structSlicePointer any) []error { errors := make([]error, 0) if err := c.parseStructTag(structSlicePointer); err != nil { @@ -84,7 +105,7 @@ func (c *CSV) Decode(structSlicePointer any) []error { for i, v := range record { validators := c.ruleSet[i] for _, validator := range validators { - if err := validator.Do(v); err != nil { + if err := validator.Do(c.i18nLocalizer, v); err != nil { errors = append(errors, fmt.Errorf("line:%d column %s: %w", line, c.header[i], err)) } } diff --git a/csv_test.go b/csv_test.go index bc473ca..3ed95e8 100644 --- a/csv_test.go +++ b/csv_test.go @@ -260,7 +260,7 @@ func Test_ErrCheck(t *testing.T) { for i, err := range got { switch i { case 0: - if err.Error() != "line:2 column id: target is not greater than or equal to the threshold value: threshold=1.000000, value=0.000000" { + if err.Error() != "line:2 column id: target is not greater than or equal to the threshold value: threshold=1, value=0" { t.Errorf("CSV.Decode() got errors: %v", err) } case 1: @@ -276,15 +276,15 @@ func Test_ErrCheck(t *testing.T) { t.Errorf("CSV.Decode() got errors: %v", err) } case 4: - if err.Error() != "line:5 column zero: target is not equal to the threshold value: threshold=0.000000, value=1.000000" { + if err.Error() != "line:5 column zero: target is not equal to the threshold value: threshold=0, value=1" { t.Errorf("CSV.Decode() got errors: %v", err) } case 5: - if err.Error() != "line:5 column zero: target is equal to threshold the value: threshold=1.000000, value=1.000000" { + if err.Error() != "line:5 column zero: target is equal to the threshold value: threshold=1, value=1" { t.Errorf("CSV.Decode() got errors: %v", err) } case 6: - if err.Error() != "line:6 column age: target is not less than the threshold value: threshold=120.000000, value=120.000000" { + if err.Error() != "line:6 column age: target is not less than the threshold value: threshold=120, value=120" { t.Errorf("CSV.Decode() got errors: %v", err) } case 7: @@ -292,11 +292,11 @@ func Test_ErrCheck(t *testing.T) { t.Errorf("CSV.Decode() got errors: %v", err) } case 8: - if err.Error() != "line:8 column age: target is not greater than the threshold value: threshold=-1.000000, value=-1.000000" { + if err.Error() != "line:8 column age: target is not greater than the threshold value: threshold=-1, value=-1" { t.Errorf("CSV.Decode() got errors: %v", err) } case 9: - if err.Error() != "line:8 column age: target is not greater than or equal to the threshold value: threshold=0.000000, value=-1.000000" { + if err.Error() != "line:8 column age: target is not greater than or equal to the threshold value: threshold=0, value=-1" { t.Errorf("CSV.Decode() got errors: %v", err) } case 10: @@ -332,11 +332,11 @@ func Test_ErrCheck(t *testing.T) { for i, err := range errs { switch i { case 0: - if err.Error() != "line:3 column age: target is less than the minimum value: threshold=0.000000, value=-1.000000" { + if err.Error() != "line:3 column age: target is less than the minimum value: threshold=0, value=-1" { t.Errorf("CSV.Decode() got errors: %v", err) } case 1: - if err.Error() != "line:5 column age: target is greater than the maximum value: threshold=120.000000, value=120.100000" { + if err.Error() != "line:5 column age: target is greater than the maximum value: threshold=120, value=120.1" { t.Errorf("CSV.Decode() got errors: %v", err) } } diff --git a/errors.go b/errors.go index 591058f..77e3b16 100644 --- a/errors.go +++ b/errors.go @@ -1,6 +1,97 @@ package csv -import "errors" +import ( + "errors" + "fmt" + + "github.com/nicksnyder/go-i18n/v2/i18n" +) + +// Error is an error that is used to localize error messages. +type Error struct { + id string + subMessage string + localizer *i18n.Localizer +} + +// Error returns the localized error message. +func (e *Error) Error() string { + if e.subMessage != "" { + return fmt.Sprintf( + "%s: %s", + e.localizer.MustLocalize(&i18n.LocalizeConfig{ + MessageID: e.id, + }), + e.subMessage, + ) + } + return e.localizer.MustLocalize(&i18n.LocalizeConfig{ + MessageID: e.id, + }) +} + +// Is reports whether the target error is the same as the error. +func (e *Error) Is(target error) bool { + t, ok := target.(*Error) + if !ok { + return false + } + return e.id == t.id +} + +// NewError returns a new Error. +func NewError(localizer *i18n.Localizer, id, subMessage string) *Error { + return &Error{ + id: id, + subMessage: subMessage, + localizer: localizer, + } +} + +var ( + // ErrStructSlicePointerID is the error ID used when the value is not a pointer to a struct slice. + ErrStructSlicePointerID = "ErrStructSlicePointer" + // ErrInvalidOneOfFormatID is the error ID used when the target is not one of the specified values. + ErrInvalidOneOfFormatID = "ErrInvalidOneOfFormat" + // ErrInvalidThresholdFormatID is the error ID used when the threshold format is invalid. + ErrInvalidThresholdFormatID = "ErrInvalidThresholdFormat" + // ErrInvalidBooleanID is the error ID used when the target is not a boolean. + ErrInvalidBooleanID = "ErrInvalidBoolean" + // ErrInvalidAlphabetID is the error ID used when the target is not an alphabetic character. + ErrInvalidAlphabetID = "ErrInvalidAlphabet" + // ErrInvalidNumericID is the error ID used when the target is not a numeric character. + ErrInvalidNumericID = "ErrInvalidNumeric" + // ErrInvalidAlphanumericID is the error ID used when the target is not an alphanumeric character. + ErrInvalidAlphanumericID = "ErrInvalidAlphanumeric" + // ErrRequiredID is the error ID used when the target is required but is empty. + ErrRequiredID = "ErrRequired" + // ErrEqualID is the error ID used when the target is not equal to the threshold value. + ErrEqualID = "ErrEqual" + // ErrInvalidThresholdID is the error ID used when the threshold value is invalid. + ErrInvalidThresholdID = "ErrInvalidThreshold" + // ErrNotEqualID is the error ID used when the target is equal to the threshold value. + ErrNotEqualID = "ErrNotEqual" + // ErrGreaterThanID is the error ID used when the target is not greater than the threshold value. + ErrGreaterThanID = "ErrGreaterThan" + // ErrGreaterThanEqualID is the error ID used when the target is not greater than or equal to the threshold value. + ErrGreaterThanEqualID = "ErrGreaterThanEqual" + // ErrLessThanID is the error ID used when the target is not less than the threshold value. + ErrLessThanID = "ErrLessThan" + // ErrLessThanEqualID is the error ID used when the target is not less than or equal to the threshold value. + ErrLessThanEqualID = "ErrLessThanEqual" + // ErrMinID is the error ID used when the target is less than the minimum value. + ErrMinID = "ErrMin" + // ErrMaxID is the error ID used when the target is greater than the maximum value. + ErrMaxID = "ErrMax" + // ErrLengthID is the error ID used when the target length is not equal to the threshold value. + ErrLengthID = "ErrLength" + // ErrOneOfID is the error ID used when the target is not one of the specified values. + ErrOneOfID = "ErrOneOf" + // ErrInvalidStructID is the error ID used when the target is not a struct. + ErrInvalidStructID = "ErrInvalidStruct" + // ErrUnsupportedTypeID is the error ID used when the target is an unsupported type. + ErrUnsupportedTypeID = "ErrUnsupportedType" +) var ( // ErrStructSlicePointer is returned when the value is not a pointer to a struct slice. @@ -9,7 +100,6 @@ var ( ErrInvalidOneOfFormat = errors.New("target is not one of the values") // ErrInvalidThresholdFormat is returned when the threshold value is not an integer. ErrInvalidThresholdFormat = errors.New("threshold format is invalid") - // ErrInvalidBoolean is returned when the target is not a boolean. ErrInvalidBoolean = errors.New("target is not a boolean") // ErrInvalidAlphabet is returned when the target is not an alphabetic character. diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..0e94a74 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,67 @@ +package csv + +import ( + "testing" +) + +func TestError_Error(t *testing.T) { + t.Parallel() + + t.Run("should return the localized error message", func(t *testing.T) { + t.Parallel() + + err := NewError(helperLocalizer(t), ErrStructSlicePointerID, "subMessage") + + got := err.Error() + want := "value is not a pointer to a struct slice: subMessage" + + if got != want { + t.Errorf("Error() = %v, want %v", got, want) + } + }) + + t.Run("should return the localized error message without subMessage", func(t *testing.T) { + t.Parallel() + + err := NewError(helperLocalizer(t), ErrStructSlicePointerID, "") + + got := err.Error() + want := "value is not a pointer to a struct slice" + + if got != want { + t.Errorf("Error() = %v, want %v", got, want) + } + }) +} + +func TestError_Is(t *testing.T) { + t.Parallel() + + t.Run("should return true if the target error is the same as the error", func(t *testing.T) { + t.Parallel() + + err := NewError(helperLocalizer(t), ErrStructSlicePointerID, "subMessage") + target := NewError(helperLocalizer(t), ErrStructSlicePointerID, "subMessage") + + got := err.Is(target) + want := true + + if got != want { + t.Errorf("Is() = %v, want %v", got, want) + } + }) + + t.Run("should return false if the target error is not the same as the error", func(t *testing.T) { + t.Parallel() + + err := NewError(helperLocalizer(t), ErrStructSlicePointerID, "subMessage") + target := NewError(helperLocalizer(t), ErrEqualID, "") + + got := err.Is(target) + want := false + + if got != want { + t.Errorf("Is() = %v, want %v", got, want) + } + }) +} diff --git a/example_test.go b/example_test.go index e98d62d..76671cd 100644 --- a/example_test.go +++ b/example_test.go @@ -36,7 +36,39 @@ a,Yulia,25 } // Output: - // line:2 column age: target is not greater than the threshold value: threshold=24.000000, value=23.000000 + // line:2 column age: target is not greater than the threshold value: threshold=24, value=23 // line:3 column id: target is not a numeric character: value=a // line:4 column name: target is not an alphabetic character: value=Den1s } + +func ExampleCSV_InJapanese() { + input := `id,name,age +1,Gina,23 +a,Yulia,25 +3,Den1s,30 +` + buf := bytes.NewBufferString(input) + c, err := csv.NewCSV(buf, csv.WithJapaneseLanguage()) + if err != nil { + panic(err) + } + + type person struct { + ID int `validate:"numeric"` + Name string `validate:"alpha"` + Age int `validate:"gt=24"` + } + people := make([]person, 0) + + errs := c.Decode(&people) + if len(errs) != 0 { + for _, err := range errs { + fmt.Println(err.Error()) + } + } + + // Output: + // line:2 column age: ターゲットがしきい値より大きくありません: threshold=24, value=23 + // line:3 column id: ターゲットが数字ではありません: value=a + // line:4 column name: ターゲットがアルファベット文字ではありません: value=Den1s +} diff --git a/go.mod b/go.mod index e1c4e5f..5a9f960 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,8 @@ go 1.20 require ( github.com/google/go-cmp v0.6.0 github.com/motemen/go-testutil v0.0.0-20231019055648-af6add1c10c8 + github.com/nicksnyder/go-i18n/v2 v2.4.0 github.com/rivo/uniseg v0.4.7 + golang.org/x/text v0.17.0 + gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index eaabd08..b068dbc 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,15 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/motemen/go-testutil v0.0.0-20231019055648-af6add1c10c8 h1:bSyU9M98Y4Rrs8X1tYkO8hyHsGw1kWCWyG6FnCQ0l/E= github.com/motemen/go-testutil v0.0.0-20231019055648-af6add1c10c8/go.mod h1:fz3ptMGvFb+/JIPQadvSpFND5BuGi7cJka/JgG7njN8= +github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM= +github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/i18n/en.yaml b/i18n/en.yaml new file mode 100644 index 0000000..9b1734f --- /dev/null +++ b/i18n/en.yaml @@ -0,0 +1,59 @@ +- id: "ErrStructSlicePointer" + translation: "value is not a pointer to a struct slice" + +- id: "ErrInvalidOneOfFormat" + translation: "target is not one of the values" + +- id: "ErrInvalidThresholdFormat" + translation: "threshold format is invalid" + +- id: "ErrInvalidBoolean" + translation: "target is not a boolean" + +- id: "ErrInvalidAlphabet" + translation: "target is not an alphabetic character" + +- id: "ErrInvalidNumeric" + translation: "target is not a numeric character" + +- id: "ErrInvalidAlphanumeric" + translation: "target is not an alphanumeric character" + +- id: "ErrRequired" + translation: "target is required but is empty" + +- id: "ErrEqual" + translation: "target is not equal to the threshold value" + +- id: "ErrInvalidThreshold" + translation: "threshold value is invalid" + +- id: "ErrNotEqual" + translation: "target is equal to the threshold value" + +- id: "ErrGreaterThan" + translation: "target is not greater than the threshold value" + +- id: "ErrGreaterThanEqual" + translation: "target is not greater than or equal to the threshold value" + +- id: "ErrLessThan" + translation: "target is not less than the threshold value" + +- id: "ErrLessThanEqual" + translation: "target is not less than or equal to the threshold value" + +- id: "ErrMin" + translation: "target is less than the minimum value" + +- id: "ErrMax" + translation: "target is greater than the maximum value" + +- id: "ErrLength" + translation: "target length is not equal to the threshold value" + +- id: "ErrOneOf" + translation: "target is not one of the values" + +- id: "ErrLoadMessageFile" + translation: "failed to load message file" diff --git a/i18n/ja.yaml b/i18n/ja.yaml new file mode 100644 index 0000000..6b4b8ef --- /dev/null +++ b/i18n/ja.yaml @@ -0,0 +1,65 @@ +- id: "ErrStructSlicePointer" + translation: "値が構造体スライスへのポインタではありません" + +- id: "ErrInvalidOneOfFormat" + translation: "ターゲットは値のいずれかではありません" + +- id: "ErrInvalidThresholdFormat" + translation: "しきい値の形式が無効です" + +- id: "ErrInvalidBoolean" + translation: "ターゲットがブール値ではありません" + +- id: "ErrInvalidAlphabet" + translation: "ターゲットがアルファベット文字ではありません" + +- id: "ErrInvalidNumeric" + translation: "ターゲットが数字ではありません" + +- id: "ErrInvalidAlphanumeric" + translation: "ターゲットが英数字ではありません" + +- id: "ErrRequired" + translation: "ターゲットは必須ですが、空です" + +- id: "ErrEqual" + translation: "ターゲットがしきい値と等しくありません" + +- id: "ErrInvalidThreshold" + translation: "しきい値が無効です" + +- id: "ErrNotEqual" + translation: "ターゲットがしきい値と等しいです" + +- id: "ErrGreaterThan" + translation: "ターゲットがしきい値より大きくありません" + +- id: "ErrGreaterThanEqual" + translation: "ターゲットがしきい値以上ではありません" + +- id: "ErrLessThan" + translation: "ターゲットがしきい値より小さくありません" + +- id: "ErrLessThanEqual" + translation: "ターゲットがしきい値以下ではありません" + +- id: "ErrMin" + translation: "ターゲットが最小値を下回っています" + +- id: "ErrMax" + translation: "ターゲットが最大値を超えています" + +- id: "ErrLength" + translation: "ターゲットの長さがしきい値と等しくありません" + +- id: "ErrOneOf" + translation: "ターゲットが値のいずれかではありません" + +- id: "ErrInvalidStruct" + translation: "構造体の範囲外のインデックスです" + +- id: "ErrUnsupportedFieldType" + translation: "サポートされていないフィールドタイプです" + +- id: "ErrLoadMessageFile" + translation: "メッセージファイルの読み込みに失敗しました" diff --git a/option.go b/option.go index 5ad00e3..fe913e0 100644 --- a/option.go +++ b/option.go @@ -1,5 +1,9 @@ package csv +import ( + "github.com/nicksnyder/go-i18n/v2/i18n" +) + // Option is a function that sets a configuration option for CSV struct. type Option func(c *CSV) error @@ -18,3 +22,11 @@ func WithHeaderless() Option { return nil } } + +// WithJapaneseLanguage is an Option that sets the i18n bundle to Japanese. +func WithJapaneseLanguage() Option { + return func(c *CSV) error { + c.i18nLocalizer = i18n.NewLocalizer(c.i18nBundle, "ja") + return nil + } +} diff --git a/validation.go b/validation.go index 8ef68e7..eee4f42 100644 --- a/validation.go +++ b/validation.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "github.com/nicksnyder/go-i18n/v2/i18n" "github.com/rivo/uniseg" ) @@ -13,7 +14,7 @@ type validators []validator // validator is the interface that wraps the Do method. type validator interface { - Do(target any) error + Do(localizer *i18n.Localizer, target any) error } // booleanValidator is a struct that contains the validation rules for a boolean column. @@ -26,13 +27,13 @@ func newBooleanValidator() *booleanValidator { // Do validates the target as a boolean. // If the target is an int, it will be validated as a boolean if it's 0 or 1. -func (b *booleanValidator) Do(target any) error { +func (b *booleanValidator) Do(localizer *i18n.Localizer, target any) error { if v, ok := target.(string); ok { if v == "true" || v == "false" || v == "0" || v == "1" { return nil } } - return fmt.Errorf("%w: value=%v", ErrInvalidBoolean, target) //nolint + return NewError(localizer, ErrInvalidBooleanID, fmt.Sprintf("value=%v", target)) } // alphabetValidator is a struct that contains the validation rules for an alpha column. @@ -44,15 +45,15 @@ func newAlphaValidator() *alphabetValidator { } // Do validates the target string only contains alphabetic character. -func (a *alphabetValidator) Do(target any) error { +func (a *alphabetValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrInvalidAlphabet, target) //nolint + return NewError(localizer, ErrInvalidAlphabetID, fmt.Sprintf("value=%v", target)) } for _, r := range v { if !isAlpha(r) { - return fmt.Errorf("%w: value=%v", ErrInvalidAlphabet, target) //nolint + return NewError(localizer, ErrInvalidAlphabetID, fmt.Sprintf("value=%v", target)) } } return nil @@ -72,10 +73,10 @@ func newNumericValidator() *numericValidator { } // Do validates the target as a numeric. -func (n *numericValidator) Do(target any) error { +func (n *numericValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrInvalidNumeric, target) //nolint + return NewError(localizer, ErrInvalidNumericID, fmt.Sprintf("value=%v", target)) } if v == "" { @@ -83,7 +84,7 @@ func (n *numericValidator) Do(target any) error { } if _, err := strconv.Atoi(v); err != nil { - return fmt.Errorf("%w: value=%s", ErrInvalidNumeric, v) //nolint + return NewError(localizer, ErrInvalidNumericID, fmt.Sprintf("value=%v", target)) } return nil } @@ -102,15 +103,15 @@ func newAlphanumericValidator() *alphanumericValidator { } // Do validates the target string only contains alphanumeric character. -func (a *alphanumericValidator) Do(target any) error { +func (a *alphanumericValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrInvalidAlphanumeric, target) //nolint + return NewError(localizer, ErrInvalidAlphanumericID, fmt.Sprintf("value=%v", target)) } for _, r := range v { if !isAlpha(r) && !isNumeric(r) { - return fmt.Errorf("%w: value=%v", ErrInvalidAlphanumeric, target) //nolint + return NewError(localizer, ErrInvalidAlphanumericID, fmt.Sprintf("value=%v", target)) } } return nil @@ -125,14 +126,14 @@ func newRequiredValidator() *requiredValidator { } // Do validates the target is not empty. -func (r *requiredValidator) Do(target any) error { +func (r *requiredValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrRequired, target) //nolint + return NewError(localizer, ErrRequiredID, fmt.Sprintf("value=%v", target)) } if v == "" { - return fmt.Errorf("%w: value=%v", ErrRequired, target) //nolint + return NewError(localizer, ErrRequiredID, fmt.Sprintf("value=%v", target)) } return nil } @@ -148,19 +149,18 @@ func newEqualValidator(threshold float64) *equalValidator { } // Do validates the target is equal to the threshold. -func (e *equalValidator) Do(target any) error { +func (e *equalValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrEqual, target) //nolint + return NewError(localizer, ErrEqualID, fmt.Sprintf("value=%v", target)) } value, err := strconv.ParseFloat(v, 64) if err != nil { - return fmt.Errorf("%w: value=%v", ErrEqual, target) //nolint + return NewError(localizer, ErrEqualID, fmt.Sprintf("value=%v", target)) } - if value != e.threshold { - return fmt.Errorf("%w: threshold=%f, value=%f", ErrEqual, e.threshold, value) //nolint + return NewError(localizer, ErrEqualID, fmt.Sprintf("threshold=%v, value=%v", e.threshold, value)) } return nil } @@ -176,19 +176,19 @@ func newNotEqualValidator(threshold float64) *notEqualValidator { } // Do validates the target is not equal to the threshold. -func (n *notEqualValidator) Do(target any) error { +func (n *notEqualValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrNotEqual, target) //nolint + return NewError(localizer, ErrNotEqualID, fmt.Sprintf("value=%v", target)) } value, err := strconv.ParseFloat(v, 64) if err != nil { - return fmt.Errorf("%w: value=%v", ErrNotEqual, target) //nolint + return NewError(localizer, ErrNotEqualID, fmt.Sprintf("value=%v", target)) } if value == n.threshold { - return fmt.Errorf("%w: threshold=%f, value=%f", ErrNotEqual, n.threshold, value) //nolint + return NewError(localizer, ErrNotEqualID, fmt.Sprintf("threshold=%v, value=%v", n.threshold, value)) } return nil } @@ -204,19 +204,19 @@ func newGreaterThanValidator(threshold float64) *greaterThanValidator { } // Do validates the target is greater than the threshold. -func (g *greaterThanValidator) Do(target any) error { +func (g *greaterThanValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrGreaterThan, target) //nolint + return NewError(localizer, ErrGreaterThanID, fmt.Sprintf("value=%v", target)) } value, err := strconv.ParseFloat(v, 64) if err != nil { - return fmt.Errorf("%w: value=%v", ErrGreaterThan, target) //nolint + return NewError(localizer, ErrGreaterThanID, fmt.Sprintf("value=%v", target)) } if value <= g.threshold { - return fmt.Errorf("%w: threshold=%f, value=%f", ErrGreaterThan, g.threshold, value) //nolint + return NewError(localizer, ErrGreaterThanID, fmt.Sprintf("threshold=%v, value=%v", g.threshold, value)) } return nil } @@ -232,19 +232,19 @@ func newGreaterThanEqualValidator(threshold float64) *greaterThanEqualValidator } // Do validates the target is greater than or equal to the threshold. -func (g *greaterThanEqualValidator) Do(target any) error { +func (g *greaterThanEqualValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrGreaterThanEqual, target) //nolint + return NewError(localizer, ErrGreaterThanEqualID, fmt.Sprintf("value=%v", target)) } value, err := strconv.ParseFloat(v, 64) if err != nil { - return fmt.Errorf("%w: value=%v", ErrGreaterThanEqual, target) //nolint + return NewError(localizer, ErrGreaterThanEqualID, fmt.Sprintf("value=%v", target)) } if value < g.threshold { - return fmt.Errorf("%w: threshold=%f, value=%f", ErrGreaterThanEqual, g.threshold, value) //nolint + return NewError(localizer, ErrGreaterThanEqualID, fmt.Sprintf("threshold=%v, value=%v", g.threshold, value)) } return nil } @@ -260,18 +260,18 @@ func newLessThanValidator(threshold float64) *lessThanValidator { } // Do validates the target is less than the threshold. -func (l *lessThanValidator) Do(target any) error { +func (l *lessThanValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrLessThan, target) //nolint + return NewError(localizer, ErrLessThanID, fmt.Sprintf("value=%v", target)) } value, err := strconv.ParseFloat(v, 64) if err != nil { - return fmt.Errorf("%w: value=%v", ErrLessThan, target) //nolint + return NewError(localizer, ErrLessThanID, fmt.Sprintf("value=%v", target)) } if value >= l.threshold { - return fmt.Errorf("%w: threshold=%f, value=%f", ErrLessThan, l.threshold, value) //nolint + return NewError(localizer, ErrLessThanID, fmt.Sprintf("threshold=%v, value=%v", l.threshold, value)) } return nil } @@ -287,19 +287,19 @@ func newLessThanEqualValidator(threshold float64) *lessThanEqualValidator { } // Do validates the target is less than or equal to the threshold. -func (l *lessThanEqualValidator) Do(target any) error { +func (l *lessThanEqualValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrLessThanEqual, target) //nolint + return NewError(localizer, ErrLessThanEqualID, fmt.Sprintf("value=%v", target)) } value, err := strconv.ParseFloat(v, 64) if err != nil { - return fmt.Errorf("%w: value=%v", ErrLessThanEqual, target) //nolint + return NewError(localizer, ErrLessThanEqualID, fmt.Sprintf("value=%v", target)) } if value > l.threshold { - return fmt.Errorf("%w: threshold=%f, value=%f", ErrLessThanEqual, l.threshold, value) //nolint + return NewError(localizer, ErrLessThanEqualID, fmt.Sprintf("threshold=%v, value=%v", l.threshold, value)) } return nil } @@ -315,19 +315,19 @@ func newMinValidator(threshold float64) *minValidator { } // Do validates the target is greater than or equal to the threshold. -func (m *minValidator) Do(target any) error { +func (m *minValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrMin, target) //nolint + return NewError(localizer, ErrMinID, fmt.Sprintf("value=%v", target)) } value, err := strconv.ParseFloat(v, 64) if err != nil { - return fmt.Errorf("%w: value=%v", ErrMin, target) //nolint + return NewError(localizer, ErrMinID, fmt.Sprintf("value=%v", target)) } if value < m.threshold { - return fmt.Errorf("%w: threshold=%f, value=%f", ErrMin, m.threshold, value) //nolint + return NewError(localizer, ErrMinID, fmt.Sprintf("threshold=%v, value=%v", m.threshold, value)) } return nil } @@ -343,19 +343,19 @@ func newMaxValidator(threshold float64) *maxValidator { } // Do validates the target is less than or equal to the threshold. -func (m *maxValidator) Do(target any) error { +func (m *maxValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrMax, target) //nolint + return NewError(localizer, ErrMaxID, fmt.Sprintf("value=%v", target)) } value, err := strconv.ParseFloat(v, 64) if err != nil { - return fmt.Errorf("%w: value=%v", ErrMax, target) //nolint + return NewError(localizer, ErrMaxID, fmt.Sprintf("value=%v", target)) } if value > m.threshold { - return fmt.Errorf("%w: threshold=%f, value=%f", ErrMax, m.threshold, value) //nolint + return NewError(localizer, ErrMaxID, fmt.Sprintf("threshold=%v, value=%v", m.threshold, value)) } return nil } @@ -371,15 +371,15 @@ func newLengthValidator(threshold float64) *lengthValidator { } // Do validates the target length is equal to the threshold. -func (l *lengthValidator) Do(target any) error { +func (l *lengthValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrLength, target) //nolint + return NewError(localizer, ErrLengthID, fmt.Sprintf("value=%v", target)) } count := uniseg.GraphemeClusterCount(v) if count != int(l.threshold) { - return fmt.Errorf("%w: length threshold=%d, value=%s", ErrLength, int(l.threshold), v) //nolint + return NewError(localizer, ErrLengthID, fmt.Sprintf("length threshold=%v, value=%v", l.threshold, target)) } return nil } @@ -395,10 +395,10 @@ func newOneOfValidator(oneOf []string) *oneOfValidator { } // Do validates the target is one of the oneOf values. -func (o *oneOfValidator) Do(target any) error { +func (o *oneOfValidator) Do(localizer *i18n.Localizer, target any) error { v, ok := target.(string) if !ok { - return fmt.Errorf("%w: value=%v", ErrOneOf, target) //nolint + return NewError(localizer, ErrOneOfID, fmt.Sprintf("value=%v", target)) } for _, s := range o.oneOf { @@ -406,5 +406,5 @@ func (o *oneOfValidator) Do(target any) error { return nil } } - return fmt.Errorf("%w: oneof=%s, value=%v", ErrOneOf, strings.Join(o.oneOf, " "), target) //nolint + return NewError(localizer, ErrOneOfID, fmt.Sprintf("oneof=%s, value=%v", strings.Join(o.oneOf, " "), target)) } diff --git a/validation_test.go b/validation_test.go index 2fc7dec..3acdf13 100644 --- a/validation_test.go +++ b/validation_test.go @@ -4,8 +4,21 @@ import ( "testing" "github.com/motemen/go-testutil/dataloc" + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" + "gopkg.in/yaml.v2" ) +// helperLocalizer is a helper function that returns a new localizer. +func helperLocalizer(t *testing.T) *i18n.Localizer { + t.Helper() + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal) + bundle.LoadMessageFileFS(LocaleFS, "i18n/en.yaml") + bundle.LoadMessageFileFS(LocaleFS, "i18n/ja.yaml") + return i18n.NewLocalizer(bundle, "en") +} + func Test_booleanValidator_Do(t *testing.T) { t.Parallel() @@ -54,7 +67,7 @@ func Test_booleanValidator_Do(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() b := &booleanValidator{} - if err := b.Do(tt.args.target); (err != nil) != tt.wantErr { + if err := b.Do(helperLocalizer(t), tt.args.target); (err != nil) != tt.wantErr { t.Errorf("booleanValidator.Do() error = %v, wantErr %v, test case at %s", err, tt.wantErr, dataloc.L(tt.name)) } }) @@ -106,7 +119,7 @@ func Test_alphaValidator_Do(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { a := &alphabetValidator{} - if err := a.Do(tt.args.target); (err != nil) != tt.wantErr { + if err := a.Do(helperLocalizer(t), tt.args.target); (err != nil) != tt.wantErr { t.Errorf("alphaValidator.Do() error = %v, wantErr %v, test case at %s", err, tt.wantErr, dataloc.L(tt.name)) } }) @@ -158,7 +171,7 @@ func Test_numericValidator_Do(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() n := &numericValidator{} - if err := n.Do(tt.arg); (err != nil) != tt.wantErr { + if err := n.Do(helperLocalizer(t), tt.arg); (err != nil) != tt.wantErr { t.Errorf("numericValidator.Do() error = %v, wantErr %v", err, tt.wantErr) } })