feat(14.2.1-01): add optional surf LocaleResolver seam
Look up a backpack-published resolver so a compiled translate plugin can strip an enabled URL prefix and write a validated locale onto context. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,13 +35,14 @@ In Go the same declaration is a `pact.HasRoutes` method. Paths use Go `http.Serv
|
||||
|
||||
surf wraps every non-raw route in the same layers. From the outside in:
|
||||
|
||||
1. CORS, only for the paths configured under `http.cors.paths`, including preflight requests.
|
||||
2. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body.
|
||||
3. The request locale, taken from the `Accept-Language` header and stored in the context.
|
||||
4. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
|
||||
5. The route's middleware, in the order you listed them: group middleware first, then the route's own.
|
||||
6. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
|
||||
7. Your handler.
|
||||
1. Optional locale-prefix rewrite, when a `surf.LocaleResolver` is published: an enabled locale as the first path segment is stripped so later layers and routing see the unprefixed path.
|
||||
2. CORS, only for the paths configured under `http.cors.paths`, including preflight requests.
|
||||
3. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body.
|
||||
4. The request locale, taken from the published `surf.LocaleResolver` when one exists, or from the `Accept-Language` header otherwise, and stored in the context.
|
||||
5. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
|
||||
6. The route's middleware, in the order you listed them: group middleware first, then the route's own.
|
||||
7. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
|
||||
8. Your handler.
|
||||
|
||||
Raw groups, declared with `pact.Router.GroupRaw`, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500.
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ for _, n := range []int{0, 1, 7} {
|
||||
|
||||
## The request locale
|
||||
|
||||
The locale lives on the request context, not in a global. For every route, [surf](../../modules/surf/README.md) sets it from the `Accept-Language` header; the `locale.from-principal` middleware switches it to the signed-in user's preferred locale. Code reads it with `towel.Locale`, and code outside a request sets it with `towel.WithLocale`, as the example above does. A context without a locale uses `app.locale`.
|
||||
The locale lives on the request context, not in a global. For every route, [surf](../../modules/surf/README.md) looks up an optional `surf.LocaleResolver` published by a compiled plugin and writes that validated code with `towel.WithLocale`. When no resolver is published, surf sets the locale from the `Accept-Language` header and the `locale.from-principal` middleware can switch it to the signed-in user's preferred locale. Code reads it with `towel.Locale`, and code outside a request sets it with `towel.WithLocale`, as the example above does. A context without a locale uses `app.locale`.
|
||||
|
||||
## Strings for the admin
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ The framework registers these names:
|
||||
| `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. |
|
||||
| `backend` | The admin guard, when the admin is enabled. |
|
||||
|
||||
Every route also gets, around its own middleware, JSON panic recovery, the request locale from `Accept-Language`, the body limit from `http.body_limits.default_bytes`, and CORS headers when its path matches `http.cors.paths`. The order is described in [Request lifecycle](../architecture/request-lifecycle.md).
|
||||
Every route also gets, around its own middleware, JSON panic recovery, the request locale from a published [`surf.LocaleResolver`](../../modules/surf/README.md) or from `Accept-Language` when none is published, the body limit from `http.body_limits.default_bytes`, and CORS headers when its path matches `http.cors.paths`. The order is described in [Request lifecycle](../architecture/request-lifecycle.md).
|
||||
|
||||
`pact.Router.GroupRaw` declares a raw group for routes that must not be wrapped in the house JSON middleware, such as webhooks, file streams or the OAuth endpoints: the default body limit is skipped, and a panic returns a bare 500.
|
||||
|
||||
|
||||
@@ -18,8 +18,9 @@ surf turns the routes that plugins declare through `pact.HasRoutes` into one `ht
|
||||
- Built-in middleware names: `throttle:<bucket>` or `throttle:<max>,<minutes>`, `body.limit:<bytes>`, `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled.
|
||||
- Fixed-window rate limiting (`surf.FixedWindowLimiter`): named buckets from plugins that implement `surf.BucketProvider`, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with `Retry-After` and `X-RateLimit-*` headers. The in-process `surf.MemoryStore` sits behind the `surf.Store` interface.
|
||||
- Client IP resolution for limiter keys (`surf.ClientIP`) that only trusts `X-Forwarded-For` hops when the direct peer is inside a configured trusted proxy range (`surf.TrustedProxies`).
|
||||
- Every non-raw route runs inside JSON panic recovery (an opaque 500 via [wire](../wire/README.md)), gets the request locale from the `Accept-Language` header (see [towel](../towel/README.md)) and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body.
|
||||
- Every non-raw route runs inside JSON panic recovery (an opaque 500 via [wire](../wire/README.md)), gets the request locale from a published `surf.LocaleResolver` when one exists or from the `Accept-Language` header otherwise (see [towel](../towel/README.md)), and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body.
|
||||
- Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`.
|
||||
- Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay.
|
||||
- `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale.
|
||||
- A read-only route table (`surf.Router.Routes`) and the `serve` and `route:list` commands.
|
||||
|
||||
@@ -118,6 +119,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `surf.ClientIP` | Resolves the client IP, honouring trusted proxies. |
|
||||
| `surf.TrustedProxies` | Parses `http.trusted_proxies` into CIDR prefixes. |
|
||||
| `surf.CORSConfig` | CORS settings; `surf.LoadCORSConfig` reads them from config. |
|
||||
| `surf.LocaleResolver` | Optional backpack-published request locale; `Resolve` returns a validated code and `Rewrite` strips an enabled prefix before routing. |
|
||||
| `surf.LocaleFromPrincipal` | Middleware that applies the signed-in user's preferred locale. |
|
||||
| `surf.ServeCommand` | The `serve` console command. |
|
||||
| `surf.RouteListCommand` | The `route:list` console command. |
|
||||
|
||||
15
modules/surf/locale_resolver.go
Normal file
15
modules/surf/locale_resolver.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package surf
|
||||
|
||||
import "net/http"
|
||||
|
||||
// LocaleResolver is an optional backpack-published service that selects a
|
||||
// validated locale code for a request. When none is published, surf keeps
|
||||
// writing the raw Accept-Language header onto the request context.
|
||||
type LocaleResolver interface {
|
||||
// Resolve returns a validated locale code for r. It may set cookies on w
|
||||
// and must not store request locale on a process-wide variable.
|
||||
Resolve(w http.ResponseWriter, r *http.Request) string
|
||||
// Rewrite returns a request whose URL has a valid enabled locale prefix
|
||||
// stripped so routing sees the unprefixed path, or r unchanged.
|
||||
Rewrite(r *http.Request) *http.Request
|
||||
}
|
||||
81
modules/surf/locale_resolver_test.go
Normal file
81
modules/surf/locale_resolver_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
type stubLocaleResolver struct {
|
||||
code string
|
||||
rewriteTo string
|
||||
}
|
||||
|
||||
func (s stubLocaleResolver) Resolve(http.ResponseWriter, *http.Request) string {
|
||||
return s.code
|
||||
}
|
||||
|
||||
func (s stubLocaleResolver) Rewrite(r *http.Request) *http.Request {
|
||||
if s.rewriteTo == "" {
|
||||
return r
|
||||
}
|
||||
clone := r.Clone(r.Context())
|
||||
clone.URL.Path = s.rewriteTo
|
||||
return clone
|
||||
}
|
||||
|
||||
func TestLocaleResolverWritesValidatedCodeAndStripsPrefix(t *testing.T) {
|
||||
cfg := writeHTTPConfig(t, "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n")
|
||||
app := backpack.New(cfg)
|
||||
if err := app.Publish[LocaleResolver](stubLocaleResolver{code: "pl", rewriteTo: "/posts"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := New(nil)
|
||||
r.app = app
|
||||
r.defaultBytes = 1024
|
||||
r.BindPlugin("acme.blog")
|
||||
var gotPath, gotLoc string
|
||||
var ok bool
|
||||
r.Get("/posts", func(w http.ResponseWriter, req *http.Request) {
|
||||
gotPath = req.URL.Path
|
||||
gotLoc, ok = towel.Locale(req.Context())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
h, err := r.compile()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/pl/posts", nil)
|
||||
req.Header.Set("Accept-Language", "de")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
if gotPath != "/posts" {
|
||||
t.Fatalf("path = %q, want /posts", gotPath)
|
||||
}
|
||||
if !ok || gotLoc != "pl" {
|
||||
t.Fatalf("locale = %q ok=%t, want pl from resolver", gotLoc, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleAbsentResolverKeepsAcceptLanguage(t *testing.T) {
|
||||
r := New(nil)
|
||||
r.BindPlugin("acme.blog")
|
||||
var gotLoc string
|
||||
var ok bool
|
||||
r.Get("/posts", func(w http.ResponseWriter, req *http.Request) {
|
||||
gotLoc, ok = towel.Locale(req.Context())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
h, err := r.compile()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/posts", nil)
|
||||
req.Header.Set("Accept-Language", "pl")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
if !ok || gotLoc != "pl" {
|
||||
t.Fatalf("locale = %q ok=%t, want pl from Accept-Language", gotLoc, ok)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ type Router struct {
|
||||
defaultBytes int64
|
||||
uploadBytes int64
|
||||
built map[string]pact.Middleware
|
||||
app *backpack.App
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -375,7 +376,7 @@ func (r *Router) compile() (http.Handler, error) {
|
||||
if err := registerOverlapFamilies(mux, r.routes, handlers, families); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pathScopedCORS(r.corsCfg, mux), nil
|
||||
return r.stripLocalePrefix(pathScopedCORS(r.corsCfg, mux)), nil
|
||||
}
|
||||
|
||||
// handleRoute registers a route, converting a ServeMux conflict panic into an error.
|
||||
@@ -436,7 +437,7 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
|
||||
if limit > 0 {
|
||||
h = bodyLimit(limit)(h)
|
||||
}
|
||||
h = locale(h)
|
||||
h = r.locale(h)
|
||||
if rt.raw {
|
||||
h = recoverBare(h)
|
||||
} else {
|
||||
@@ -458,6 +459,7 @@ func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) {
|
||||
// so callers (route:list) can inspect Routes() after a successful boot.
|
||||
func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
r := New(corsOrigins(app))
|
||||
r.app = app
|
||||
trusted := TrustedProxies(nil)
|
||||
if app != nil {
|
||||
trusted = TrustedProxies(app.Config)
|
||||
@@ -704,9 +706,27 @@ func (w *bufferedResponse) commit(dst http.ResponseWriter) {
|
||||
_, _ = dst.Write(w.body.Bytes())
|
||||
}
|
||||
|
||||
func locale(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r.WithContext(towel.WithLocale(r.Context(), r.Header.Get("Accept-Language"))))
|
||||
func (r *Router) locale(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if r != nil && r.app != nil {
|
||||
if resolver, ok := r.app.Lookup[LocaleResolver](); ok && resolver != nil {
|
||||
code := resolver.Resolve(w, req)
|
||||
next.ServeHTTP(w, req.WithContext(towel.WithLocale(req.Context(), code)))
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, req.WithContext(towel.WithLocale(req.Context(), req.Header.Get("Accept-Language"))))
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Router) stripLocalePrefix(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if r != nil && r.app != nil {
|
||||
if resolver, ok := r.app.Lookup[LocaleResolver](); ok && resolver != nil {
|
||||
req = resolver.Rewrite(req)
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user