Files
ersteller/htmx_route.go
T

550 lines
14 KiB
Go

package ersteller_lib
import (
"encoding/json"
"fmt"
"net/http"
"runtime"
"runtime/debug"
"sort"
"strings"
"maragu.dev/gomponents"
htmx "maragu.dev/gomponents-htmx"
)
const AuthContextKey = "authContext"
type AuthContext struct {
Email string
UserId int
}
type HtmxContext interface {
Render(node gomponents.Node)
SetError(err error)
GetLanguage() Language
GetActivePath() *ActivePath
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
GetAuthContext() (bool, AuthContext)
}
type HtmxContextImpl struct {
req *http.Request
res http.ResponseWriter
language Language
activePath *ActivePath
pathTemplate string
routes []HtmxRoute
}
func (c *HtmxContextImpl) GetAuthContext() (bool, AuthContext) {
authCtx := c.req.Context().Value(AuthContextKey)
if authCtx == nil {
return false, AuthContext{}
}
return true, authCtx.(AuthContext)
}
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) {
c.res.Header().Set(key, value)
}
func (c *HtmxContextImpl) GetFormValue(key string) string {
err := c.req.ParseForm()
if err != nil {
Error("failed to parse form", err)
c.SetError(err)
return ""
}
return c.req.FormValue(key)
}
func (c *HtmxContextImpl) SetError(err error) {
_, writeErr := c.res.Write([]byte(err.Error()))
if writeErr != nil {
Error("failed to write error", writeErr)
}
c.res.WriteHeader(500)
}
func (c *HtmxContextImpl) GetPathParam(key string, defaultValue ...string) string {
pathParam := c.req.PathValue(key)
if pathParam != "" {
return pathParam
}
if len(defaultValue) > 0 {
return defaultValue[0]
}
return ""
}
func (c *HtmxContextImpl) GetPath() string {
return c.req.URL.Path
}
func (c *HtmxContextImpl) SetActivePath(activePath *ActivePath) {
c.activePath = activePath
}
func (c *HtmxContextImpl) GetActivePath() *ActivePath {
return c.activePath
}
func (c *HtmxContextImpl) HasActivePath() bool {
return c.activePath != nil
}
func (c *HtmxContextImpl) GetLanguage() Language {
return c.language
}
func NewHtmxContext(req *http.Request, res http.ResponseWriter, language Language) HtmxContext {
return &HtmxContextImpl{req: req, res: res, language: language}
}
func (c *HtmxContextImpl) Render(node gomponents.Node) {
err := node.Render(c.res)
if err != nil {
Error("failed to render", err)
c.SetError(err)
}
}
type HtmxRouteFunc func(ctx HtmxContext)
type HtmxPathParam struct {
Key string
Value string
}
type LocationRedirectParams struct {
Path string `json:"path"`
Target string `json:"target,omitempty"`
Swap string `json:"swap,omitempty"`
}
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
GetActivePath() *ActivePath
GetPaths() LanguagePaths
Add(server HtmxServer)
Execute(c HtmxContext)
RedirectToThisRoute(c HtmxContext, params LocationRedirectParams)
IsCurrentRoute(c HtmxContext) bool
}
type GlobalHtmxRoutes interface {
GetRoutes() []HtmxRoute
Add(route HtmxRoute)
// MatchByPath tries to find a route whose template matches the given path
// even if the path contains path parameters. Returns (route, true) if found.
MatchByPath(path string) (*HtmxRouteInfo, bool)
}
type HtmxRouteInfo struct {
initTrace string
route HtmxRoute
}
type GlobalHtmxRoutesImpl struct {
routes map[string]*HtmxRouteInfo
}
var GlobalHtmxRoutesInstance GlobalHtmxRoutes = NewGlobalHtmxRoutesImpl()
func (g *GlobalHtmxRoutesImpl) GetRoutes() []HtmxRoute {
routes := make([]HtmxRoute, len(g.routes))
index := 0
for _, route := range g.routes {
Debug("index: ", index, "route: ", route, "")
routes[index] = route.route
index++
}
return routes
}
func (g *GlobalHtmxRoutesImpl) Add(route HtmxRoute) {
paths := route.GetPaths()
uniqueKey := ""
for language, path := range paths {
uniqueKey += string(language) + path
}
g.routes[uniqueKey] = &HtmxRouteInfo{
initTrace: string(debug.Stack()),
route: route,
}
}
// MatchByPath tries to find a route whose language-specific template matches the given
// concrete URL path. It treats path parameters like {id} as wildcards.
func (g *GlobalHtmxRoutesImpl) MatchByPath(path string) (*HtmxRouteInfo, bool) {
p := normalizePath(path)
// Extract language as first segment (if present)
trim := strings.Trim(p, "/")
segs := []string{}
if trim != "" {
segs = strings.Split(trim, "/")
}
if len(segs) == 0 {
return nil, false
}
lang := Language(segs[0])
for _, info := range g.routes {
paths := info.route.GetPaths()
tpl, ok := paths[lang]
if !ok {
continue
}
candidate := addLanguageToPath(tpl, lang)
if matchTemplate(candidate, p) {
return info, true
}
}
return nil, false
}
func normalizePath(path string) string {
if path == "" {
return "/"
}
// strip query string and fragment
if i := strings.Index(path, "?"); i >= 0 {
path = path[:i]
}
if i := strings.Index(path, "#"); i >= 0 {
path = path[:i]
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
// remove duplicate slashes (basic)
for strings.Contains(path, "//") {
path = strings.ReplaceAll(path, "//", "/")
}
// drop trailing slash except root
if len(path) > 1 && strings.HasSuffix(path, "/") {
path = strings.TrimSuffix(path, "/")
}
return path
}
func matchTemplate(templatePath, concretePath string) bool {
// Normalize both
t := normalizePath(templatePath)
p := normalizePath(concretePath)
// Quick equality short-circuit
if t == p {
return true
}
// Compare segment-wise
tSegs := strings.Split(strings.Trim(t, "/"), "/")
pSegs := strings.Split(strings.Trim(p, "/"), "/")
if len(tSegs) != len(pSegs) {
return false
}
for i := range tSegs {
seg := tSegs[i]
if isParamSegment(seg) {
// wildcard for one segment
continue
}
if seg != pSegs[i] {
return false
}
}
return true
}
func isParamSegment(seg string) bool {
return strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}") && len(seg) > 2
}
func NewGlobalHtmxRoutesImpl() *GlobalHtmxRoutesImpl {
return &GlobalHtmxRoutesImpl{routes: make(map[string]*HtmxRouteInfo)}
}
func addLanguageToPath(path string, language Language) string {
return "/" + string(language) + path
}
// getCallerLocation returns the file location of the function that called NewCommonHtmxRoute
func getCallerLocation() string {
// Skip 2 frames: getCallerLocation itself and NewCommonHtmxRoute
_, file, line, ok := runtime.Caller(3)
if !ok {
return "unknown location"
}
return fmt.Sprintf("%s:%d", file, line)
}
type CommonHtmxRoute struct {
Paths LanguagePaths
RouteFunc HtmxRouteFunc
PathParams []HtmxPathParam
ActivePath *ActivePath
Method string
HtmxMethod HtmxHttpMethodFunction
createdLocation string
}
func (h CommonHtmxRoute) GetActivePath() *ActivePath {
return h.ActivePath
}
func (h CommonHtmxRoute) GetPaths() LanguagePaths {
return h.Paths
}
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)
if err != nil {
marshalError := fmt.Errorf("failed to marshal LocationRedirectParams: %w, %w", err, params)
Error(marshalError)
c.SetError(marshalError)
return
}
c.SetHeader("HX-Location", string(jsonData))
}
func NewCommonHtmxRoute(routeFunc HtmxRouteFunc, paths LanguagePaths, method string,
htmxMethod HtmxHttpMethodFunction) *CommonHtmxRoute {
route := &CommonHtmxRoute{RouteFunc: routeFunc, Paths: paths, Method: method, HtmxMethod: htmxMethod,
createdLocation: getCallerLocation()}
GlobalHtmxRoutesInstance.Add(route)
return route
}
func (h CommonHtmxRoute) Execute(c HtmxContext) {
h.traceLocation()
h.RouteFunc(c)
}
func (h CommonHtmxRoute) SetActivePath(activePath *ActivePath) HtmxRoute {
h.ActivePath = activePath
return h
}
var HtmxRouteDebugTrace bool = false
func (h CommonHtmxRoute) AddWithMethod(method string, server HtmxServer) {
for language, path := range h.Paths {
server.GetHttpServer().HandleFunc(method+" "+addLanguageToPath(path, language), func(res http.ResponseWriter, req *http.Request) {
h.traceLocation()
context := NewHtmxContext(req, res, language)
context.SetPathTemplate(addLanguageToPath(path, language))
context.SetAllRoutes(server.GetAllRoutes())
if h.ActivePath != nil {
context.SetActivePath(h.ActivePath)
}
h.RouteFunc(context)
})
}
}
func (h CommonHtmxRoute) traceLocation() {
if HtmxRouteDebugTrace {
Debug(h.createdLocation)
}
}
func (h CommonHtmxRoute) Add(server HtmxServer) {
server.Add(h)
h.AddWithMethod(h.Method, server)
}
func (h CommonHtmxRoute) WithPathParams(params ...HtmxPathParam) HtmxRoute {
h.PathParams = params
return h
}
func (h CommonHtmxRoute) ToUrl(language Language, queryParams ...HtmxPathParam) string {
path := addLanguageToPath(h.Paths[language], language)
if len(h.PathParams) == 0 {
return path
}
for _, param := range h.PathParams {
path = strings.ReplaceAll(path, "{"+param.Key+"}", param.Value)
}
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 {
return method(h.ToUrl(language, queryParams...))
}
func (h CommonHtmxRoute) GetHtmx(language Language, queryParams ...HtmxPathParam) gomponents.Node {
return h.GetHtmxFromMethod(h.HtmxMethod, language, queryParams...)
}
func NewHtmxGetRoute(routeFunc HtmxRouteFunc, paths LanguagePaths) HtmxRoute {
return NewCommonHtmxRoute(routeFunc, paths, "GET", htmx.Get)
}
func NewHtmxPostRoute(routeFunc HtmxRouteFunc, paths LanguagePaths) HtmxRoute {
return NewCommonHtmxRoute(routeFunc, paths, "POST", htmx.Post)
}
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")))
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)
return
}
if path == "/" {
context := NewHtmxContext(r, w, defaultLanguage)
context.SetActivePath(defaultRoute.GetActivePath())
defaultRoute.Execute(context)
return
}
http.Error(w, "Not found", http.StatusNotFound)
})
}