~repos /gromer
git clone
https://pyrossh.dev/repos/gromer.git
gromer is a framework and cli to build multipage web apps in golang using htmx and alpinejs.
file:
http.go
package gromer
import ( "bytes" "context" "crypto/md5" "embed" "encoding/json" "fmt" "net" "net/http" "net/url" "os" "reflect" "regexp" "runtime/debug" "strconv" "strings" "sync" "time"
"github.com/felixge/httpsnoop" "github.com/go-playground/validator/v10" "github.com/google/uuid" "github.com/gorilla/mux" "github.com/pyros2097/gromer/assets" "github.com/pyros2097/gromer/gsx" "github.com/rotisserie/eris" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/segmentio/go-camelcase" "gocloud.dev/server" "xojoc.pw/useragent")
var ( info *debug.BuildInfo IsCloundRun bool pathParamsRegex = regexp.MustCompile(`{(.*?)}`) globalStatusComponent StatusComponent = nil baseRouter = &mux.Router{} pageRouter = &mux.Router{})
type StatusComponent func(c *gsx.Context, status int, err error) []*gsx.Tag
type File struct { Name string ContentType string Data *bytes.Buffer}
func init() { IsCloundRun = os.Getenv("K_REVISION") != "" info, _ = debug.ReadBuildInfo() zerolog.ErrorStackMarshaler = func(err error) interface{} { return eris.ToJSON(err, true) } if IsCloundRun { zerolog.LevelFieldName = "severity" zerolog.TimestampFieldName = "timestamp" zerolog.TimeFieldFormat = time.RFC3339Nano } else { zerolog.SetGlobalLevel(zerolog.DebugLevel) log.Logger = log.Output(zerolog.ConsoleWriter{ Out: os.Stdout, NoColor: IsCloundRun, PartsExclude: []string{zerolog.TimestampFieldName}, }) } gsx.RegisterFunc(GetAssetUrl)}
func RespondError(w http.ResponseWriter, r *http.Request, status int, err error) { if r.Header.Get("Content-Type") == "application/json" { validationErrors, ok := eris.Cause(err).(validator.ValidationErrors) errorMap := gsx.M{ "error": err.Error(), } if ok { errorMap["error"] = GetValidationError(validationErrors) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) data, _ := json.Marshal(errorMap) w.Write(data) return } w.Header().Set("Content-Type", "text/html") w.WriteHeader(status) // always write status last if status >= 500 { formattedStr := eris.ToCustomString(err, eris.StringFormat{ Options: eris.FormatOptions{ WithTrace: true, InvertOutput: true, InvertTrace: true, }, MsgStackSep: "\n", PreStackSep: "\t", StackElemSep: " | ", ErrorSep: "\n", }) log.Error().Msg(err.Error() + "\n" + formattedStr) } c := createCtx(r, "Status") c.Set("funcName", "error") c.Set("error", err.Error()) if r.Header.Get("HX-Request") == "true" || globalStatusComponent == nil { tags := c.Render(` <div style="color: red;"> <h1>{error}</h1> </div> `) gsx.Write(c, w, tags) return } tags := globalStatusComponent(c, status, err) gsx.Write(c, w, tags)}
func PerformRequest(route string, h interface{}, c interface{}, w http.ResponseWriter, r *http.Request, isJson bool) { params := []string{} found := pathParamsRegex.FindAllString(route, -1) for _, v := range found { params = append(params, strings.Replace(strings.Replace(v, "}", "", 1), "{", "", 1)) } args := []reflect.Value{reflect.ValueOf(c)} funcType := reflect.TypeOf(h) icount := funcType.NumIn() vars := mux.Vars(r) for _, k := range params { args = append(args, reflect.ValueOf(vars[k])) } if len(args) != icount { structType := funcType.In(icount - 1) instance := reflect.New(structType) if structType.Kind() != reflect.Struct { log.Fatal().Msgf("router '%s' func final param should be a struct", route) } method := r.Method contentType := r.Header.Get("Content-Type") if method == "GET" || ((method == "POST" || method == "PUT" || method == "PATCH") && contentType == "application/x-www-form-urlencoded") { err := r.ParseForm() if err != nil { RespondError(w, r, 400, err) return } rv := instance.Elem() for i := 0; i < structType.NumField(); i++ { if f := rv.Field(i); f.CanSet() { jsonName := structType.Field(i).Tag.Get("json") jsonValue := "" if method == "GET" { jsonValue = r.URL.Query().Get(jsonName) } else { jsonValue = r.Form.Get(jsonName) } if f.Kind() == reflect.String { f.SetString(jsonValue) } else if f.Kind() == reflect.Int64 || f.Kind() == reflect.Int32 || f.Kind() == reflect.Int { base := 64 if f.Kind() == reflect.Int32 { base = 32 } if jsonValue == "" { f.SetInt(0) } else { v, err := strconv.ParseInt(jsonValue, 10, base) if err != nil { RespondError(w, r, 400, err) return } f.SetInt(v) } } else if f.Kind() == reflect.Struct && f.Type().Name() == "Time" { if jsonValue == "" { f.Set(reflect.ValueOf(time.Time{})) } else { v, err := time.Parse(time.RFC3339, jsonValue) if err != nil { RespondError(w, r, 400, err) return } f.Set(reflect.ValueOf(v)) } } else { log.Fatal().Msgf("Uknown form param: '%s' '%s'", jsonName, jsonValue) } } } } else if (method == "POST" || method == "PUT" || method == "PATCH") && contentType == "application/json" { err := json.NewDecoder(r.Body).Decode(instance.Interface()) if err != nil { RespondError(w, r, 400, err) return } } else { RespondError(w, r, 400, eris.Errorf("Illegal Content-Type found %s", contentType)) return } if !isJson { c.(*gsx.Context).Set("params", instance.Elem().Interface()) } args = append(args, instance.Elem()) } values := reflect.ValueOf(h).Call(args) response := values[0].Interface() responseStatus := values[1].Interface().(int) responseError := values[2].Interface() if responseError != nil { RespondError(w, r, responseStatus, eris.Wrap(responseError.(error), "Render failed")) return } if file, ok := response.(*File); ok { w.Header().Set("Content-Type", file.ContentType) w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Data.Len())) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Name)) w.Write(file.Data.Bytes()) return } if isJson { w.Header().Set("Content-Type", "application/json") w.WriteHeader(responseStatus) data, err := json.Marshal(response) if err != nil { RespondError(w, r, responseStatus, eris.Wrap(responseError.(error), "Json Marshal failed")) return } w.Write(data) return } w.Header().Set("Content-Type", "text/html") // This has to be at end always w.WriteHeader(responseStatus) if responseStatus != 204 { gsx.Write(c.(*gsx.Context), w, response.([]*gsx.Tag)) }}
func LogMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { url := r.URL.Path if r.URL.RawQuery != "" { url += "?" + r.URL.RawQuery } ip, _, _ := net.SplitHostPort(r.RemoteAddr) if len(ip) > 0 && ip[0] == '[' { ip = ip[1 : len(ip)-1] } ua := useragent.Parse(r.UserAgent()).Name defer func() { if err := recover(); err != nil { log.Error().Msgf("%s 599 %s %s", r.Method, ua, url) RespondError(w, r, 599, eris.Errorf("%+v", err)) } }() m := httpsnoop.CaptureMetrics(next, w, r) log.Info().Msgf("%s %d %.2fkb %s %s %s", r.Method, m.Code, float64(m.Written)/1024.0, m.Duration.Round(time.Millisecond).String(), ua, url, ) })}
func CacheMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=2592000") // perma cache for 1 month next.ServeHTTP(w, r) })}
func StaticRoute(router *mux.Router, path string, fs embed.FS) { router.PathPrefix(path).Methods("GET").Handler(http.StripPrefix(path, http.FileServer(http.FS(fs))))}
func IconsRoute(router *mux.Router, path string, fs embed.FS) { router.PathPrefix(path).Methods("GET").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { RespondError(w, r, 400, err) return } data, err := fs.ReadFile(strings.TrimPrefix(r.URL.Path, "/")) if err != nil { RespondError(w, r, 404, err) return } fill := r.Form.Get("fill") color := gsx.GetColor(fill) svg := strings.ReplaceAll(string(data), "<svg", fmt.Sprintf(`<svg fill="%s" `, color)) w.Header().Set("Content-Type", "image/svg+xml") w.WriteHeader(200) w.Write([]byte(svg)) })}
func ComponentStylesRoute(router *mux.Router, route string) { router.Path(route).Methods("GET").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") w.WriteHeader(200) w.Write([]byte(gsx.GetComponentStyles())) })}
func createCtx(r *http.Request, route string) *gsx.Context { newCtx := context.WithValue(context.WithValue(r.Context(), "url", r.URL), "header", r.Header) var hx *gsx.HX if r.Header.Get("HX-Request") == "true" { hx = &gsx.HX{ Boosted: r.Header.Get("HX-Boosted") == "true", CurrentUrl: r.Header.Get("HX-Current-URL"), Prompt: r.Header.Get("HX-Prompt"), Target: r.Header.Get("HX-Target"), TriggerName: r.Header.Get("HX-Trigger-Name"), TriggerID: r.Header.Get("HX-Trigger"), } } c := gsx.NewContext(newCtx, hx) c.Set("funcName", camelcase.Camelcase(route)) c.Set("requestId", uuid.NewString()) c.Link("stylesheet", "/gromer/css/normalize@3.0.0.css", "", "") c.Link("stylesheet", GetComponentsStylesUrl(), "", "") c.Link("icon", "/assets/favicon.ico", "image/x-icon", "image") c.Script("/gromer/js/htmx@1.7.0.js", false) c.Script("/gromer/js/hyperscript@0.9.6.js", false) // c.Script("/gromer/js/alpinejs@3.9.6.js", true) return c}
func RegisterStatusHandler(router *mux.Router, comp StatusComponent) { globalStatusComponent = comp router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c := createCtx(r, "Status") tags := comp(c, 404, nil) w.Header().Set("Content-Type", "text/html") w.WriteHeader(404) gsx.Write(c, w, tags) })}
func PageRoute(route string, page, action interface{}) { pageRouter.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) { c := createCtx(r, route) if r.Method == "GET" { PerformRequest(route, page, c, w, r, false) } else { PerformRequest(route, action, c, w, r, false) } }).Methods("GET", "POST")}
func GetUrl(ctx context.Context) *url.URL { return ctx.Value("url").(*url.URL)}
func GetHeader(ctx context.Context) http.Header { return ctx.Value("header").(http.Header)}
var sumCache = sync.Map{}
func getSum(k string, cb func() [16]byte) string { if v, ok := sumCache.Load(k); ok { return v.(string) } sum := fmt.Sprintf("%x", cb()) sumCache.Store(k, sum) return sum}
func GetAssetUrl(fs embed.FS, path string) string { sum := getSum(path, func() [16]byte { data, err := fs.ReadFile(path) if err != nil { panic(err) } return md5.Sum(data) }) return fmt.Sprintf("/assets/%s?hash=%s", path, sum)}
func GetComponentsStylesUrl() string { sum := getSum("components.css", func() [16]byte { return md5.Sum([]byte(gsx.GetComponentStyles())) }) return fmt.Sprintf("/components.css?hash=%s", sum)}
func Init(status StatusComponent, appAssets embed.FS) { baseRouter = mux.NewRouter() baseRouter.Use(LogMiddleware) RegisterStatusHandler(baseRouter, status)
staticRouter := baseRouter.NewRoute().Subrouter() staticRouter.Use(CacheMiddleware) StaticRoute(staticRouter, "/gromer/", assets.FS) StaticRoute(staticRouter, "/assets/", appAssets) IconsRoute(staticRouter, "/icons/", appAssets) ComponentStylesRoute(staticRouter, "/components.css") pageRouter = baseRouter.NewRoute().Subrouter()}
func GetRouter() *mux.Router { return baseRouter}
func Run(port string) { log.Info().Msg("http server listening on http://localhost:" + port) srv := server.New(baseRouter, nil) if err := srv.ListenAndServe(":" + port); err != nil { log.Fatal().Stack().Err(err).Msg("failed to listen") }}