[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()
This commit is contained in:
Pavel 2020-05-18 13:37:24 +03:00 committed by GitHub
parent 6a6726377d
commit e6dd1643cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 365 additions and 81 deletions

2
.gitignore vendored
View File

@ -1,2 +1,2 @@
.idea .idea
coverage.txt coverage.*

View File

@ -20,22 +20,36 @@ import (
) )
func main() { func main() {
// Create new core.Engine instance
app := core.New() app := core.New()
// Load configuration
app.Config = core.NewConfig("config.yml") app.Config = core.NewConfig("config.yml")
// Set default error translation key (will be returned if something goes wrong)
app.DefaultError = "unknown_error" app.DefaultError = "unknown_error"
// Set translations path
app.TranslationsPath = "./translations" 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) { app.ConfigureRouter(func(engine *gin.Engine) {
engine.Static("/static", "./static") engine.Static("/static", "./static")
engine.HTMLRender = app.CreateRenderer( engine.HTMLRender = app.CreateRenderer(
] // Insert templates here. Custom functions also can be provided.
// Default transl function will be injected automatically
func(renderer *core.Renderer) { 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") r.Push("home", "templates/layout.html", "templates/home.html")
}, },
template.FuncMap{}, template.FuncMap{},
) )
}) })
// Start application or fail if something gone wrong (e.g. port is already in use)
if err := app.Prepare().Run(); err != nil { if err := app.Prepare().Run(); err != nil {
fmt.Printf("Fatal error: %s", err.Error()) fmt.Printf("Fatal error: %s", err.Error())
os.Exit(1) os.Exit(1)
@ -68,14 +82,18 @@ func main() {
app := core.New() app := core.New()
app.Config = core.NewConfig("config.yml") app.Config = core.NewConfig("config.yml")
app.DefaultError = "unknown_error" app.DefaultError = "unknown_error"
// Now translations will be loaded from packr.Box
app.TranslationsBox = translations app.TranslationsBox = translations
app.PreloadLanguages = core.DefaultLanguages
app.ConfigureRouter(func(engine *gin.Engine) { app.ConfigureRouter(func(engine *gin.Engine) {
// gin.Engine can use packr.Box as http.FileSystem
engine.StaticFS("/static", static) engine.StaticFS("/static", static)
engine.HTMLRender = app.CreateRendererFS( engine.HTMLRender = app.CreateRendererFS(
templates, templates,
func(renderer *core.Renderer) { func(renderer *core.Renderer) {
// insert templates here. Example: // Same Push method here, but without relative directory.
r.Push("home", "layout.html", "home.html") r.Push("home", "layout.html", "home.html")
}, },
template.FuncMap{}, template.FuncMap{},

View File

@ -10,6 +10,7 @@ import (
"github.com/gorilla/securecookie" "github.com/gorilla/securecookie"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/op/go-logging" "github.com/op/go-logging"
"golang.org/x/text/language"
) )
// Engine struct // Engine struct
@ -18,30 +19,35 @@ type Engine struct {
ORM ORM
Sentry Sentry
Utils Utils
ginEngine *gin.Engine ginEngine *gin.Engine
httpClient *http.Client httpClient *http.Client
logger LoggerInterface logger LoggerInterface
mutex sync.RWMutex mutex sync.RWMutex
csrf *CSRF csrf *CSRF
jobManager *JobManager jobManager *JobManager
Sessions sessions.Store PreloadLanguages []language.Tag
Config ConfigInterface Sessions sessions.Store
LogFormatter logging.Formatter Config ConfigInterface
prepared bool 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) // New Engine instance (must be configured manually, gin can be accessed via engine.Router() directly or engine.ConfigureRouter(...) with callback)
func New() *Engine { func New() *Engine {
return &Engine{ return &Engine{
Config: nil, Config: nil,
Localizer: Localizer{}, Localizer: Localizer{
ORM: ORM{}, i18nStorage: &sync.Map{},
Sentry: Sentry{}, loadMutex: &sync.RWMutex{},
Utils: Utils{}, },
ginEngine: nil, PreloadLanguages: []language.Tag{},
logger: nil, ORM: ORM{},
mutex: sync.RWMutex{}, Sentry: Sentry{},
prepared: false, Utils: Utils{},
ginEngine: nil,
logger: nil,
mutex: sync.RWMutex{},
prepared: false,
} }
} }
@ -76,14 +82,20 @@ func (e *Engine) Prepare() *Engine {
if e.LogFormatter == nil { if e.LogFormatter == nil {
e.LogFormatter = DefaultLogFormatter() e.LogFormatter = DefaultLogFormatter()
} }
if e.LocaleBundle == nil {
e.LocaleBundle = DefaultLocalizerBundle()
}
if e.LocaleMatcher == nil { if e.LocaleMatcher == nil {
e.LocaleMatcher = DefaultLocalizerMatcher() e.LocaleMatcher = DefaultLocalizerMatcher()
} }
if e.isUnd(e.Localizer.LanguageTag) {
e.Localizer.LanguageTag = DefaultLanguage
}
e.LoadTranslations() e.LoadTranslations()
if len(e.PreloadLanguages) > 0 {
e.Localizer.Preload(e.PreloadLanguages)
}
e.createDB(e.Config.GetDBConfig()) e.createDB(e.Config.GetDBConfig())
e.createRavenClient(e.Config.GetSentryDSN()) e.createRavenClient(e.Config.GetSentryDSN())
e.resetUtils(e.Config.GetAWSConfig(), e.Config.IsDebug(), 0) e.resetUtils(e.Config.GetAWSConfig(), e.Config.IsDebug(), 0)

View File

@ -34,13 +34,7 @@ func (e *EngineTest) SetupTest() {
db, _, err = sqlmock.New() db, _, err = sqlmock.New()
require.NoError(e.T(), err) require.NoError(e.T(), err)
if _, err := os.Stat(testTranslationsDir); err != nil && os.IsNotExist(err) { createTestLangFiles(e.T())
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)
}
e.engine.Config = Config{ e.engine.Config = Config{
Version: "1", Version: "1",
@ -102,6 +96,17 @@ func (e *EngineTest) Test_Prepare() {
e.engine.TranslationsPath = testTranslationsDir e.engine.TranslationsPath = testTranslationsDir
e.engine.Prepare() e.engine.Prepare()
assert.True(e.T(), e.engine.prepared) 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() { func (e *EngineTest) Test_initGin_Release() {

View File

@ -4,6 +4,7 @@ import (
"html/template" "html/template"
"io/ioutil" "io/ioutil"
"path" "path"
"sync"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gobuffalo/packd" "github.com/gobuffalo/packd"
@ -13,11 +14,24 @@ import (
"gopkg.in/yaml.v2" "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 // Localizer struct
type Localizer struct { type Localizer struct {
i18n *i18n.Localizer i18nStorage *sync.Map
TranslationsBox *packr.Box TranslationsBox *packr.Box
LocaleBundle *i18n.Bundle loadMutex *sync.RWMutex
LocaleMatcher language.Matcher LocaleMatcher language.Matcher
LanguageTag language.Tag LanguageTag language.Tag
TranslationsPath string TranslationsPath string
@ -25,13 +39,13 @@ type Localizer struct {
// NewLocalizer returns localizer instance with specified parameters. // NewLocalizer returns localizer instance with specified parameters.
// Usage: // Usage:
// NewLocalizer(language.English, DefaultLocalizerBundle(), DefaultLocalizerMatcher(), "translations") // NewLocalizer(language.English, DefaultLocalizerMatcher(), "translations")
func NewLocalizer(locale language.Tag, bundle *i18n.Bundle, matcher language.Matcher, translationsPath string) *Localizer { func NewLocalizer(locale language.Tag, matcher language.Matcher, translationsPath string) *Localizer {
localizer := &Localizer{ localizer := &Localizer{
i18n: nil, i18nStorage: &sync.Map{},
LocaleBundle: bundle,
LocaleMatcher: matcher, LocaleMatcher: matcher,
TranslationsPath: translationsPath, TranslationsPath: translationsPath,
loadMutex: &sync.RWMutex{},
} }
localizer.SetLanguage(locale) localizer.SetLanguage(locale)
localizer.LoadTranslations() 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. // NewLocalizerFS returns localizer instance with specified parameters. *packr.Box should be used instead of directory.
// Usage: // Usage:
// NewLocalizerFS(language.English, DefaultLocalizerBundle(), DefaultLocalizerMatcher(), translationsBox) // NewLocalizerFS(language.English, DefaultLocalizerMatcher(), translationsBox)
// TODO This code should be covered with tests. // 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{ localizer := &Localizer{
i18n: nil, i18nStorage: &sync.Map{},
LocaleBundle: bundle,
LocaleMatcher: matcher, LocaleMatcher: matcher,
TranslationsBox: translationsBox, TranslationsBox: translationsBox,
loadMutex: &sync.RWMutex{},
} }
localizer.SetLanguage(locale) localizer.SetLanguage(locale)
localizer.LoadTranslations() 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 // DefaultLocalizerBundle returns new localizer bundle with English as default language
func DefaultLocalizerBundle() *i18n.Bundle { 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 // DefaultLocalizerMatcher returns matcher with English, Russian and Spanish tags
func DefaultLocalizerMatcher() language.Matcher { func DefaultLocalizerMatcher() language.Matcher {
return language.NewMatcher([]language.Tag{ return language.NewMatcher(DefaultLanguages)
language.English,
language.Russian,
language.Spanish,
})
} }
// LocalizationMiddleware returns gin.HandlerFunc which will set localizer language by Accept-Language header // 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: // Usage:
// engine := gin.New() // engine := gin.New()
// localizer := NewLocalizer("en", DefaultLocalizerBundle(), DefaultLocalizerMatcher(), "translations") // localizer := NewLocalizer("en", DefaultLocalizerBundle(), DefaultLocalizerMatcher(), "translations")
// engine.Use(localizer.LocalizationMiddleware()) // engine.Use(localizer.LocalizationMiddleware())
func (l *Localizer) LocalizationMiddleware() gin.HandlerFunc { func (l *Localizer) LocalizationMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { 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 { func (l *Localizer) getLocaleBundle() *i18n.Bundle {
if l.LocaleBundle == nil { return l.createLocaleBundleByTag(l.LanguageTag)
l.LocaleBundle = DefaultLocalizerBundle() }
}
return l.LocaleBundle // 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 // LoadTranslations will load all translation files from translations directory or from embedded box
func (l *Localizer) LoadTranslations() { 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 { switch {
case l.TranslationsPath != "": case l.TranslationsPath != "":
if err := l.loadFromDirectory(); err != nil { if err := l.loadFromDirectory(i18nBundle); err != nil {
panic(err.Error()) panic(err.Error())
} }
case l.TranslationsBox != nil: case l.TranslationsBox != nil:
if err := l.loadFromFS(); err != nil { if err := l.loadFromFS(i18nBundle); err != nil {
panic(err.Error()) panic(err.Error())
} }
default: default:
@ -125,7 +165,7 @@ func (l *Localizer) LoadTranslations() {
} }
// LoadTranslations will load all translation files from translations directory // 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) files, err := ioutil.ReadDir(l.TranslationsPath)
if err != nil { if err != nil {
return err return err
@ -133,7 +173,7 @@ func (l *Localizer) loadFromDirectory() error {
for _, f := range files { for _, f := range files {
if !f.IsDir() { 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 // 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 { err := l.TranslationsBox.Walk(func(s string, file packd.File) error {
if fileInfo, err := file.FileInfo(); err == nil { if fileInfo, err := file.FileInfo(); err == nil {
if !fileInfo.IsDir() { if !fileInfo.IsDir() {
if data, err := ioutil.ReadAll(file); err == nil { 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 return err
} }
} else { } else {
@ -167,32 +207,93 @@ func (l *Localizer) loadFromFS() error {
return nil 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 // SetLocale will change language for current localizer
func (l *Localizer) SetLocale(al string) { func (l *Localizer) SetLocale(al string) {
tag, _ := language.MatchStrings(l.LocaleMatcher, al) l.SetLanguage(l.matchByString(al))
l.SetLanguage(tag) }
// 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 // SetLanguage will change language using language tag
func (l *Localizer) SetLanguage(tag language.Tag) { func (l *Localizer) SetLanguage(tag language.Tag) {
if l.isUnd(tag) {
tag = DefaultLanguage
}
l.LanguageTag = tag l.LanguageTag = tag
l.FetchLanguage() l.LoadTranslations()
} }
// FetchLanguage will load language from tag // FetchLanguage will load language from tag
//
// Deprecated: Use `(*core.Localizer).LoadTranslations()` instead
func (l *Localizer) FetchLanguage() { 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 { 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/ // It uses text/template syntax: https://golang.org/pkg/text/template/
func (l *Localizer) GetLocalizedTemplateMessage(messageID string, templateData map[string]interface{}) string { 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, MessageID: messageID,
TemplateData: templateData, TemplateData: templateData,
}) })
@ -202,3 +303,28 @@ func (l *Localizer) GetLocalizedTemplateMessage(messageID string, templateData m
func (l *Localizer) BadRequestLocalized(err string) (int, interface{}) { func (l *Localizer) BadRequestLocalized(err string) (int, interface{}) {
return BadRequest(l.GetLocalizedMessage(err)) 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")
}

View File

@ -2,11 +2,17 @@ package core
import ( import (
"io/ioutil" "io/ioutil"
"math/rand"
"net/http" "net/http"
"net/http/httptest"
"net/url"
"os" "os"
"path" "path"
"sync"
"testing" "testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
@ -15,24 +21,37 @@ import (
var ( var (
testTranslationsDir = path.Join(os.TempDir(), "translations_test_dir") 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 { type LocalizerTest struct {
suite.Suite suite.Suite
localizer *Localizer localizer *Localizer
} }
func (l *LocalizerTest) SetupSuite() { func (l *LocalizerTest) SetupSuite() {
if _, err := os.Stat(testTranslationsDir); err != nil && os.IsNotExist(err) { createTestLangFiles(l.T())
err := os.Mkdir(testTranslationsDir, os.ModePerm) l.localizer = NewLocalizer(language.English, DefaultLocalizerMatcher(), testTranslationsDir)
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)
} }
func (l *LocalizerTest) Test_SetLocale() { func (l *LocalizerTest) Test_SetLocale() {
@ -40,11 +59,87 @@ func (l *LocalizerTest) Test_SetLocale() {
require.Nil(l.T(), recover()) require.Nil(l.T(), recover())
}() }()
l.localizer.SetLocale("es")
assert.Equal(l.T(), "Mensaje de prueba", l.localizer.GetLocalizedMessage("message"))
l.localizer.SetLocale("en") l.localizer.SetLocale("en")
assert.Equal(l.T(), "Test message", l.localizer.GetLocalizedMessage("message"))
} }
func (l *LocalizerTest) Test_LocalizationMiddleware() { func (l *LocalizerTest) Test_LocalizationMiddleware_Context() {
assert.NotNil(l.T(), l.localizer.LocalizationMiddleware()) 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() { func (l *LocalizerTest) Test_LocalizationFuncMap() {
@ -78,6 +173,23 @@ func (l *LocalizerTest) Test_BadRequestLocalized() {
assert.Equal(l.T(), "Test message", resp.(ErrorResponse).Error) 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() { func (l *LocalizerTest) TearDownSuite() {
err := os.RemoveAll(testTranslationsDir) err := os.RemoveAll(testTranslationsDir)
require.Nil(l.T(), err) require.Nil(l.T(), err)
@ -87,6 +199,18 @@ func TestLocalizer_Suite(t *testing.T) {
suite.Run(t, new(LocalizerTest)) 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) { func TestLocalizer_NoDirectory(t *testing.T) {
defer func() { defer func() {
assert.NotNil(t, recover()) assert.NotNil(t, recover())
@ -94,7 +218,6 @@ func TestLocalizer_NoDirectory(t *testing.T) {
_ = NewLocalizer( _ = NewLocalizer(
language.English, language.English,
DefaultLocalizerBundle(),
DefaultLocalizerMatcher(), DefaultLocalizerMatcher(),
path.Join(os.TempDir(), "this directory should not exist"), path.Join(os.TempDir(), "this directory should not exist"),
) )