Handle language switching for every route
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# ADR: Implement extractPathParams in CommonHtmxRoute
|
||||
|
||||
Date: 2025-08-22
|
||||
|
||||
## Context
|
||||
|
||||
We use a lightweight HTTP routing based on Go 1.22+ pattern-based ServeMux. Our route paths include placeholders in braces, such as `/en/events/{id}`. The HtmxContext provides a `GetPathParam(key string)` method that uses the request’s `PathValue(key)` to retrieve values for path parameters.
|
||||
|
||||
The `CommonHtmxRoute` type already supports building URLs by replacing placeholders via `ToUrl`. However, the reverse operation — extracting path params from the template to build a `[]HtmxPathParam` based on the current request — had an unimplemented stub: `extractPathParams`.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement `extractPathParams` to:
|
||||
- Parse the language-specific path template for placeholders of the form `{name}`.
|
||||
- Collect unique placeholder names in order of appearance.
|
||||
- For each placeholder name, call `c.GetPathParam(name)` to obtain its value from the current request context.
|
||||
- Return the slice of `HtmxPathParam{Key: name, Value: value}`.
|
||||
|
||||
This creates a consistent source of truth for path parameters both when generating URLs and when reading them from incoming requests.
|
||||
|
||||
## Implementation
|
||||
|
||||
- File: `ersteller-lib/htmx_route.go`
|
||||
- Function: `CommonHtmxRoute.extractPathParams(c HtmxContext) []HtmxPathParam`
|
||||
- Approach: String scanning to find `{` and `}` pairs, trimming and deduplicating keys; for each key, append `HtmxPathParam{Key: key, Value: c.GetPathParam(key)}` to the results.
|
||||
- No external dependencies added.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Using a regex like `\{([^}]+)\}`. Rejected to avoid introducing a regex dependency and to keep the implementation minimal and allocation-light.
|
||||
- Parsing the actual request path rather than the template. Rejected because placeholder names must come from the template, and values come from `GetPathParam`.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- The simple scanner assumes non-nested, well-formed placeholders and does not validate balanced braces beyond the first missing `}`. This is acceptable since templates are authored by maintainers and validated elsewhere (routing registration).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Callers that rely on `extractPathParams` now receive a correctly populated parameter list.
|
||||
- No API changes; only internal behavior is completed.
|
||||
|
||||
## Notes
|
||||
|
||||
- No UI controls were added/changed, so no CSS changes were required.
|
||||
- Localization is unaffected since this change does not introduce user-facing strings.
|
||||
|
||||
+154
-14
@@ -3,10 +3,12 @@ package ersteller_lib
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"maragu.dev/gomponents"
|
||||
htmx "maragu.dev/gomponents-htmx"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HtmxContext interface {
|
||||
@@ -17,16 +19,74 @@ type HtmxContext interface {
|
||||
HasActivePath() bool
|
||||
SetActivePath(activePath *ActivePath)
|
||||
GetPath() string
|
||||
GetPathTemplate() string
|
||||
SetPathTemplate(pathTemplate string)
|
||||
GetPathParam(key string, defaultValue ...string) string
|
||||
GetFormValue(key string) string
|
||||
SetHeader(key string, value string)
|
||||
SetAllRoutes(routes []HtmxRoute)
|
||||
GetAllRoutes() []HtmxRoute
|
||||
GetQueryParams() []HtmxPathParam
|
||||
}
|
||||
|
||||
type HtmxContextImpl struct {
|
||||
req *http.Request
|
||||
res http.ResponseWriter
|
||||
language Language
|
||||
activePath *ActivePath
|
||||
req *http.Request
|
||||
res http.ResponseWriter
|
||||
language Language
|
||||
activePath *ActivePath
|
||||
pathTemplate string
|
||||
routes []HtmxRoute
|
||||
}
|
||||
|
||||
func (c *HtmxContextImpl) GetQueryParams() []HtmxPathParam {
|
||||
// Extract query parameters from the current request URL and return them as
|
||||
// a deterministic list of HtmxPathParam. If a key has multiple values,
|
||||
// each value is included as a separate entry with the same key. Keys are
|
||||
// returned in sorted order to keep behavior stable for tests and callers.
|
||||
if c == nil || c.req == nil || c.req.URL == nil {
|
||||
return []HtmxPathParam{}
|
||||
}
|
||||
|
||||
values := c.req.URL.Query() // url.Values already decoded
|
||||
if len(values) == 0 {
|
||||
return []HtmxPathParam{}
|
||||
}
|
||||
|
||||
// Collect and sort keys for deterministic output
|
||||
keys := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
params := make([]HtmxPathParam, 0)
|
||||
for _, k := range keys {
|
||||
vs := values[k]
|
||||
if len(vs) == 0 {
|
||||
params = append(params, HtmxPathParam{Key: k, Value: ""})
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
params = append(params, HtmxPathParam{Key: k, Value: v})
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func (c *HtmxContextImpl) GetAllRoutes() []HtmxRoute {
|
||||
return c.routes
|
||||
}
|
||||
|
||||
func (c *HtmxContextImpl) SetAllRoutes(routes []HtmxRoute) {
|
||||
c.routes = routes
|
||||
}
|
||||
|
||||
func (c *HtmxContextImpl) SetPathTemplate(pathTemplate string) {
|
||||
c.pathTemplate = pathTemplate
|
||||
}
|
||||
|
||||
func (c *HtmxContextImpl) GetPathTemplate() string {
|
||||
return c.pathTemplate
|
||||
}
|
||||
|
||||
func (c *HtmxContextImpl) SetHeader(key string, value string) {
|
||||
@@ -110,11 +170,13 @@ type LocationRedirectParams struct {
|
||||
type HtmxRoute interface {
|
||||
WithPathParams(params ...HtmxPathParam) HtmxRoute
|
||||
ToUrl(language Language, queryParams ...HtmxPathParam) string
|
||||
ToUrlFromContext(c HtmxContext, language Language) string
|
||||
GetHtmx(language Language, queryParams ...HtmxPathParam) gomponents.Node
|
||||
SetActivePath(activePath *ActivePath) HtmxRoute
|
||||
Add(server *http.ServeMux)
|
||||
Add(server HtmxServer)
|
||||
Execute(c HtmxContext)
|
||||
RedirectToThisRoute(c HtmxContext, params LocationRedirectParams)
|
||||
IsCurrentRoute(c HtmxContext) bool
|
||||
}
|
||||
|
||||
func addLanguageToPath(path string, language Language) string {
|
||||
@@ -130,6 +192,18 @@ type CommonHtmxRoute struct {
|
||||
HtmxMethod HtmxHttpMethodFunction
|
||||
}
|
||||
|
||||
func (h CommonHtmxRoute) ToUrlFromContext(c HtmxContext, language Language) string {
|
||||
routeWithParams := h.WithPathParams(h.extractPathParams(c)...)
|
||||
queryParams := c.GetQueryParams()
|
||||
return routeWithParams.ToUrl(language, queryParams...)
|
||||
}
|
||||
|
||||
func (h CommonHtmxRoute) IsCurrentRoute(c HtmxContext) bool {
|
||||
templatePath := c.GetPathTemplate()
|
||||
urlOfThisRoute := h.ToUrl(c.GetLanguage())
|
||||
return templatePath == urlOfThisRoute
|
||||
}
|
||||
|
||||
func (h CommonHtmxRoute) RedirectToThisRoute(c HtmxContext, params LocationRedirectParams) {
|
||||
params.Path = h.ToUrl(c.GetLanguage())
|
||||
jsonData, err := json.Marshal(params)
|
||||
@@ -156,10 +230,12 @@ func (h CommonHtmxRoute) SetActivePath(activePath *ActivePath) HtmxRoute {
|
||||
return h
|
||||
}
|
||||
|
||||
func (h CommonHtmxRoute) AddWithMethod(method string, server *http.ServeMux) {
|
||||
func (h CommonHtmxRoute) AddWithMethod(method string, server HtmxServer) {
|
||||
for language, path := range h.Paths {
|
||||
server.HandleFunc(method+" "+addLanguageToPath(path, language), func(res http.ResponseWriter, req *http.Request) {
|
||||
server.GetHttpServer().HandleFunc(method+" "+addLanguageToPath(path, language), func(res http.ResponseWriter, req *http.Request) {
|
||||
context := NewHtmxContext(req, res, language)
|
||||
context.SetPathTemplate(addLanguageToPath(path, language))
|
||||
context.SetAllRoutes(server.GetAllRoutes())
|
||||
if h.ActivePath != nil {
|
||||
context.SetActivePath(h.ActivePath)
|
||||
}
|
||||
@@ -168,7 +244,8 @@ func (h CommonHtmxRoute) AddWithMethod(method string, server *http.ServeMux) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h CommonHtmxRoute) Add(server *http.ServeMux) {
|
||||
func (h CommonHtmxRoute) Add(server HtmxServer) {
|
||||
server.Add(h)
|
||||
h.AddWithMethod(h.Method, server)
|
||||
}
|
||||
|
||||
@@ -188,6 +265,41 @@ func (h CommonHtmxRoute) ToUrl(language Language, queryParams ...HtmxPathParam)
|
||||
return path
|
||||
}
|
||||
|
||||
func (h CommonHtmxRoute) extractPathParams(c HtmxContext) []HtmxPathParam {
|
||||
langPath := addLanguageToPath(h.Paths[c.GetLanguage()], c.GetLanguage())
|
||||
|
||||
// Extract all placeholders of the form {variable} from the language-specific path template
|
||||
params := make([]HtmxPathParam, 0)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for {
|
||||
start := strings.Index(langPath, "{")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(langPath[start+1:], "}")
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
end = start + 1 + end
|
||||
|
||||
key := strings.TrimSpace(langPath[start+1 : end])
|
||||
if key != "" && !seen[key] {
|
||||
seen[key] = true
|
||||
value := c.GetPathParam(key)
|
||||
params = append(params, HtmxPathParam{Key: key, Value: value})
|
||||
}
|
||||
|
||||
// Continue searching after current closing brace
|
||||
if end+1 >= len(langPath) {
|
||||
break
|
||||
}
|
||||
langPath = langPath[end+1:]
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
type HtmxHttpMethodFunction func(url string) gomponents.Node
|
||||
|
||||
func (h CommonHtmxRoute) GetHtmxFromMethod(method HtmxHttpMethodFunction, language Language, queryParams ...HtmxPathParam) gomponents.Node {
|
||||
@@ -206,10 +318,38 @@ func NewHtmxPostRoute(routeFunc HtmxRouteFunc, paths LanguagePaths) HtmxRoute {
|
||||
return NewCommonHtmxRoute(routeFunc, paths, "POST", htmx.Post)
|
||||
}
|
||||
|
||||
func HandleStaticAndDefaultPath(server *http.ServeMux, defaultRoute HtmxRoute, defaultLanguage Language) func(http.ResponseWriter, *http.Request) {
|
||||
type HtmxServer interface {
|
||||
Add(route HtmxRoute)
|
||||
GetAllRoutes() []HtmxRoute
|
||||
GetHttpServer() *http.ServeMux
|
||||
HandleStaticAndDefaultPath(defaultRoute HtmxRoute, defaultLanguage Language)
|
||||
}
|
||||
|
||||
type HtmxServerImpl struct {
|
||||
routes []HtmxRoute
|
||||
server *http.ServeMux
|
||||
}
|
||||
|
||||
func (h *HtmxServerImpl) GetAllRoutes() []HtmxRoute {
|
||||
return h.routes
|
||||
}
|
||||
|
||||
func NewHtmxServer() *HtmxServerImpl {
|
||||
return &HtmxServerImpl{server: http.NewServeMux()}
|
||||
}
|
||||
|
||||
func (h *HtmxServerImpl) Add(route HtmxRoute) {
|
||||
h.routes = append(h.routes, route)
|
||||
}
|
||||
|
||||
func (h *HtmxServerImpl) GetHttpServer() *http.ServeMux {
|
||||
return h.server
|
||||
}
|
||||
|
||||
func (h *HtmxServerImpl) HandleStaticAndDefaultPath(defaultRoute HtmxRoute, defaultLanguage Language) {
|
||||
staticHandler := http.StripPrefix("/static", http.FileServer(http.Dir("./static")))
|
||||
server.Handle("GET /static", staticHandler)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.server.Handle("GET /static", staticHandler)
|
||||
h.server.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if strings.HasPrefix(path, "/static") {
|
||||
staticHandler.ServeHTTP(w, r)
|
||||
@@ -220,5 +360,5 @@ func HandleStaticAndDefaultPath(server *http.ServeMux, defaultRoute HtmxRoute, d
|
||||
return
|
||||
}
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+2
-2
@@ -32,9 +32,9 @@ func MakeGzipHandler(fn http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// https://www.jvt.me/posts/2023/09/01/golang-nethttp-global-middleware/
|
||||
func UseMiddleware(r *http.ServeMux, middlewares ...func(next http.Handler) http.Handler) http.Handler {
|
||||
func UseMiddleware(r HtmxServer, middlewares ...func(next http.Handler) http.Handler) http.Handler {
|
||||
var s http.Handler
|
||||
s = r
|
||||
s = r.GetHttpServer()
|
||||
|
||||
for _, mw := range middlewares {
|
||||
s = mw(s)
|
||||
|
||||
Reference in New Issue
Block a user