feat: release v0.26 portable i18n and presentation adapters
This commit is contained in:
151
i18n/errori18n/errors.go
Normal file
151
i18n/errori18n/errors.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Package errori18n adapts public errors to independent i18n dictionaries.
|
||||
package errori18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
)
|
||||
|
||||
type Localizer struct {
|
||||
bundle *i18n.Bundle
|
||||
report func(error)
|
||||
}
|
||||
|
||||
// New borrows an immutable Bundle. report is optional, must be concurrency-safe,
|
||||
// and receives diagnostics without argument values or internal error causes.
|
||||
func New(bundle *i18n.Bundle, report func(error)) *Localizer {
|
||||
return &Localizer{bundle: bundle, report: report}
|
||||
}
|
||||
|
||||
func kind(t string) i18n.Kind {
|
||||
switch t {
|
||||
case "string":
|
||||
return i18n.String
|
||||
case "bool":
|
||||
return i18n.Boolean
|
||||
case "int", "int32", "int64":
|
||||
return i18n.Integer
|
||||
case "float64":
|
||||
return i18n.Number
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Bind returns a detached catalog, assigning references only to entries which
|
||||
// do not already have one. Different catalogs can bind to different namespaces.
|
||||
func Bind(c apierrors.Catalog, namespace string) apierrors.Catalog {
|
||||
c = c.Clone()
|
||||
for key, d := range c.Definitions {
|
||||
if d.Translation == nil {
|
||||
d.Translation = &apierrors.Translation{Namespace: namespace, Key: key}
|
||||
c.Definitions[key] = d
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Source exports only explicitly referenced public messages. Codes, statuses,
|
||||
// examples and causes are not included. Fixed legacy braces remain literal.
|
||||
func Source(c apierrors.Catalog, locale string) ([]i18n.Document, error) {
|
||||
r, err := apierrors.NewRegistry(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := map[string]i18n.Document{}
|
||||
for _, d := range r.Catalog().Definitions {
|
||||
if d.Translation == nil {
|
||||
continue
|
||||
}
|
||||
ref := d.Translation
|
||||
doc, ok := groups[ref.Namespace]
|
||||
if !ok {
|
||||
doc = i18n.Document{Version: i18n.Version, Locale: locale, Namespace: ref.Namespace, Messages: map[string]i18n.Message{}}
|
||||
}
|
||||
m := i18n.Message{Params: map[string]i18n.Kind{}, Value: i18n.Pattern{Text: d.Message, Literal: d.Params == nil}}
|
||||
for _, p := range d.Params {
|
||||
m.Params[p.Name] = kind(p.Type)
|
||||
}
|
||||
if prior, exists := doc.Messages[ref.Key]; exists && !reflect.DeepEqual(prior, m) {
|
||||
return nil, fmt.Errorf("errori18n: conflicting source messages for one translation reference")
|
||||
}
|
||||
doc.Messages[ref.Key] = m
|
||||
groups[ref.Namespace] = doc
|
||||
}
|
||||
names := []string{}
|
||||
for ns := range groups {
|
||||
names = append(names, ns)
|
||||
}
|
||||
sort.Strings(names)
|
||||
docs := []i18n.Document{}
|
||||
for _, ns := range names {
|
||||
docs = append(docs, groups[ns])
|
||||
}
|
||||
_, err = i18n.New(i18n.Config{SourceLocale: locale, DefaultLocale: locale, Locales: []string{locale}}, docs...)
|
||||
return docs, err
|
||||
}
|
||||
|
||||
// Validate can be used during startup or generation to check explicit references
|
||||
// and type compatibility. A runtime mismatch always preserves the original error.
|
||||
func (l *Localizer) Validate(c apierrors.Catalog) error {
|
||||
r, err := apierrors.NewRegistry(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range r.Catalog().Definitions {
|
||||
if d.Translation != nil {
|
||||
if err := l.check(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (l *Localizer) check(d apierrors.Definition) error {
|
||||
ref := d.Translation
|
||||
if l == nil || l.bundle == nil {
|
||||
return &i18n.Diagnostic{Kind: "missing", Problem: "error localizer has no bundle"}
|
||||
}
|
||||
schema, ok := l.bundle.Schema(i18n.Ref{Namespace: ref.Namespace, Key: ref.Key})
|
||||
if !ok {
|
||||
return &i18n.Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Problem: "error translation reference is not defined"}
|
||||
}
|
||||
expected := map[string]i18n.Kind{}
|
||||
for _, p := range d.Params {
|
||||
expected[p.Name] = kind(p.Type)
|
||||
}
|
||||
if !reflect.DeepEqual(schema, expected) {
|
||||
return &i18n.Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Problem: "error parameter schema differs from message schema"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (l *Localizer) Localize(ctx context.Context, registry *apierrors.Registry, failure *apierrors.APIError) (string, bool) {
|
||||
if l == nil || failure == nil {
|
||||
return "", false
|
||||
}
|
||||
d, ok := registry.Definition(failure.Code)
|
||||
if !ok || d.Translation == nil {
|
||||
return "", false
|
||||
}
|
||||
if err := l.check(d); err != nil {
|
||||
if l.report != nil {
|
||||
l.report(err)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
result, err := l.bundle.RenderContext(ctx, i18n.Ref{Namespace: d.Translation.Namespace, Key: d.Translation.Key}, failure.Parameters())
|
||||
if err != nil {
|
||||
if l.report != nil {
|
||||
l.report(err)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if result.Fallback && l.report != nil {
|
||||
l.report(&i18n.Diagnostic{Kind: "fallback", Namespace: d.Translation.Namespace, Key: d.Translation.Key, Locale: result.Requested, Problem: "used a fallback dictionary"})
|
||||
}
|
||||
return result.Text, true
|
||||
}
|
||||
54
i18n/errori18n/errors_test.go
Normal file
54
i18n/errori18n/errors_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package errori18n_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||
)
|
||||
|
||||
func TestCatalogAdapterIsExplicitAndDetached(t *testing.T) {
|
||||
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"A": {Status: 409, Code: 7, Message: "{value}", Params: []apierrors.Parameter{{Name: "value", Type: "int64"}}}, "LITERAL": {Code: 8, Message: "${HOME}"}}}
|
||||
bound := errori18n.Bind(c, "errors")
|
||||
if c.Definitions["A"].Translation != nil {
|
||||
t.Fatal("mutated source")
|
||||
}
|
||||
source, err := errori18n.Source(bound, "en")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en"}}, source...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := errori18n.New(b, nil)
|
||||
if err := l.Validate(bound); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := apierrors.MustRegistry(bound)
|
||||
bound.Definitions["A"].Translation.Namespace = "changed"
|
||||
metadata, _ := r.Definition("A")
|
||||
metadata.Translation.Namespace = "changed"
|
||||
metadata.Params[0].Type = "bool"
|
||||
private := errors.New("private")
|
||||
failure := r.Resolve(apierrors.ByCode("A").WithParams(map[string]any{"value": int64(9223372036854775807)}).WithCause(private))
|
||||
message, ok := l.Localize(context.Background(), r, failure)
|
||||
if !ok || message != "9223372036854775807" || failure.Status != 409 || !errors.Is(failure, private) {
|
||||
t.Fatal(message, ok, failure)
|
||||
}
|
||||
if _, ok := l.Localize(context.Background(), apierrors.MustRegistry(c), failure); ok {
|
||||
t.Fatal("implicit translation")
|
||||
}
|
||||
missing := errori18n.New(nil, nil)
|
||||
if _, ok := missing.Localize(context.Background(), r, failure); ok {
|
||||
t.Fatal("missing bundle translated")
|
||||
}
|
||||
args := failure.Parameters()
|
||||
args["value"] = int64(0)
|
||||
if failure.Parameters()["value"] != int64(9223372036854775807) {
|
||||
t.Fatal("mutable parameters")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user