// 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)) }) }