From e6dd1643cf268bb365284fe03caf4bc1506c164a Mon Sep 17 00:00:00 2001 From: Neur0toxine Date: Mon, 18 May 2020 13:37:24 +0300 Subject: [PATCH] [feature] Individual localizer for each context * proposal with context localizer (engine.GetLocalizedMessage shouldn't be used for localizing for individual requests data) * normal test for SetLocale * use sync.Map * method to preload languages into localizer * DefaultLanguages variable * PreloadLanguages field in core.Engine * don't copy sync.Map * patch for goroutine safety usage of Localizer * fix for engine constructor * additional test for localizer middleware, which is more close to real environment * filter language.Und * prevent root placeholder tag usage * remove reduntant bundle storage, deprecated (*core.Localizer).FetchLanguage() --- .gitignore | 2 +- README.md | 22 ++++- core/engine.go | 56 +++++++----- core/engine_test.go | 19 ++-- core/localizer.go | 198 +++++++++++++++++++++++++++++++++-------- core/localizer_test.go | 149 ++++++++++++++++++++++++++++--- 6 files changed, 365 insertions(+), 81 deletions(-) diff --git a/.gitignore b/.gitignore index 3c56ae0..28195a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ .idea -coverage.txt \ No newline at end of file +coverage.* diff --git a/README.md b/README.md index 84e9313..a049f11 100644 --- a/README.md +++ b/README.md @@ -20,22 +20,36 @@ import ( ) func main() { + // Create new core.Engine instance app := core.New() + + // Load configuration app.Config = core.NewConfig("config.yml") + + // Set default error translation key (will be returned if something goes wrong) app.DefaultError = "unknown_error" + + // Set translations path app.TranslationsPath = "./translations" + + // Preload some translations so they will not be loaded for every request + app.PreloadLanguages = core.DefaultLanguages + // Configure gin.Engine inside core.Engine app.ConfigureRouter(func(engine *gin.Engine) { engine.Static("/static", "./static") engine.HTMLRender = app.CreateRenderer( +] // Insert templates here. Custom functions also can be provided. + // Default transl function will be injected automatically func(renderer *core.Renderer) { - // insert templates here. Example: + // Push method will load template from FS or from binary r.Push("home", "templates/layout.html", "templates/home.html") }, template.FuncMap{}, ) }) + // Start application or fail if something gone wrong (e.g. port is already in use) if err := app.Prepare().Run(); err != nil { fmt.Printf("Fatal error: %s", err.Error()) os.Exit(1) @@ -68,14 +82,18 @@ func main() { app := core.New() app.Config = core.NewConfig("config.yml") app.DefaultError = "unknown_error" + + // Now translations will be loaded from packr.Box app.TranslationsBox = translations + app.PreloadLanguages = core.DefaultLanguages app.ConfigureRouter(func(engine *gin.Engine) { + // gin.Engine can use packr.Box as http.FileSystem engine.StaticFS("/static", static) engine.HTMLRender = app.CreateRendererFS( templates, func(renderer *core.Renderer) { - // insert templates here. Example: + // Same Push method here, but without relative directory. r.Push("home", "layout.html", "home.html") }, template.FuncMap{}, diff --git a/core/engine.go b/core/engine.go index c808bf1..e9f521a 100644 --- a/core/engine.go +++ b/core/engine.go @@ -10,6 +10,7 @@ import ( "github.com/gorilla/securecookie" "github.com/gorilla/sessions" "github.com/op/go-logging" + "golang.org/x/text/language" ) // Engine struct @@ -18,30 +19,35 @@ type Engine struct { ORM Sentry Utils - ginEngine *gin.Engine - httpClient *http.Client - logger LoggerInterface - mutex sync.RWMutex - csrf *CSRF - jobManager *JobManager - Sessions sessions.Store - Config ConfigInterface - LogFormatter logging.Formatter - prepared bool + ginEngine *gin.Engine + httpClient *http.Client + logger LoggerInterface + mutex sync.RWMutex + csrf *CSRF + jobManager *JobManager + PreloadLanguages []language.Tag + Sessions sessions.Store + Config ConfigInterface + LogFormatter logging.Formatter + prepared bool } // New Engine instance (must be configured manually, gin can be accessed via engine.Router() directly or engine.ConfigureRouter(...) with callback) func New() *Engine { return &Engine{ - Config: nil, - Localizer: Localizer{}, - ORM: ORM{}, - Sentry: Sentry{}, - Utils: Utils{}, - ginEngine: nil, - logger: nil, - mutex: sync.RWMutex{}, - prepared: false, + Config: nil, + Localizer: Localizer{ + i18nStorage: &sync.Map{}, + loadMutex: &sync.RWMutex{}, + }, + PreloadLanguages: []language.Tag{}, + ORM: ORM{}, + Sentry: Sentry{}, + Utils: Utils{}, + ginEngine: nil, + logger: nil, + mutex: sync.RWMutex{}, + prepared: false, } } @@ -76,14 +82,20 @@ func (e *Engine) Prepare() *Engine { if e.LogFormatter == nil { e.LogFormatter = DefaultLogFormatter() } - if e.LocaleBundle == nil { - e.LocaleBundle = DefaultLocalizerBundle() - } if e.LocaleMatcher == nil { e.LocaleMatcher = DefaultLocalizerMatcher() } + if e.isUnd(e.Localizer.LanguageTag) { + e.Localizer.LanguageTag = DefaultLanguage + } + e.LoadTranslations() + + if len(e.PreloadLanguages) > 0 { + e.Localizer.Preload(e.PreloadLanguages) + } + e.createDB(e.Config.GetDBConfig()) e.createRavenClient(e.Config.GetSentryDSN()) e.resetUtils(e.Config.GetAWSConfig(), e.Config.IsDebug(), 0) diff --git a/core/engine_test.go b/core/engine_test.go index 8024604..0d9425c 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -34,13 +34,7 @@ func (e *EngineTest) SetupTest() { db, _, err = sqlmock.New() require.NoError(e.T(), err) - if _, err := os.Stat(testTranslationsDir); err != nil && os.IsNotExist(err) { - err := os.Mkdir(testTranslationsDir, os.ModePerm) - require.Nil(e.T(), err) - data := []byte("message: Test message\nmessage_template: Test message with {{.data}}") - err = ioutil.WriteFile(testLangFile, data, os.ModePerm) - require.Nil(e.T(), err) - } + createTestLangFiles(e.T()) e.engine.Config = Config{ Version: "1", @@ -102,6 +96,17 @@ func (e *EngineTest) Test_Prepare() { e.engine.TranslationsPath = testTranslationsDir e.engine.Prepare() assert.True(e.T(), e.engine.prepared) + assert.NotNil(e.T(), e.engine.Config) + assert.NotEmpty(e.T(), e.engine.DefaultError) + assert.NotEmpty(e.T(), e.engine.LogFormatter) + assert.NotEmpty(e.T(), e.engine.LocaleMatcher) + assert.False(e.T(), e.engine.isUnd(e.engine.Localizer.LanguageTag)) + assert.NotNil(e.T(), e.engine.DB) + assert.NotNil(e.T(), e.engine.Client) + assert.NotNil(e.T(), e.engine.logger) + assert.NotNil(e.T(), e.engine.Sentry.Localizer) + assert.NotNil(e.T(), e.engine.Sentry.Logger) + assert.NotNil(e.T(), e.engine.Utils.Logger) } func (e *EngineTest) Test_initGin_Release() { diff --git a/core/localizer.go b/core/localizer.go index e06d891..5901c82 100644 --- a/core/localizer.go +++ b/core/localizer.go @@ -4,6 +4,7 @@ import ( "html/template" "io/ioutil" "path" + "sync" "github.com/gin-gonic/gin" "github.com/gobuffalo/packd" @@ -13,11 +14,24 @@ import ( "gopkg.in/yaml.v2" ) +// DefaultLanguages for transports +var DefaultLanguages = []language.Tag{ + language.English, + language.Russian, + language.Spanish, +} + +// DefaultLanguage is a base language which will be chosen if current language is unspecified +var DefaultLanguage = language.English + +// LocalizerContextKey is a key which is used to store localizer in gin.Context key-value storage +const LocalizerContextKey = "localizer" + // Localizer struct type Localizer struct { - i18n *i18n.Localizer + i18nStorage *sync.Map TranslationsBox *packr.Box - LocaleBundle *i18n.Bundle + loadMutex *sync.RWMutex LocaleMatcher language.Matcher LanguageTag language.Tag TranslationsPath string @@ -25,13 +39,13 @@ type Localizer struct { // NewLocalizer returns localizer instance with specified parameters. // Usage: -// NewLocalizer(language.English, DefaultLocalizerBundle(), DefaultLocalizerMatcher(), "translations") -func NewLocalizer(locale language.Tag, bundle *i18n.Bundle, matcher language.Matcher, translationsPath string) *Localizer { +// NewLocalizer(language.English, DefaultLocalizerMatcher(), "translations") +func NewLocalizer(locale language.Tag, matcher language.Matcher, translationsPath string) *Localizer { localizer := &Localizer{ - i18n: nil, - LocaleBundle: bundle, + i18nStorage: &sync.Map{}, LocaleMatcher: matcher, TranslationsPath: translationsPath, + loadMutex: &sync.RWMutex{}, } localizer.SetLanguage(locale) localizer.LoadTranslations() @@ -41,14 +55,14 @@ func NewLocalizer(locale language.Tag, bundle *i18n.Bundle, matcher language.Mat // NewLocalizerFS returns localizer instance with specified parameters. *packr.Box should be used instead of directory. // Usage: -// NewLocalizerFS(language.English, DefaultLocalizerBundle(), DefaultLocalizerMatcher(), translationsBox) +// NewLocalizerFS(language.English, DefaultLocalizerMatcher(), translationsBox) // TODO This code should be covered with tests. -func NewLocalizerFS(locale language.Tag, bundle *i18n.Bundle, matcher language.Matcher, translationsBox *packr.Box) *Localizer { +func NewLocalizerFS(locale language.Tag, matcher language.Matcher, translationsBox *packr.Box) *Localizer { localizer := &Localizer{ - i18n: nil, - LocaleBundle: bundle, + i18nStorage: &sync.Map{}, LocaleMatcher: matcher, TranslationsBox: translationsBox, + loadMutex: &sync.RWMutex{}, } localizer.SetLanguage(locale) localizer.LoadTranslations() @@ -58,26 +72,40 @@ func NewLocalizerFS(locale language.Tag, bundle *i18n.Bundle, matcher language.M // DefaultLocalizerBundle returns new localizer bundle with English as default language func DefaultLocalizerBundle() *i18n.Bundle { - return i18n.NewBundle(language.English) + return i18n.NewBundle(DefaultLanguage) +} + +// LocalizerBundle returns new localizer bundle provided language as default +func LocalizerBundle(tag language.Tag) *i18n.Bundle { + return i18n.NewBundle(tag) } // DefaultLocalizerMatcher returns matcher with English, Russian and Spanish tags func DefaultLocalizerMatcher() language.Matcher { - return language.NewMatcher([]language.Tag{ - language.English, - language.Russian, - language.Spanish, - }) + return language.NewMatcher(DefaultLanguages) } // LocalizationMiddleware returns gin.HandlerFunc which will set localizer language by Accept-Language header +// Result Localizer instance will share it's internal data (translations, bundles, etc) with instance which was used +// to append middleware to gin. +// Because of that all Localizer instances from this middleware will share *same* mutex. This mutex is used to wrap +// i18n.Bundle methods (those aren't goroutine-safe to use). // Usage: // engine := gin.New() // localizer := NewLocalizer("en", DefaultLocalizerBundle(), DefaultLocalizerMatcher(), "translations") // engine.Use(localizer.LocalizationMiddleware()) func (l *Localizer) LocalizationMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - l.SetLocale(c.GetHeader("Accept-Language")) + clone := &Localizer{ + i18nStorage: l.i18nStorage, + TranslationsBox: l.TranslationsBox, + LocaleMatcher: l.LocaleMatcher, + LanguageTag: l.LanguageTag, + TranslationsPath: l.TranslationsPath, + loadMutex: l.loadMutex, + } + clone.SetLocale(c.GetHeader("Accept-Language")) + c.Set(LocalizerContextKey, clone) } } @@ -99,24 +127,36 @@ func (l *Localizer) LocalizationFuncMap() template.FuncMap { } } +// getLocaleBundle returns current locale bundle and creates it if needed func (l *Localizer) getLocaleBundle() *i18n.Bundle { - if l.LocaleBundle == nil { - l.LocaleBundle = DefaultLocalizerBundle() - } - return l.LocaleBundle + return l.createLocaleBundleByTag(l.LanguageTag) +} + +// createLocaleBundleByTag creates locale bundle by language tag +func (l *Localizer) createLocaleBundleByTag(tag language.Tag) *i18n.Bundle { + bundle := i18n.NewBundle(tag) + bundle.RegisterUnmarshalFunc("yml", yaml.Unmarshal) + l.loadTranslationsToBundle(bundle) + + return bundle } // LoadTranslations will load all translation files from translations directory or from embedded box func (l *Localizer) LoadTranslations() { - l.getLocaleBundle().RegisterUnmarshalFunc("yml", yaml.Unmarshal) + defer l.loadMutex.Unlock() + l.loadMutex.Lock() + l.getCurrentLocalizer() +} +// loadTranslationsToBundle loads translations to provided bundle +func (l *Localizer) loadTranslationsToBundle(i18nBundle *i18n.Bundle) { switch { case l.TranslationsPath != "": - if err := l.loadFromDirectory(); err != nil { + if err := l.loadFromDirectory(i18nBundle); err != nil { panic(err.Error()) } case l.TranslationsBox != nil: - if err := l.loadFromFS(); err != nil { + if err := l.loadFromFS(i18nBundle); err != nil { panic(err.Error()) } default: @@ -125,7 +165,7 @@ func (l *Localizer) LoadTranslations() { } // LoadTranslations will load all translation files from translations directory -func (l *Localizer) loadFromDirectory() error { +func (l *Localizer) loadFromDirectory(i18nBundle *i18n.Bundle) error { files, err := ioutil.ReadDir(l.TranslationsPath) if err != nil { return err @@ -133,7 +173,7 @@ func (l *Localizer) loadFromDirectory() error { for _, f := range files { if !f.IsDir() { - l.getLocaleBundle().MustLoadMessageFile(path.Join(l.TranslationsPath, f.Name())) + i18nBundle.MustLoadMessageFile(path.Join(l.TranslationsPath, f.Name())) } } @@ -141,12 +181,12 @@ func (l *Localizer) loadFromDirectory() error { } // LoadTranslations will load all translation files from embedded box -func (l *Localizer) loadFromFS() error { +func (l *Localizer) loadFromFS(i18nBundle *i18n.Bundle) error { err := l.TranslationsBox.Walk(func(s string, file packd.File) error { if fileInfo, err := file.FileInfo(); err == nil { if !fileInfo.IsDir() { if data, err := ioutil.ReadAll(file); err == nil { - if _, err := l.getLocaleBundle().ParseMessageFileBytes(data, fileInfo.Name()); err != nil { + if _, err := i18nBundle.ParseMessageFileBytes(data, fileInfo.Name()); err != nil { return err } } else { @@ -167,32 +207,93 @@ func (l *Localizer) loadFromFS() error { return nil } +// getLocalizer returns *i18n.Localizer with provided language tag. It will be created if not exist +func (l *Localizer) getLocalizer(tag language.Tag) *i18n.Localizer { + var localizer *i18n.Localizer + + if l.isUnd(tag) { + tag = DefaultLanguage + } + + if item, ok := l.i18nStorage.Load(tag); !ok { + l.i18nStorage.Store(tag, i18n.NewLocalizer(l.createLocaleBundleByTag(tag), tag.String())) + } else { + localizer = item.(*i18n.Localizer) + } + + return localizer +} + +func (l *Localizer) matchByString(al string) language.Tag { + tag, _ := language.MatchStrings(l.LocaleMatcher, al) + if l.isUnd(tag) { + return DefaultLanguage + } + + return tag +} + +func (l *Localizer) isUnd(tag language.Tag) bool { + return tag == language.Und || tag.IsRoot() +} + +// getCurrentLocalizer returns *i18n.Localizer with current language tag +func (l *Localizer) getCurrentLocalizer() *i18n.Localizer { + return l.getLocalizer(l.LanguageTag) +} + // SetLocale will change language for current localizer func (l *Localizer) SetLocale(al string) { - tag, _ := language.MatchStrings(l.LocaleMatcher, al) - l.SetLanguage(tag) + l.SetLanguage(l.matchByString(al)) +} + +// Preload provided languages (so they will not be loaded every time in middleware) +func (l *Localizer) Preload(tags []language.Tag) { + for _, tag := range tags { + l.getLocalizer(tag) + } } // SetLanguage will change language using language tag func (l *Localizer) SetLanguage(tag language.Tag) { + if l.isUnd(tag) { + tag = DefaultLanguage + } + l.LanguageTag = tag - l.FetchLanguage() + l.LoadTranslations() } // FetchLanguage will load language from tag +// +// Deprecated: Use `(*core.Localizer).LoadTranslations()` instead func (l *Localizer) FetchLanguage() { - l.i18n = i18n.NewLocalizer(l.getLocaleBundle(), l.LanguageTag.String()) + l.LoadTranslations() } -// GetLocalizedMessage will return localized message by it's ID +// GetLocalizedMessage will return localized message by it's ID. It doesn't use `Must` prefix in order to keep BC. func (l *Localizer) GetLocalizedMessage(messageID string) string { - return l.i18n.MustLocalize(&i18n.LocalizeConfig{MessageID: messageID}) + return l.getCurrentLocalizer().MustLocalize(&i18n.LocalizeConfig{MessageID: messageID}) } -// GetLocalizedTemplateMessage will return localized message with specified data +// GetLocalizedTemplateMessage will return localized message with specified data. It doesn't use `Must` prefix in order to keep BC. // It uses text/template syntax: https://golang.org/pkg/text/template/ func (l *Localizer) GetLocalizedTemplateMessage(messageID string, templateData map[string]interface{}) string { - return l.i18n.MustLocalize(&i18n.LocalizeConfig{ + return l.getCurrentLocalizer().MustLocalize(&i18n.LocalizeConfig{ + MessageID: messageID, + TemplateData: templateData, + }) +} + +// Localize will return localized message by it's ID, or error if message wasn't found +func (l *Localizer) Localize(messageID string) (string, error) { + return l.getCurrentLocalizer().Localize(&i18n.LocalizeConfig{MessageID: messageID}) +} + +// LocalizedTemplateMessage will return localized message with specified data, or error if message wasn't found +// It uses text/template syntax: https://golang.org/pkg/text/template/ +func (l *Localizer) LocalizeTemplateMessage(messageID string, templateData map[string]interface{}) (string, error) { + return l.getCurrentLocalizer().Localize(&i18n.LocalizeConfig{ MessageID: messageID, TemplateData: templateData, }) @@ -202,3 +303,28 @@ func (l *Localizer) GetLocalizedTemplateMessage(messageID string, templateData m func (l *Localizer) BadRequestLocalized(err string) (int, interface{}) { return BadRequest(l.GetLocalizedMessage(err)) } + +// GetContextLocalizer returns localizer from context if it exist there +func GetContextLocalizer(c *gin.Context) (*Localizer, bool) { + if c == nil { + return nil, false + } + + if item, ok := c.Get(LocalizerContextKey); ok { + if localizer, ok := item.(*Localizer); ok { + return localizer, true + } else { + return nil, false + } + } else { + return nil, false + } +} + +// MustGetContextLocalizer returns Localizer instance if it exists in provided context. Panics otherwise. +func MustGetContextLocalizer(c *gin.Context) *Localizer { + if localizer, ok := GetContextLocalizer(c); ok { + return localizer + } + panic("localizer is not present in provided context") +} diff --git a/core/localizer_test.go b/core/localizer_test.go index b90151b..6ffba21 100644 --- a/core/localizer_test.go +++ b/core/localizer_test.go @@ -2,11 +2,17 @@ package core import ( "io/ioutil" + "math/rand" "net/http" + "net/http/httptest" + "net/url" "os" "path" + "sync" "testing" + "time" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -15,24 +21,37 @@ import ( var ( testTranslationsDir = path.Join(os.TempDir(), "translations_test_dir") - testLangFile = path.Join(testTranslationsDir, "translate.en.yml") + testLangFiles = map[string][]byte{ + "translate.en.yml": []byte("message: Test message\nmessage_template: Test message with {{.data}}"), + "translate.es.yml": []byte("message: Mensaje de prueba\nmessage_template: Mensaje de prueba con {{.data}}"), + "translate.ru.yml": []byte("message: Тестовое сообщение\nmessage_template: Тестовое сообщение с {{.data}}"), + } ) +func createTestLangFiles(t *testing.T) { + for name, data := range testLangFiles { + fileName := path.Join(testTranslationsDir, name) + + if _, err := os.Stat(testTranslationsDir); err != nil && os.IsNotExist(err) { + err := os.Mkdir(testTranslationsDir, os.ModePerm) + require.Nil(t, err) + } + + if _, err := os.Stat(fileName); err != nil && os.IsNotExist(err) { + err = ioutil.WriteFile(fileName, data, os.ModePerm) + require.Nil(t, err) + } + } +} + type LocalizerTest struct { suite.Suite localizer *Localizer } func (l *LocalizerTest) SetupSuite() { - if _, err := os.Stat(testTranslationsDir); err != nil && os.IsNotExist(err) { - err := os.Mkdir(testTranslationsDir, os.ModePerm) - require.Nil(l.T(), err) - data := []byte("message: Test message\nmessage_template: Test message with {{.data}}") - err = ioutil.WriteFile(testLangFile, data, os.ModePerm) - require.Nil(l.T(), err) - } - - l.localizer = NewLocalizer(language.English, DefaultLocalizerBundle(), DefaultLocalizerMatcher(), testTranslationsDir) + createTestLangFiles(l.T()) + l.localizer = NewLocalizer(language.English, DefaultLocalizerMatcher(), testTranslationsDir) } func (l *LocalizerTest) Test_SetLocale() { @@ -40,11 +59,87 @@ func (l *LocalizerTest) Test_SetLocale() { require.Nil(l.T(), recover()) }() + l.localizer.SetLocale("es") + assert.Equal(l.T(), "Mensaje de prueba", l.localizer.GetLocalizedMessage("message")) l.localizer.SetLocale("en") + assert.Equal(l.T(), "Test message", l.localizer.GetLocalizedMessage("message")) } -func (l *LocalizerTest) Test_LocalizationMiddleware() { - assert.NotNil(l.T(), l.localizer.LocalizationMiddleware()) +func (l *LocalizerTest) Test_LocalizationMiddleware_Context() { + l.localizer.Preload(DefaultLanguages) + middlewareFunc := l.localizer.LocalizationMiddleware() + require.NotNil(l.T(), middlewareFunc) + + enContext := l.getContextWithLang(language.English) + esContext := l.getContextWithLang(language.Spanish) + ruContext := l.getContextWithLang(language.Russian) + + middlewareFunc(enContext) + middlewareFunc(esContext) + middlewareFunc(ruContext) + + defer func() { + assert.Nil(l.T(), recover()) + }() + + enLocalizer := MustGetContextLocalizer(enContext) + esLocalizer := MustGetContextLocalizer(esContext) + ruLocalizer := MustGetContextLocalizer(ruContext) + + assert.NotNil(l.T(), enLocalizer) + assert.NotNil(l.T(), esLocalizer) + assert.NotNil(l.T(), ruLocalizer) + + assert.Equal(l.T(), language.English, enLocalizer.LanguageTag) + assert.Equal(l.T(), language.Spanish, esLocalizer.LanguageTag) + assert.Equal(l.T(), language.Russian, ruLocalizer.LanguageTag) + + assert.Equal(l.T(), "Test message", enLocalizer.GetLocalizedMessage("message")) + assert.Equal(l.T(), "Mensaje de prueba", esLocalizer.GetLocalizedMessage("message")) + assert.Equal(l.T(), "Тестовое сообщение", ruLocalizer.GetLocalizedMessage("message")) +} + +func (l *LocalizerTest) Test_LocalizationMiddleware_Httptest() { + var wg sync.WaitGroup + rand.Seed(time.Now().UnixNano()) + l.localizer.Preload(DefaultLanguages) + langMsgMap := map[language.Tag]string{ + language.English: "Test message", + language.Russian: "Тестовое сообщение", + language.Spanish: "Mensaje de prueba", + } + + fw := gin.New() + fw.Use(l.localizer.LocalizationMiddleware()) + fw.GET("/test", func(c *gin.Context) { + loc := MustGetContextLocalizer(c) + c.String(http.StatusOK, loc.GetLocalizedMessage("message")) + }) + + for i := 0; i < 1000; i++ { + wg.Add(1) + go func(m map[language.Tag]string, wg *sync.WaitGroup) { + var tag language.Tag + switch rand.Intn(3-1) + 1 { + case 1: + tag = language.English + case 2: + tag = language.Russian + case 3: + tag = language.Spanish + } + + req, err := http.NewRequest(http.MethodGet, "/test", nil) + require.NoError(l.T(), err) + req.Header.Add("Accept-Language", tag.String()) + rr := httptest.NewRecorder() + fw.ServeHTTP(rr, req) + assert.Equal(l.T(), m[tag], rr.Body.String()) + wg.Done() + }(langMsgMap, &wg) + } + + wg.Wait() } func (l *LocalizerTest) Test_LocalizationFuncMap() { @@ -78,6 +173,23 @@ func (l *LocalizerTest) Test_BadRequestLocalized() { assert.Equal(l.T(), "Test message", resp.(ErrorResponse).Error) } +// getContextWithLang generates context with Accept-Language header +func (l *LocalizerTest) getContextWithLang(tag language.Tag) *gin.Context { + urlInstance, _ := url.Parse("https://example.com") + headers := http.Header{} + headers.Add("Accept-Language", tag.String()) + return &gin.Context{ + Request: &http.Request{ + Method: "GET", + URL: urlInstance, + Proto: "https", + Header: headers, + Host: "example.com", + }, + Keys: map[string]interface{}{}, + } +} + func (l *LocalizerTest) TearDownSuite() { err := os.RemoveAll(testTranslationsDir) require.Nil(l.T(), err) @@ -87,6 +199,18 @@ func TestLocalizer_Suite(t *testing.T) { suite.Run(t, new(LocalizerTest)) } +func TestLocalizer_DefaultLocalizerMatcher(t *testing.T) { + assert.NotNil(t, DefaultLocalizerMatcher()) +} + +func TestLocalizer_DefaultLocalizerBundle(t *testing.T) { + assert.NotNil(t, DefaultLocalizerBundle()) +} + +func TestLocalizer_LocalizerBundle(t *testing.T) { + assert.NotNil(t, LocalizerBundle(language.Russian)) +} + func TestLocalizer_NoDirectory(t *testing.T) { defer func() { assert.NotNil(t, recover()) @@ -94,7 +218,6 @@ func TestLocalizer_NoDirectory(t *testing.T) { _ = NewLocalizer( language.English, - DefaultLocalizerBundle(), DefaultLocalizerMatcher(), path.Join(os.TempDir(), "this directory should not exist"), )