diff --git a/api.go b/api.go index 5770fbe..58db596 100644 --- a/api.go +++ b/api.go @@ -63,6 +63,7 @@ type Engine struct { expvarEnabled bool ssePath string graphql *graphqlConfig + i18nConfig I18nConfig } // New creates an Engine with the given options. diff --git a/i18n.go b/i18n.go index 316a8c1..03ddb3b 100644 --- a/i18n.go +++ b/i18n.go @@ -3,6 +3,7 @@ package api import ( + "slices" "strings" "github.com/gin-gonic/gin" @@ -46,6 +47,23 @@ type I18nConfig struct { Messages map[string]map[string]string } +// I18nConfig returns the configured locale and message catalogue settings for +// the engine. +// +// The result snapshots the Engine state at call time and clones slices/maps so +// callers can safely reuse or modify the returned value. +// +// Example: +// +// cfg := engine.I18nConfig() +func (e *Engine) I18nConfig() I18nConfig { + if e == nil { + return I18nConfig{} + } + + return cloneI18nConfig(e.i18nConfig) +} + // WithI18n adds Accept-Language header parsing and locale detection middleware. // The middleware uses golang.org/x/text/language for RFC 5646 language matching // with quality weighting support. The detected locale is stored in the Gin @@ -77,9 +95,11 @@ func WithI18n(cfg ...I18nConfig) Option { tags = append(tags, tag) } } + snapshot := cloneI18nConfig(config) + e.i18nConfig = snapshot matcher := language.NewMatcher(tags) - e.middlewares = append(e.middlewares, i18nMiddleware(matcher, config)) + e.middlewares = append(e.middlewares, i18nMiddleware(matcher, snapshot)) } } @@ -197,3 +217,30 @@ func localeFallbacks(locale string) []string { return fallbacks } + +func cloneI18nConfig(cfg I18nConfig) I18nConfig { + out := cfg + out.Supported = slices.Clone(cfg.Supported) + out.Messages = cloneI18nMessages(cfg.Messages) + return out +} + +func cloneI18nMessages(messages map[string]map[string]string) map[string]map[string]string { + if len(messages) == 0 { + return nil + } + + out := make(map[string]map[string]string, len(messages)) + for locale, msgs := range messages { + if len(msgs) == 0 { + out[locale] = nil + continue + } + cloned := make(map[string]string, len(msgs)) + for key, value := range msgs { + cloned[key] = value + } + out[locale] = cloned + } + return out +} diff --git a/i18n_test.go b/i18n_test.go index 5010c7c..b56e5cf 100644 --- a/i18n_test.go +++ b/i18n_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "testing" "github.com/gin-gonic/gin" @@ -288,3 +289,85 @@ func TestWithI18n_Good_FallsBackToParentLocaleMessage(t *testing.T) { t.Fatal("expected found=true") } } + +func TestEngine_I18nConfig_Good_SnapshotsCurrentSettings(t *testing.T) { + e, _ := api.New(api.WithI18n(api.I18nConfig{ + DefaultLocale: "en", + Supported: []string{"en", "fr"}, + Messages: map[string]map[string]string{ + "en": {"greeting": "Hello"}, + "fr": {"greeting": "Bonjour"}, + }, + })) + + snap := e.I18nConfig() + if snap.DefaultLocale != "en" { + t.Fatalf("expected default locale en, got %q", snap.DefaultLocale) + } + if !slices.Equal(snap.Supported, []string{"en", "fr"}) { + t.Fatalf("expected supported locales [en fr], got %v", snap.Supported) + } + if snap.Messages["fr"]["greeting"] != "Bonjour" { + t.Fatalf("expected cloned French greeting, got %q", snap.Messages["fr"]["greeting"]) + } +} + +func TestEngine_I18nConfig_Good_ClonesMutableInputs(t *testing.T) { + supported := []string{"en", "fr"} + messages := map[string]map[string]string{ + "en": {"greeting": "Hello"}, + "fr": {"greeting": "Bonjour"}, + } + + e, _ := api.New(api.WithI18n(api.I18nConfig{ + DefaultLocale: "en", + Supported: supported, + Messages: messages, + })) + + supported[0] = "de" + messages["fr"]["greeting"] = "Salut" + + snap := e.I18nConfig() + if !slices.Equal(snap.Supported, []string{"en", "fr"}) { + t.Fatalf("expected engine supported locales to be cloned, got %v", snap.Supported) + } + if snap.Messages["fr"]["greeting"] != "Bonjour" { + t.Fatalf("expected engine message catalogue to be cloned, got %q", snap.Messages["fr"]["greeting"]) + } +} + +func TestWithI18n_Good_SnapshotsMutableInputs(t *testing.T) { + gin.SetMode(gin.TestMode) + messages := map[string]map[string]string{ + "en": {"greeting": "Hello"}, + "fr": {"greeting": "Bonjour"}, + } + + e, _ := api.New(api.WithI18n(api.I18nConfig{ + DefaultLocale: "en", + Supported: []string{"en", "fr"}, + Messages: messages, + })) + e.Register(&i18nTestGroup{}) + + messages["fr"]["greeting"] = "Salut" + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/greeting", nil) + req.Header.Set("Accept-Language", "fr") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp i18nMessageResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data.Message != "Bonjour" { + t.Fatalf("expected cloned greeting %q, got %q", "Bonjour", resp.Data.Message) + } +}