diff --git a/go.mod b/go.mod index 1f62987..9e8f500 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 + golang.org/x/text v0.31.0 ) require ( @@ -69,7 +70,6 @@ require ( golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect golang.org/x/tools v0.38.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/i18n.go b/i18n.go new file mode 100644 index 0000000..a9b5974 --- /dev/null +++ b/i18n.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "github.com/gin-gonic/gin" + "golang.org/x/text/language" +) + +// i18nContextKey is the Gin context key for the detected locale string. +const i18nContextKey = "i18n.locale" + +// i18nMessagesKey is the Gin context key for the message lookup map. +const i18nMessagesKey = "i18n.messages" + +// I18nConfig configures the internationalisation middleware. +type I18nConfig struct { + // DefaultLocale is the fallback locale when the Accept-Language header + // is absent or does not match any supported locale. Defaults to "en". + DefaultLocale string + + // Supported lists the locale tags the application supports. + // Each entry should be a BCP 47 language tag (e.g. "en", "fr", "de"). + // If empty, only the default locale is supported. + Supported []string + + // Messages maps locale tags to key-value message pairs. + // For example: {"en": {"greeting": "Hello"}, "fr": {"greeting": "Bonjour"}} + // This is optional — handlers can use GetLocale() alone for custom logic. + Messages map[string]map[string]string +} + +// 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 +// context and can be retrieved by handlers via GetLocale(). +// +// If messages are configured, handlers can look up localised strings via +// GetMessage(). This is a lightweight bridge — the go-i18n grammar engine +// can replace the message map later. +func WithI18n(cfg ...I18nConfig) Option { + return func(e *Engine) { + var config I18nConfig + if len(cfg) > 0 { + config = cfg[0] + } + if config.DefaultLocale == "" { + config.DefaultLocale = "en" + } + + // Build the language.Matcher from supported locales. + tags := []language.Tag{language.Make(config.DefaultLocale)} + for _, s := range config.Supported { + tag := language.Make(s) + // Avoid duplicating the default if it also appears in Supported. + if tag != tags[0] { + tags = append(tags, tag) + } + } + matcher := language.NewMatcher(tags) + + e.middlewares = append(e.middlewares, i18nMiddleware(matcher, config)) + } +} + +// i18nMiddleware returns Gin middleware that parses Accept-Language, matches +// it against supported locales, and stores the result in the context. +func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc { + return func(c *gin.Context) { + accept := c.GetHeader("Accept-Language") + + var locale string + if accept == "" { + locale = cfg.DefaultLocale + } else { + tags, _, _ := language.ParseAcceptLanguage(accept) + tag, _, _ := matcher.Match(tags...) + base, _ := tag.Base() + locale = base.String() + } + + c.Set(i18nContextKey, locale) + + // Attach the message map for this locale if messages are configured. + if cfg.Messages != nil { + 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) + } + } + + c.Next() + } +} + +// GetLocale returns the detected locale for the current request. +// Returns "en" if the i18n middleware was not applied. +func GetLocale(c *gin.Context) string { + if v, ok := c.Get(i18nContextKey); ok { + if s, ok := v.(string); ok { + return s + } + } + return "en" +} + +// GetMessage looks up a localised message by key for the current request. +// Returns the message string and true if found, or empty string and false +// if the key does not exist or the i18n middleware was not applied. +func GetMessage(c *gin.Context, key string) (string, bool) { + if v, ok := c.Get(i18nMessagesKey); ok { + if msgs, ok := v.(map[string]string); ok { + if msg, ok := msgs[key]; ok { + return msg, true + } + } + } + return "", false +} diff --git a/i18n_test.go b/i18n_test.go new file mode 100644 index 0000000..a081dd1 --- /dev/null +++ b/i18n_test.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/go-api" +) + +// ── Helpers ───────────────────────────────────────────────────────────── + +// i18nTestGroup provides routes that expose locale detection results. +type i18nTestGroup struct{} + +func (i *i18nTestGroup) Name() string { return "i18n" } +func (i *i18nTestGroup) BasePath() string { return "/i18n" } +func (i *i18nTestGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/locale", func(c *gin.Context) { + locale := api.GetLocale(c) + c.JSON(http.StatusOK, api.OK(map[string]string{"locale": locale})) + }) + rg.GET("/greeting", func(c *gin.Context) { + msg, ok := api.GetMessage(c, "greeting") + c.JSON(http.StatusOK, api.OK(map[string]any{ + "locale": api.GetLocale(c), + "message": msg, + "found": ok, + })) + }) +} + +// i18nLocaleResponse is the typed response for locale detection tests. +type i18nLocaleResponse struct { + Success bool `json:"success"` + Data map[string]string `json:"data"` +} + +// i18nMessageResponse is the typed response for message lookup tests. +type i18nMessageResponse struct { + Success bool `json:"success"` + Data struct { + Locale string `json:"locale"` + Message string `json:"message"` + Found bool `json:"found"` + } `json:"data"` +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +func TestWithI18n_Good_DetectsLocaleFromHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithI18n(api.I18nConfig{ + Supported: []string{"en", "fr", "de"}, + })) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", 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 i18nLocaleResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["locale"] != "fr" { + t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"]) + } +} + +func TestWithI18n_Good_FallsBackToDefault(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithI18n(api.I18nConfig{ + DefaultLocale: "en", + Supported: []string{"en", "fr"}, + })) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil) + // No Accept-Language header. + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp i18nLocaleResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["locale"] != "en" { + t.Fatalf("expected locale=%q, got %q", "en", resp.Data["locale"]) + } +} + +func TestWithI18n_Good_QualityWeighting(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithI18n(api.I18nConfig{ + Supported: []string{"en", "fr", "de"}, + })) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil) + // French has higher quality weight than German. + req.Header.Set("Accept-Language", "de;q=0.5, fr;q=0.9") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp i18nLocaleResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["locale"] != "fr" { + t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"]) + } +} + +func TestWithI18n_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithI18n(api.I18nConfig{ + Supported: []string{"en", "fr"}, + }), + api.WithRequestID(), + ) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil) + req.Header.Set("Accept-Language", "fr") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // i18n middleware should detect French. + var resp i18nLocaleResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["locale"] != "fr" { + t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"]) + } + + // RequestID middleware should also have run. + if w.Header().Get("X-Request-ID") == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithI18n_Good_LooksUpMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + 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"}, + }, + })) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + + // Test French message lookup. + 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.Locale != "fr" { + t.Fatalf("expected locale=%q, got %q", "fr", resp.Data.Locale) + } + if resp.Data.Message != "Bonjour" { + t.Fatalf("expected message=%q, got %q", "Bonjour", resp.Data.Message) + } + if !resp.Data.Found { + t.Fatal("expected found=true") + } + + // Test English message lookup. + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/i18n/greeting", nil) + req.Header.Set("Accept-Language", "en") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var respEn i18nMessageResponse + if err := json.Unmarshal(w.Body.Bytes(), &respEn); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if respEn.Data.Message != "Hello" { + t.Fatalf("expected message=%q, got %q", "Hello", respEn.Data.Message) + } +}