feat(api): add i18n config snapshot

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 12:25:38 +00:00
parent f760ab6c72
commit 5de64a0a75
3 changed files with 132 additions and 1 deletions

1
api.go
View file

@ -63,6 +63,7 @@ type Engine struct {
expvarEnabled bool
ssePath string
graphql *graphqlConfig
i18nConfig I18nConfig
}
// New creates an Engine with the given options.

49
i18n.go
View file

@ -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
}

View file

@ -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)
}
}