Skip to content

Commit

Permalink
Introduce i18n
Browse files Browse the repository at this point in the history
  • Loading branch information
nao1215 committed Sep 2, 2024
1 parent 5fbfcb0 commit 4c72f6c
Show file tree
Hide file tree
Showing 13 changed files with 481 additions and 72 deletions.
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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

Expand Down Expand Up @@ -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
}
```
Expand Down
27 changes: 24 additions & 3 deletions csv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 (
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
}
}
Expand Down
16 changes: 8 additions & 8 deletions csv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -276,27 +276,27 @@ 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:
if err.Error() != "line:7 column is_admin: target is not a boolean: value=2" {
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:
Expand Down Expand Up @@ -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)
}
}
Expand Down
94 changes: 92 additions & 2 deletions errors.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
Loading

0 comments on commit 4c72f6c

Please sign in to comment.