diff --git a/cmd/protoc-gen-go-errors/errors.go b/cmd/protoc-gen-go-errors/errors.go index f09c89ea810..f613728031d 100644 --- a/cmd/protoc-gen-go-errors/errors.go +++ b/cmd/protoc-gen-go-errors/errors.go @@ -15,8 +15,9 @@ import ( ) const ( - errorsPackage = protogen.GoImportPath("github.com/go-kratos/kratos/v2/errors") - fmtPackage = protogen.GoImportPath("fmt") + errorsPackage = protogen.GoImportPath("github.com/go-kratos/kratos/v2/errors") + fmtPackage = protogen.GoImportPath("fmt") + contextPackage = protogen.GoImportPath("context") ) var enCases = cases.Title(language.AmericanEnglish, cases.NoLower) @@ -32,6 +33,7 @@ func generateFile(gen *protogen.Plugin, file *protogen.File) *protogen.Generated g.P() g.P("package ", file.GoPackageName) g.P() + g.QualifiedGoIdent(contextPackage.Ident("")) g.QualifiedGoIdent(fmtPackage.Ident("")) generateFileContent(gen, file, g) return g diff --git a/cmd/protoc-gen-go-errors/errorsTemplate.tpl b/cmd/protoc-gen-go-errors/errorsTemplate.tpl index b77a93905cd..315b295c088 100644 --- a/cmd/protoc-gen-go-errors/errorsTemplate.tpl +++ b/cmd/protoc-gen-go-errors/errorsTemplate.tpl @@ -14,4 +14,11 @@ func Error{{ .CamelValue }}(format string, args ...interface{}) *errors.Error { return errors.New({{ .HTTPCode }}, {{ .Name }}_{{ .Value }}.String(), fmt.Sprintf(format, args...)) } +func Error{{ .CamelValue }}WithContext(ctx context.Context, args ...interface{}) *errors.Error { + if len(args) == 0 { + return errors.NewWithContext(ctx, {{ .HTTPCode }}, {{ .Name }}_{{ .Value }}.String(), "") + } + return errors.NewWithContext(ctx, {{ .HTTPCode }}, {{ .Name }}_{{ .Value }}.String(), args[0]) +} + {{- end }} diff --git a/errors/errors.go b/errors/errors.go index aaffa282c38..f3f5bd16815 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -1,6 +1,7 @@ package errors import ( + "context" "errors" "fmt" @@ -75,6 +76,16 @@ func New(code int, reason, message string) *Error { } } +// NewWithContext Use context to create error objects and support i18n localization +func NewWithContext(ctx context.Context, code int, reason string, data any) *Error { + message := "" + // If the global i18n manager is registered, it is used to localize the error message + if globalI18n != nil { + message = globalI18n.Localize(ctx, reason, data) + } + return New(code, reason, message) +} + // Newf New(code fmt.Sprintf(format, a...)) func Newf(code int, reason, format string, a ...any) *Error { return New(code, reason, fmt.Sprintf(format, a...)) diff --git a/errors/errors_test.go b/errors/errors_test.go index c5f4d42be5a..1ae90f42af2 100644 --- a/errors/errors_test.go +++ b/errors/errors_test.go @@ -1,11 +1,14 @@ package errors import ( + "context" "errors" "fmt" "net/http" "reflect" + "strings" "testing" + text "text/template" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -147,3 +150,104 @@ func TestOther(t *testing.T) { t.Errorf("Clone(nil) = %v, want %v", Clone(err400), err400) } } + +// MockI18n is a mock implementation of the I18n interface +type MockI18n struct { + localizeFunc func(ctx context.Context, reason string, data any) string +} + +// Localize mocks the Localize method of the I18n interface +func (m *MockI18n) Localize(ctx context.Context, reason string, data any) string { + return m.localizeFunc(ctx, reason, data) +} + +func textFormatter(format string, data any) (s string, err error) { + t, err := text.New("text/template").Parse(format) + if err != nil { + return "", nil + } + tmpl := text.Must(t, err) + resultIoWriter := new(strings.Builder) + + if err = tmpl.Execute(resultIoWriter, data); err != nil { + return "", err + } + return resultIoWriter.String(), nil +} + +// TestNewWithContext_GlobalI18nIsNil tests the NewWithContext function when globalI18n is nil. +// It verifies that the returned Error object has an empty Message field. +func TestNewWithContext_GlobalI18nIsNil(t *testing.T) { + // Arrange + ctx := context.Background() + code := 404 + reason := "not_found" + data := map[string]string{"resource": "user"} + + // Act + err := NewWithContext(ctx, code, reason, data) + + // Assert + if !errors.Is(err, New(code, reason, "")) { + t.Errorf("Expected error to be %v, but got %v", New(code, reason, ""), err) + } + if err.Message != "" { + t.Errorf("Expected error Message to be empty, but got %v", err.Message) + } +} + +type langContextKey struct{} + +func withLang(ctx context.Context, lang string) context.Context { + return context.WithValue(ctx, langContextKey{}, lang) +} + +func getLang(ctx context.Context) string { + lang, _ := ctx.Value(langContextKey{}).(string) + return lang +} + +// TestNewWithContext_GlobalI18nIsNotNil tests the NewWithContext function when globalI18n is not nil. +// It verifies that the returned Error object has a localized Message field. +func TestNewWithContext_GlobalI18nIsNotNil(t *testing.T) { + // Arrange + ctx := context.Background() + code := 404 + reason := "not_found" + data := map[string]string{"resource": "Kratos"} + lang := map[string]string{ + "en_CH": "{{ .resource }} is the best", + "zh_CN": "{{ .resource }}是最好的", + } + + // Mock the globalI18n + mockI18n := &MockI18n{ + localizeFunc: func(ctx context.Context, reason string, data any) string { + message := lang[getLang(ctx)] + s, err := textFormatter(message, data) + if err != nil { + return "" + } + return s + }, + } + RegisterI18nManager(mockI18n) + + // Act + err := NewWithContext(withLang(ctx, "en_CH"), code, reason, data) + // Assert + if !errors.Is(err, New(code, reason, "")) { + t.Errorf("Expected error to be %v, but got %v", New(code, reason, ""), err) + } + if err.Message != "Kratos is the best" { + t.Errorf("Expected error message to be %v, but got %v", "Kratos is the best", err.Message) + } + + err = NewWithContext(withLang(ctx, "zh_CN"), code, reason, data) + if !errors.Is(err, New(code, reason, "")) { + t.Errorf("Expected error to be %v, but got %v", New(code, reason, ""), err) + } + if err.Message != "Kratos是最好的" { + t.Errorf("Expected error message to be %v, but got %v", "Kratos是最好的", err.Message) + } +} diff --git a/errors/i18n.go b/errors/i18n.go new file mode 100644 index 00000000000..d31be70364d --- /dev/null +++ b/errors/i18n.go @@ -0,0 +1,23 @@ +package errors + +import ( + "context" + "sync" +) + +// I18nMessage An interface to internationalize error messages is defined +type I18nMessage interface { + // Localize Localization of error causes based on context and data + Localize(ctx context.Context, reason string, data any) string +} + +// The global i18n manager +var globalI18n I18nMessage +var globalI18nOnce sync.Once + +// RegisterI18nManager Register the global i18n manager +func RegisterI18nManager(i18n I18nMessage) { + globalI18nOnce.Do(func() { + globalI18n = i18n + }) +}