diff --git a/i18n.go b/i18n.go index 2ee77c1..7b526ca 100644 --- a/i18n.go +++ b/i18n.go @@ -3,6 +3,8 @@ package api import ( + "strings" + "github.com/gin-gonic/gin" "golang.org/x/text/language" ) @@ -13,6 +15,12 @@ const i18nContextKey = "i18n.locale" // i18nMessagesKey is the Gin context key for the message lookup map. const i18nMessagesKey = "i18n.messages" +// i18nCatalogKey is the Gin context key for the full locale->message catalog. +const i18nCatalogKey = "i18n.catalog" + +// i18nDefaultLocaleKey stores the configured default locale for fallback lookups. +const i18nDefaultLocaleKey = "i18n.default_locale" + // I18nConfig configures the internationalisation middleware. type I18nConfig struct { // DefaultLocale is the fallback locale when the Accept-Language header @@ -79,14 +87,13 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc { } c.Set(i18nContextKey, locale) + c.Set(i18nDefaultLocaleKey, cfg.DefaultLocale) // Attach the message map for this locale if messages are configured. if cfg.Messages != nil { + c.Set(i18nCatalogKey, cfg.Messages) if msgs, ok := cfg.Messages[locale]; ok { c.Set(i18nMessagesKey, msgs) - } else if msgs, ok := cfg.Messages[cfg.DefaultLocale]; ok { - // Fall back to default locale messages. - c.Set(i18nMessagesKey, msgs) } } @@ -116,5 +123,57 @@ func GetMessage(c *gin.Context, key string) (string, bool) { } } } + + catalog, _ := c.Get(i18nCatalogKey) + msgsByLocale, _ := catalog.(map[string]map[string]string) + if len(msgsByLocale) == 0 { + return "", false + } + + locales := localeFallbacks(GetLocale(c)) + if defaultLocale, ok := c.Get(i18nDefaultLocaleKey); ok { + if fallback, ok := defaultLocale.(string); ok && fallback != "" { + locales = append(locales, localeFallbacks(fallback)...) + } + } + + seen := make(map[string]struct{}, len(locales)) + for _, locale := range locales { + if locale == "" { + continue + } + if _, ok := seen[locale]; ok { + continue + } + seen[locale] = struct{}{} + if msgs, ok := msgsByLocale[locale]; ok { + if msg, ok := msgs[key]; ok { + return msg, true + } + } + } + return "", false } + +// localeFallbacks returns the locale and its parent tags in order from +// most specific to least specific. For example, "fr-CA" yields +// ["fr-CA", "fr"] and "zh-Hant-TW" yields ["zh-Hant-TW", "zh-Hant", "zh"]. +func localeFallbacks(locale string) []string { + locale = strings.TrimSpace(strings.ReplaceAll(locale, "_", "-")) + if locale == "" { + return nil + } + + parts := strings.Split(locale, "-") + if len(parts) == 0 { + return []string{locale} + } + + fallbacks := make([]string, 0, len(parts)) + for i := len(parts); i >= 1; i-- { + fallbacks = append(fallbacks, strings.Join(parts[:i], "-")) + } + + return fallbacks +} diff --git a/i18n_test.go b/i18n_test.go index 32ab926..5010c7c 100644 --- a/i18n_test.go +++ b/i18n_test.go @@ -251,3 +251,40 @@ func TestWithI18n_Good_LooksUpMessage(t *testing.T) { t.Fatalf("expected message=%q, got %q", "Hello", respEn.Data.Message) } } + +func TestWithI18n_Good_FallsBackToParentLocaleMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithI18n(api.I18nConfig{ + DefaultLocale: "en", + Supported: []string{"en", "fr", "fr-CA"}, + Messages: map[string]map[string]string{ + "en": {"greeting": "Hello"}, + "fr": {"greeting": "Bonjour"}, + }, + })) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/greeting", nil) + req.Header.Set("Accept-Language", "fr-CA") + 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.Locale != "fr-CA" { + t.Fatalf("expected locale=%q, got %q", "fr-CA", resp.Data.Locale) + } + if resp.Data.Message != "Bonjour" { + t.Fatalf("expected fallback message=%q, got %q", "Bonjour", resp.Data.Message) + } + if !resp.Data.Found { + t.Fatal("expected found=true") + } +}