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:
Jakub Zych
2026-10-06 11:54:00 +02:00
parent bd0a27f59f
commit b2ce986604
7 changed files with 134 additions and 15 deletions

View File

@@ -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: 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. 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. 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. 2. CORS, only for the paths configured under `http.cors.paths`, including preflight requests.
3. The request locale, taken from the `Accept-Language` header and stored in the context. 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 body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`. 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 route's middleware, in the order you listed them: group middleware first, then the route's own. 5. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
6. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs. 6. The route's middleware, in the order you listed them: group middleware first, then the route's own.
7. Your handler. 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. 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.

View File

@@ -75,7 +75,7 @@ for _, n := range []int{0, 1, 7} {
## The request locale ## 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 ## Strings for the admin

View File

@@ -101,7 +101,7 @@ The framework registers these names:
| `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. | | `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. |
| `backend` | The admin guard, when the admin is enabled. | | `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. `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.

View File

@@ -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. - 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. - 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`). - 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`. - 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. - `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. - 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.ClientIP` | Resolves the client IP, honouring trusted proxies. |
| `surf.TrustedProxies` | Parses `http.trusted_proxies` into CIDR prefixes. | | `surf.TrustedProxies` | Parses `http.trusted_proxies` into CIDR prefixes. |
| `surf.CORSConfig` | CORS settings; `surf.LoadCORSConfig` reads them from config. | | `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.LocaleFromPrincipal` | Middleware that applies the signed-in user's preferred locale. |
| `surf.ServeCommand` | The `serve` console command. | | `surf.ServeCommand` | The `serve` console command. |
| `surf.RouteListCommand` | The `route:list` console command. | | `surf.RouteListCommand` | The `route:list` console command. |

View 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
}

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

View File

@@ -59,6 +59,7 @@ type Router struct {
defaultBytes int64 defaultBytes int64
uploadBytes int64 uploadBytes int64
built map[string]pact.Middleware built map[string]pact.Middleware
app *backpack.App
} }
var ( var (
@@ -375,7 +376,7 @@ func (r *Router) compile() (http.Handler, error) {
if err := registerOverlapFamilies(mux, r.routes, handlers, families); err != nil { if err := registerOverlapFamilies(mux, r.routes, handlers, families); err != nil {
return nil, err 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. // 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 { if limit > 0 {
h = bodyLimit(limit)(h) h = bodyLimit(limit)(h)
} }
h = locale(h) h = r.locale(h)
if rt.raw { if rt.raw {
h = recoverBare(h) h = recoverBare(h)
} else { } 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. // so callers (route:list) can inspect Routes() after a successful boot.
func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) { func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
r := New(corsOrigins(app)) r := New(corsOrigins(app))
r.app = app
trusted := TrustedProxies(nil) trusted := TrustedProxies(nil)
if app != nil { if app != nil {
trusted = TrustedProxies(app.Config) trusted = TrustedProxies(app.Config)
@@ -704,9 +706,27 @@ func (w *bufferedResponse) commit(dst http.ResponseWriter) {
_, _ = dst.Write(w.body.Bytes()) _, _ = dst.Write(w.body.Bytes())
} }
func locale(next http.Handler) http.Handler { func (r *Router) locale(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
next.ServeHTTP(w, r.WithContext(towel.WithLocale(r.Context(), r.Header.Get("Accept-Language")))) 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)
}) })
} }