diff --git a/examples/full/cmd/server.go b/examples/full/cmd/server.go index b3f7e1d..1076af7 100644 --- a/examples/full/cmd/server.go +++ b/examples/full/cmd/server.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "fmt" "io/fs" "net/http" "time" @@ -25,11 +26,6 @@ type Server struct { } func (s *Server) Run(ctxRoot context.Context) error { - // Prepare some interfaces to later use. - html := webutil.NewHTMLTemplateView(s.TemplateFS, - webutil.SimpleTemplateFuncMap("prettyTime", PrettyTimeTemplateFunction), - ) - // Creating a new context, so we can have two stages for the graceful // shutdown. First is to make pod unready (within the admin api) and the // seconds is all the rest. @@ -49,20 +45,28 @@ func (s *Server) Run(ctxRoot context.Context) error { webutil.AdminAPIListenAndServe(ctxRoot, group, cancel) // Other background processes use the main context. - s.setupHTTPServer(ctx, group, html) + s.setupHTTPServer(ctx, group) return errors.WithStack(group.Wait()) } -func (s *Server) setupHTTPServer(ctx context.Context, group *errgroup.Group, html *webutil.HTMLTemplateView) { +func (s *Server) setupHTTPServer(ctx context.Context, group *errgroup.Group) { // It is a good practice to init a new context logger for a new background // process, so we can see what triggered a specific log message later. ctx = logutil.Start(ctx, "http-server") + // Prepare some interfaces to later use. + vh := webutil.NewViewHandler(s.TemplateFS, + webutil.SimpleTemplateFuncMap("prettyTime", PrettyTimeTemplateFunction), + ) + router := chi.NewRouter() router.Use(middleware.Logger) - router.Get("/", webutil.Presenter(s.indexModel, html.View("index.html"))) + router.Get("/", vh.Wrap(s.handleIndex)) + router.Get("/json", vh.Wrap(s.handleJSON)) + router.Get("/redirect", vh.Wrap(s.handleRedirect)) + router.Get("/error", vh.Wrap(s.handleError)) router.Mount("/assets", http.StripPrefix("/assets", http.FileServer(http.FS(s.AssetFS)))) group.Go(func() error { @@ -72,9 +76,24 @@ func (s *Server) setupHTTPServer(ctx context.Context, group *errgroup.Group, htm }) } -func (s *Server) indexModel(r *http.Request) (interface{}, int, error) { - InstIndexRequest(r.Context(), r) +func (s *Server) timeModel() any { return map[string]interface{}{ "now": time.Now(), - }, http.StatusOK, nil + } +} + +func (s *Server) handleIndex(v *webutil.View, r *http.Request) webutil.Response { + return v.HTML(http.StatusOK, "index.html", s.timeModel()) +} + +func (s *Server) handleJSON(v *webutil.View, r *http.Request) webutil.Response { + return v.JSON(http.StatusOK, s.timeModel()) +} + +func (s *Server) handleRedirect(v *webutil.View, r *http.Request) webutil.Response { + return v.Redirect(http.StatusTemporaryRedirect, "/") +} + +func (s *Server) handleError(v *webutil.View, r *http.Request) webutil.Response { + return v.Error(http.StatusBadRequest, fmt.Errorf("oh no")) } diff --git a/examples/full/cmd/templates/index.html b/examples/full/cmd/templates/index.html index 9c562b3..d832dc4 100644 --- a/examples/full/cmd/templates/index.html +++ b/examples/full/cmd/templates/index.html @@ -8,6 +8,13 @@ -

Hello.

+

Hello @ {{ prettyTime .now }}

+ + + diff --git a/pkg/webutil/mvp.go b/pkg/webutil/mvp.go deleted file mode 100644 index 59f4312..0000000 --- a/pkg/webutil/mvp.go +++ /dev/null @@ -1,157 +0,0 @@ -package webutil - -import ( - "bytes" - "encoding/json" - "fmt" - "html/template" - "io/fs" - "net/http" - - "github.com/pkg/errors" - "github.com/sirupsen/logrus" -) - -// Model should be used by the Presenter and its purpose is to provide an -// interface for data generation that is used by templates. This has the -// advantage that we can reuse models for multiple views (eg JSON and HTML) and -// that the data generation is isolated from representation. -type Model func(*http.Request) (interface{}, int, error) - -// View should be used by with the Presenter and its puropose is to avoid -// having to implement the Golang template rendering for the gazillionth time. -// This package contains some ready-to-use views. -type View func(http.ResponseWriter, *http.Request, interface{}, int, error) - -// Presenter (from Model-view-presenter [1]) acts as a middleman between Model -// and View. -// [1]: https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93presenter -func Presenter(m Model, v View) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - data, code, err := m(r) - if err != nil { - logrus. - WithField("stacktrace", fmt.Sprintf("%+v", err)). - WithError(errors.WithStack(err)). - Errorf("response failed: %s", err) - } - - switch code { - case http.StatusMovedPermanently: - fallthrough - case http.StatusFound: - fallthrough - case http.StatusSeeOther: - fallthrough - case http.StatusTemporaryRedirect: - fallthrough - case http.StatusPermanentRedirect: - url := data.(string) - http.Redirect(w, r, url, code) - return - } - - v(w, r, data, code, err) - } -} - -// NilModel is a Model that contains no data. Useful for rendering templates -// that do not need any data. -func NilModel(*http.Request) (interface{}, int, error) { - return nil, http.StatusOK, nil -} - -type TemplateFuncMap func(*http.Request) template.FuncMap - -func SimpleTemplateFuncMap(name string, fn interface{}) TemplateFuncMap { - return func(_ *http.Request) template.FuncMap { - return template.FuncMap{ - name: fn, - } - } -} - -func SimpleTemplateFuncMaps(fm template.FuncMap) TemplateFuncMap { - return func(_ *http.Request) template.FuncMap { - return fm - } -} - -// HTMLTemplateView provides a View that renders the Model with html/template. -type HTMLTemplateView struct { - FS fs.FS - FuncMaps []TemplateFuncMap -} - -func NewHTMLTemplateView(fs fs.FS, fms ...TemplateFuncMap) *HTMLTemplateView { - v := &HTMLTemplateView{ - FS: fs, - FuncMaps: fms, - } - - return v -} - -func (v *HTMLTemplateView) Render(filename string, r *http.Request, d interface{}) (*bytes.Buffer, error) { - t := template.New(filename) - - for _, fm := range v.FuncMaps { - t = t.Funcs(fm(r)) - } - - t, err := t.ParseFS(v.FS, "*") - if err != nil { - return nil, errors.Wrap(err, "parsing template failed") - } - - buf := new(bytes.Buffer) - err = t.Execute(buf, d) - - return buf, errors.Wrap(err, "executing template failed") -} - -// View returns a View that can be used by a Presenter. -// -// Usage: -// html := &HTMLTemplateView{ FS: server.TemplateFS } -// router.GET("/", Presenter(server.indexModel, html.View("index.html"))) -func (v *HTMLTemplateView) View(filename string) View { - return func(w http.ResponseWriter, r *http.Request, d interface{}, s int, err error) { - if err != nil { - w.WriteHeader(s) - fmt.Fprint(w, err) - return - } - - buf, err := v.Render(filename, r, d) - if err != nil { - logrus.WithError(errors.WithStack(err)).Errorf("rendering template failed") - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.WriteHeader(s) - w.Header().Set("Content-Type", "text/html") - buf.WriteTo(w) - } -} - -// JSONView is a View that renders the Model as JSON. -func JSONView(w http.ResponseWriter, r *http.Request, d interface{}, s int, err error) { - if err != nil { - d = err - } - - buf := new(bytes.Buffer) - enc := json.NewEncoder(buf) - enc.SetIndent("", " ") - err = enc.Encode(d) - if err != nil { - return - } - - w.WriteHeader(s) - w.Header().Set("Content-Type", "application/json") - buf.WriteTo(w) - -} diff --git a/pkg/webutil/view.go b/pkg/webutil/view.go new file mode 100644 index 0000000..fe2ae86 --- /dev/null +++ b/pkg/webutil/view.go @@ -0,0 +1,130 @@ +package webutil + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "io/fs" + "net/http" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +type ViewHandler struct { + FS fs.FS + FuncMaps []TemplateFuncMap +} + +func NewViewHandler(fs fs.FS, fms ...TemplateFuncMap) *ViewHandler { + v := &ViewHandler{ + FS: fs, + FuncMaps: fms, + } + + return v +} + +type ResponseHandlerFunc func(*View, *http.Request) Response + +func (h *ViewHandler) Wrap(fn ResponseHandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fn(&View{handler: h}, r)(w, r) + } +} + +func (h *ViewHandler) Render(filename string, r *http.Request, d interface{}) (*bytes.Buffer, error) { + t := template.New(filename) + + for _, fm := range h.FuncMaps { + t = t.Funcs(fm(r)) + } + + t, err := t.ParseFS(h.FS, "*") + if err != nil { + return nil, errors.Wrap(err, "parsing template failed") + } + + buf := new(bytes.Buffer) + err = t.Execute(buf, d) + + return buf, errors.Wrap(err, "executing template failed") +} + +type TemplateFuncMap func(*http.Request) template.FuncMap + +func SimpleTemplateFuncMap(name string, fn interface{}) TemplateFuncMap { + return func(_ *http.Request) template.FuncMap { + return template.FuncMap{ + name: fn, + } + } +} + +func SimpleTemplateFuncMaps(fm template.FuncMap) TemplateFuncMap { + return func(_ *http.Request) template.FuncMap { + return fm + } +} + +type Response = http.HandlerFunc + +type View struct { + handler *ViewHandler +} + +func (v *View) Error(status int, err error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + logrus. + WithField("stacktrace", fmt.Sprintf("%+v", err)). + WithError(errors.WithStack(err)). + Errorf("request failed: %s", err) + + w.WriteHeader(status) + fmt.Fprint(w, err.Error()) + } +} + +func (v *View) Errorf(status int, text string, a ...interface{}) http.HandlerFunc { + return v.Error(status, fmt.Errorf(text, a...)) +} + +func (v *View) Redirect(status int, location string, args ...interface{}) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + url := fmt.Sprintf(location, args...) + http.Redirect(w, r, url, status) + } +} + +func (v *View) JSON(status int, data any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + buf := new(bytes.Buffer) + enc := json.NewEncoder(buf) + enc.SetIndent("", " ") + + err := enc.Encode(data) + if err != nil { + v.Error(http.StatusInternalServerError, err)(w, r) + return + } + + w.WriteHeader(status) + w.Header().Set("Content-Type", "application/json") + buf.WriteTo(w) + } +} + +func (v *View) HTML(status int, filename string, data any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + buf, err := v.handler.Render(filename, r, data) + if err != nil { + v.Error(http.StatusInternalServerError, err)(w, r) + return + } + + w.WriteHeader(status) + w.Header().Set("Content-Type", "text/html") + buf.WriteTo(w) + } +}