feat: release v0.26 portable i18n and presentation adapters
This commit is contained in:
131
i18n/httpi18n/http.go
Normal file
131
i18n/httpi18n/http.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Package httpi18n negotiates an i18n language for standard HTTP requests.
|
||||
package httpi18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Resolver supplies ordered language tags, e.g. from an authenticated preference.
|
||||
// It must be safe for concurrent requests. It does not receive a mutable Bundle.
|
||||
// A panic is treated as no preference, preserving request execution.
|
||||
type Resolver func(*http.Request) []string
|
||||
type Option func(*Policy) error
|
||||
type Policy struct {
|
||||
bundle *i18n.Bundle
|
||||
resolver Resolver
|
||||
vary []string
|
||||
private bool
|
||||
}
|
||||
|
||||
// WithResolver overrides the default Accept-Language resolver. Cache variation
|
||||
// is explicit. With no varying headers, responses are marked private.
|
||||
func WithResolver(resolver Resolver, varyHeaders ...string) Option {
|
||||
return func(p *Policy) error {
|
||||
if resolver == nil {
|
||||
return fmt.Errorf("httpi18n: nil resolver")
|
||||
}
|
||||
p.resolver = resolver
|
||||
p.vary = nil
|
||||
p.private = len(varyHeaders) == 0
|
||||
for _, h := range varyHeaders {
|
||||
if !headerName(h) {
|
||||
return fmt.Errorf("httpi18n: invalid Vary header")
|
||||
}
|
||||
p.vary = append(p.vary, http.CanonicalHeaderKey(h))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func headerName(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c > 127 || !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", c)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func New(bundle *i18n.Bundle, options ...Option) (*Policy, error) {
|
||||
if bundle == nil {
|
||||
return nil, fmt.Errorf("httpi18n: bundle required")
|
||||
}
|
||||
p := &Policy{bundle: bundle, vary: []string{"Accept-Language"}, resolver: func(r *http.Request) []string {
|
||||
header := r.Header.Get("Accept-Language")
|
||||
if len(header) > 8192 {
|
||||
return nil
|
||||
}
|
||||
tags, _, err := language.ParseAcceptLanguage(header)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := []string{}
|
||||
for _, tag := range tags {
|
||||
if len(out) == 64 {
|
||||
break
|
||||
}
|
||||
out = append(out, tag.String())
|
||||
}
|
||||
return out
|
||||
}}
|
||||
for _, o := range options {
|
||||
if o == nil {
|
||||
return nil, fmt.Errorf("httpi18n: nil option")
|
||||
}
|
||||
if err := o(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Apply installs the negotiated language before application middleware. An
|
||||
// explicit context preference takes priority and makes the response private.
|
||||
// It does not claim Content-Language: individual messages may use fallbacks.
|
||||
func (p *Policy) Apply(w http.ResponseWriter, r *http.Request) *http.Request {
|
||||
if p == nil {
|
||||
return r
|
||||
}
|
||||
preferences := []string{}
|
||||
explicit := i18n.Locale(r.Context())
|
||||
if explicit != "" {
|
||||
preferences = append(preferences, explicit)
|
||||
}
|
||||
preferences = append(preferences, p.resolve(r)...)
|
||||
for _, h := range p.vary {
|
||||
addVary(w.Header(), h)
|
||||
}
|
||||
if p.private || explicit != "" {
|
||||
w.Header().Add("Cache-Control", "private")
|
||||
}
|
||||
return r.WithContext(i18n.WithLocale(r.Context(), p.bundle.Match(preferences...)))
|
||||
}
|
||||
|
||||
func (p *Policy) resolve(r *http.Request) (preferences []string) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
preferences = nil
|
||||
}
|
||||
}()
|
||||
return p.resolver(r)
|
||||
}
|
||||
func addVary(h http.Header, value string) {
|
||||
for _, line := range h.Values("Vary") {
|
||||
for _, v := range strings.Split(line, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(v), value) || strings.TrimSpace(v) == "*" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
h.Add("Vary", value)
|
||||
}
|
||||
func (p *Policy) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, p.Apply(w, r)) })
|
||||
}
|
||||
59
i18n/httpi18n/http_test.go
Normal file
59
i18n/httpi18n/http_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package httpi18n_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||
)
|
||||
|
||||
func TestNegotiationAndExplicitResolvers(t *testing.T) {
|
||||
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "fr", "zh-Hans", "zh-Hant"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := httpi18n.New(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct{ header, explicit, want string }{{"fr;q=0.8,en;q=0.1", "", "fr"}, {"fr;q=0,en;q=1", "", "en"}, {"zh-TW", "", "zh-Hant"}, {"zh-CN", "", "zh-Hans"}, {"invalid;garbage", "", "en"}, {strings.Repeat("x", 8193), "", "en"}, {"fr", "en", "en"}} {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Language", test.header)
|
||||
if test.explicit != "" {
|
||||
r = r.WithContext(i18n.WithLocale(r.Context(), test.explicit))
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
w.Header().Set("Vary", "Origin")
|
||||
p.Wrap(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
if got := i18n.Locale(r.Context()); got != test.want {
|
||||
t.Errorf("got %s want %s", got, test.want)
|
||||
}
|
||||
})).ServeHTTP(w, r)
|
||||
if strings.Join(w.Header().Values("Vary"), ",") != "Origin,Accept-Language" {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
if test.explicit != "" && !strings.Contains(w.Header().Get("Cache-Control"), "private") {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
}
|
||||
custom, err := httpi18n.New(b, httpi18n.WithResolver(func(*http.Request) []string { return []string{"fr"} }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := custom.Apply(w, httptest.NewRequest("GET", "/", nil))
|
||||
if i18n.Locale(r.Context()) != "fr" || w.Header().Get("Cache-Control") != "private" {
|
||||
t.Fatal(r, w.Header())
|
||||
}
|
||||
broken, err := httpi18n.New(b, httpi18n.WithResolver(func(*http.Request) []string { panic("preference lookup failed") }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = broken.Apply(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||
if i18n.Locale(r.Context()) != "en" {
|
||||
t.Fatal("resolver panic must preserve default selection")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user