Skip to content

Commit 687d406

Browse files
fix(yalocales): Nested JSON handling
1 parent 0864eb8 commit 687d406

10 files changed

Lines changed: 228 additions & 17 deletions

File tree

yalocales/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ var (
1111
ErrSubMapNotFound = errors.New("submap not found")
1212
ErrKeyNotFound = errors.New("key not found")
1313
ErrNilLocale = errors.New("nil locale")
14+
ErrPathConflict = errors.New("path conflict")
1415
ErrMismatchedKeys = errors.New("mismatched locale keys")
1516
ErrDefaultCoverage = errors.New("default language missing keys")
1617
ErrMismatchedPlaceholders = errors.New("mismatched locale placeholders")

yalocales/locale.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,19 @@ func (c *compiledLocale) insertByCompositeKey(key, value string) yaerrors.Error
107107

108108
keyPart := strings.SplitN(key, Separator, keySplitMaxParts)
109109

110-
if c.SubMap == nil {
111-
c.SubMap = make(map[string]*compiledLocale)
110+
if c.Value != "" {
111+
return yaerrors.FromError(
112+
http.StatusTeapot,
113+
ErrPathConflict,
114+
fmt.Sprintf("Key '%s' is a leaf and cannot contain subkeys", c.Key),
115+
)
112116
}
113117

114118
if len(keyPart) == keySplitMaxParts {
119+
if c.SubMap == nil {
120+
c.SubMap = make(map[string]*compiledLocale)
121+
}
122+
115123
_, ok := c.SubMap[keyPart[0]]
116124
if !ok {
117125
c.SubMap[keyPart[0]] = &compiledLocale{
@@ -127,7 +135,19 @@ func (c *compiledLocale) insertByCompositeKey(key, value string) yaerrors.Error
127135
return nil
128136
}
129137

130-
if _, ok := c.SubMap[key]; ok {
138+
if c.SubMap == nil {
139+
c.SubMap = make(map[string]*compiledLocale)
140+
}
141+
142+
if existing, ok := c.SubMap[key]; ok {
143+
if existing != nil && existing.SubMap != nil {
144+
return yaerrors.FromError(
145+
http.StatusTeapot,
146+
ErrPathConflict,
147+
fmt.Sprintf("Key '%s' already exists as a namespace", key),
148+
)
149+
}
150+
131151
return yaerrors.FromError(
132152
http.StatusTeapot,
133153
ErrDuplicateKey,

yalocales/locales.go

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,18 @@ func (l *YaLocalizer) GetJSONByCompositeKeyAndLang(
298298
key string,
299299
lang string,
300300
) ([]byte, yaerrors.Error) {
301+
if l.data[lang] == nil {
302+
if l.fallbackLang != "" && lang != l.fallbackLang {
303+
lang = l.fallbackLang
304+
} else {
305+
return nil, yaerrors.FromError(
306+
http.StatusNotFound,
307+
ErrInvalidLanguage,
308+
fmt.Sprintf("Language '%s' not found", lang),
309+
)
310+
}
311+
}
312+
301313
value, err := l.data[lang].retriveJSONByCompositeKey(key)
302314
if err != nil {
303315
if l.fallbackLang != "" && lang != l.fallbackLang {
@@ -325,6 +337,18 @@ func (l *YaLocalizer) GetValueByCompositeKeyAndLang(
325337
key string,
326338
lang string,
327339
) (string, yaerrors.Error) {
340+
if l.data[lang] == nil {
341+
if l.fallbackLang != "" && lang != l.fallbackLang {
342+
lang = l.fallbackLang
343+
} else {
344+
return "", yaerrors.FromError(
345+
http.StatusNotFound,
346+
ErrInvalidLanguage,
347+
fmt.Sprintf("Language '%s' not found", lang),
348+
)
349+
}
350+
}
351+
328352
value, err := l.data[lang].retriveValueByCompositeKey(key)
329353
if err != nil {
330354
if l.fallbackLang != "" && lang != l.fallbackLang {
@@ -526,7 +550,7 @@ func (l *YaLocalizer) loadFolder(fileSystem fs.FS, compositeKey string) yaerrors
526550
}
527551

528552
func (l *YaLocalizer) processJSONFile(data []byte, lang, compositeKey string) yaerrors.Error {
529-
var locales map[string]string
553+
var locales map[string]json.RawMessage
530554

531555
err := json.Unmarshal(data, &locales)
532556
if err != nil {
@@ -537,31 +561,95 @@ func (l *YaLocalizer) processJSONFile(data []byte, lang, compositeKey string) ya
537561
)
538562
}
539563

540-
for key, value := range locales {
541-
fullKey := key
542-
543-
if compositeKey != "" {
544-
fullKey = compositeKey + Separator + key
564+
for key, rawValue := range locales {
565+
if key == "" {
566+
return yaerrors.FromError(
567+
http.StatusTeapot,
568+
ErrInvalidTranslation,
569+
fmt.Sprintf("Empty translation key at path '%s'", compositeKey),
570+
)
545571
}
546572

547-
err := l.insertByCompositeKeyAndLang(fullKey, lang, value)
548-
if err != nil {
549-
return err.Wrap(fmt.Sprintf("Failed to insert locale at key '%s'", fullKey))
573+
fullKey := l.joinCompositeKeys(compositeKey, key)
574+
575+
yaErr := l.processJSONNode(rawValue, lang, fullKey)
576+
if yaErr != nil {
577+
return yaErr.Wrap(fmt.Sprintf("Failed to process JSON node at key '%s'", fullKey))
550578
}
551579
}
552580

553581
return nil
554582
}
555583

584+
func (l *YaLocalizer) processJSONNode(
585+
rawValue json.RawMessage,
586+
lang, fullKey string,
587+
) yaerrors.Error {
588+
var value string
589+
if err := json.Unmarshal(rawValue, &value); err == nil {
590+
yaErr := l.insertByCompositeKeyAndLang(fullKey, lang, value)
591+
if yaErr != nil {
592+
return yaErr.Wrap(fmt.Sprintf("Failed to insert locale at key '%s'", fullKey))
593+
}
594+
595+
return nil
596+
}
597+
598+
var block map[string]json.RawMessage
599+
if err := json.Unmarshal(rawValue, &block); err == nil {
600+
for key, subRawValue := range block {
601+
if key == "" {
602+
return yaerrors.FromError(
603+
http.StatusTeapot,
604+
ErrInvalidTranslation,
605+
fmt.Sprintf("Empty translation key at path '%s'", fullKey),
606+
)
607+
}
608+
609+
subKey := l.joinCompositeKeys(fullKey, key)
610+
611+
yaErr := l.processJSONNode(subRawValue, lang, subKey)
612+
if yaErr != nil {
613+
return yaErr.Wrap(fmt.Sprintf("Failed to process JSON block at key '%s'", subKey))
614+
}
615+
}
616+
617+
return nil
618+
}
619+
620+
return yaerrors.FromError(
621+
http.StatusTeapot,
622+
ErrInvalidTranslation,
623+
fmt.Sprintf(
624+
"Invalid translation value at key '%s'; expected string or JSON object",
625+
fullKey,
626+
),
627+
)
628+
}
629+
630+
func (l *YaLocalizer) joinCompositeKeys(prefix, key string) string {
631+
if prefix == "" {
632+
return key
633+
}
634+
635+
return prefix + Separator + key
636+
}
637+
556638
func (l *YaLocalizer) insertByCompositeKeyAndLang(key, lang, value string) yaerrors.Error {
557-
if _, ok := l.data[lang]; !ok {
558-
keyPart := strings.SplitN(key, Separator, keySplitMaxParts)
639+
if key == "" {
640+
return yaerrors.FromError(
641+
http.StatusTeapot,
642+
ErrInvalidTranslation,
643+
"Empty translation key is not allowed",
644+
)
645+
}
559646

560-
if len(keyPart) == keySplitMaxParts {
647+
if _, ok := l.data[lang]; !ok {
648+
if lang == "" {
561649
return yaerrors.FromError(
562650
http.StatusTeapot,
563651
ErrInvalidLanguage,
564-
"New languages must be populated top-to-bottom",
652+
"Language tag cannot be empty",
565653
)
566654
}
567655

yalocales/locales_test.go

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func TestFormattedValueWithMap(t *testing.T) {
121121
t.Fatalf("format value: %v", yaErr)
122122
}
123123

124-
// nolint: goconst
124+
//nolint: goconst
125125
want := "This is a Formatable Locale Replacement"
126126
if got != want {
127127
t.Fatalf("unexpected formatted value: got %q want %q", got, want)
@@ -416,3 +416,79 @@ func TestGetDefaultLangValueByCompositeKeyNoDefaultLang(t *testing.T) {
416416
t.Fatalf("unexpected error cause: %v", yaErr.Unwrap())
417417
}
418418
}
419+
420+
func TestLoadLocalesNestedBlocksMergedWithFolders(t *testing.T) {
421+
sub, err := fs.Sub(localesFS, "testdata/nested_blocks_with_folders")
422+
if err != nil {
423+
t.Fatalf("failed to access sub fs: %v", err)
424+
}
425+
426+
loc := yalocales.NewLocalizer("en", false)
427+
if yaErr := loc.LoadLocales(sub); yaErr != nil {
428+
t.Fatalf("load locales: %v", yaErr)
429+
}
430+
431+
cases := []struct {
432+
key string
433+
lang string
434+
want string
435+
}{
436+
{key: "root", lang: "en", want: "Root"},
437+
{key: "home.title", lang: "en", want: "Home"},
438+
{key: "home.subtitle", lang: "ua", want: "Ласкаво просимо"},
439+
{key: "home.cta", lang: "ua", want: "До дому"},
440+
}
441+
442+
for _, tc := range cases {
443+
got, yaErr := loc.GetValueByCompositeKeyAndLang(tc.key, tc.lang)
444+
if yaErr != nil {
445+
t.Fatalf("get value for key %q lang %q: %v", tc.key, tc.lang, yaErr)
446+
}
447+
448+
if got != tc.want {
449+
t.Fatalf(
450+
"unexpected value for key %q lang %q: got %q want %q",
451+
tc.key,
452+
tc.lang,
453+
got,
454+
tc.want,
455+
)
456+
}
457+
}
458+
459+
gotJSON, yaErr := loc.GetJSONByCompositeKeyAndLang("home", "en")
460+
if yaErr != nil {
461+
t.Fatalf("get JSON for key %q lang %q: %v", "home", "en", yaErr)
462+
}
463+
464+
wantJSON, err := json.Marshal(map[string]string{
465+
"cta": "Go Home",
466+
"subtitle": "Welcome",
467+
"title": "Home",
468+
})
469+
if err != nil {
470+
t.Fatalf("marshal expected JSON: %v", err)
471+
}
472+
473+
if string(gotJSON) != string(wantJSON) {
474+
t.Fatalf("unexpected merged JSON: got %s want %s", string(gotJSON), string(wantJSON))
475+
}
476+
}
477+
478+
func TestLoadLocalesPathConflict(t *testing.T) {
479+
sub, err := fs.Sub(localesFS, "testdata/path_conflict")
480+
if err != nil {
481+
t.Fatalf("failed to access sub fs: %v", err)
482+
}
483+
484+
loc := yalocales.NewLocalizer("en", false)
485+
486+
yaErr := loc.LoadLocales(sub)
487+
if yaErr == nil {
488+
t.Fatalf("expected path conflict error, got nil")
489+
}
490+
491+
if !errors.Is(yaErr.Unwrap(), yalocales.ErrPathConflict) {
492+
t.Fatalf("unexpected error cause: %v", yaErr.Unwrap())
493+
}
494+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"root": "Root",
3+
"home": {
4+
"title": "Home",
5+
"subtitle": "Welcome"
6+
}
7+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"cta": "Go Home"
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"cta": "До дому"
3+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"root": "Корінь",
3+
"home": {
4+
"title": "Дім",
5+
"subtitle": "Ласкаво просимо"
6+
}
7+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"section": "Leaf value"
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"child": "Nested value"
3+
}

0 commit comments

Comments
 (0)