// Package handlers holds the HTTP handlers. They are methods on one // struct whose dependencies come from fx, so a handler never reaches // for a package-level singleton and a test can build the struct with // exactly the collaborators it wants. package handlers import ( "log/slog" "net/http" "go.uber.org/fx" "sneak.berlin/go/simplexcalc/internal/config" "sneak.berlin/go/simplexcalc/internal/database" "sneak.berlin/go/simplexcalc/internal/globals" "sneak.berlin/go/simplexcalc/internal/logger" "sneak.berlin/go/simplexcalc/internal/middleware" "sneak.berlin/go/simplexcalc/internal/render" "sneak.berlin/go/simplexcalc/internal/telemetry" ) // Params defines dependencies for Handlers. type Params struct { fx.In Config *config.Config Globals *globals.Globals Logger *logger.Logger Database *database.Database Renderer *render.Renderer Sentry *telemetry.Sentry } // Handlers is the set of HTTP handlers. type Handlers struct { params Params log *slog.Logger } // New creates the handler set. // //nolint:revive // lc parameter is required by fx even if unused. func New(lc fx.Lifecycle, params Params) (*Handlers, error) { return &Handlers{params: params, log: params.Logger.Get()}, nil } // NotFound answers unmatched routes with the error page rather than // net/http's bare text, so a 404 still carries the site's own headers // and chrome. func (h *Handlers) NotFound() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { data := render.ErrorPage{ Page: h.page(r), Status: http.StatusNotFound, Message: "No such page.", } err := h.params.Renderer.HTML(w, http.StatusNotFound, "error.html", data) if err != nil { h.log.Error("rendering 404 failed", "error", err) http.Error(w, "not found", http.StatusNotFound) } } } // MethodNotAllowed answers a known path with the wrong method. func (h *Handlers) MethodNotAllowed() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { data := render.ErrorPage{ Page: h.page(r), Status: http.StatusMethodNotAllowed, Message: "That method is not allowed here.", } err := h.params.Renderer.HTML(w, http.StatusMethodNotAllowed, "error.html", data) if err != nil { h.log.Error("rendering 405 failed", "error", err) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } } // page builds the common template data for r. func (h *Handlers) page(r *http.Request) render.Page { return h.params.Renderer.NewPage(middleware.CSRFField(r)) } // fail reports a handler error and answers with the error page. // // The message shown to the client is chosen by the caller and is never // the error's text: an error from the database layer carries a query, // possibly a value out of a row, and always more about the internals // than a stranger should be given. The error itself goes to the log and // to Sentry, tied to the request id that is also in the response // header, so the two can be joined afterwards. func (h *Handlers) fail( w http.ResponseWriter, r *http.Request, status int, message string, err error, ) { h.log.Error("handler error", "id", middleware.RequestIDFrom(r.Context()), "path", r.URL.Path, "status", status, "error", err, ) if status >= http.StatusInternalServerError { h.params.Sentry.CaptureError(err) } data := render.ErrorPage{ Page: h.page(r), Status: status, Message: message, } renderErr := h.params.Renderer.HTML(w, status, "error.html", data) if renderErr != nil { // The error page itself failed. Anything further would be // another chance to fail, so this is the floor: a plain // status, and the reason in the log. h.log.Error("rendering error page failed", "error", renderErr) http.Error(w, http.StatusText(status), status) } }