feat: add WithI18n locale detection middleware
Lightweight Accept-Language header parsing using golang.org/x/text/language for RFC 5646 language matching with quality weighting. Provides GetLocale() and GetMessage() context helpers for handlers. Bridges to go-i18n grammar engine later — no heavy file-based i18n dependencies needed. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
a612d85dba
commit
36d77ea015
3 changed files with 348 additions and 1 deletions
2
go.mod
2
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
|
||||
|
|
|
|||
121
i18n.go
Normal file
121
i18n.go
Normal file
|
|
@ -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
|
||||
}
|
||||
226
i18n_test.go
Normal file
226
i18n_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue