Seed from go-template-repo, renamed to simplexcalc
The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// errBadWidgetName is a rejected form value, not a fault. It exists
|
||||
// so that h.fail always has a non-nil error to log: a rejection
|
||||
// with no error recorded is a rejection nobody can explain later.
|
||||
errBadWidgetName = errors.New("widget name is empty or too long")
|
||||
|
||||
// errBadWidgetSize is a size field that is not a byte count.
|
||||
errBadWidgetSize = errors.New("widget size is not a byte count")
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// HealthResponse is the healthcheck body. It is a typed struct rather
|
||||
// than a map so that the shape is part of the code and a change to it
|
||||
// shows up in a diff: something is always parsing this.
|
||||
type HealthResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
App string `json:"app"`
|
||||
Version string `json:"version"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
DatabaseOK bool `json:"database_ok"`
|
||||
SentryEnabled bool `json:"sentry_enabled"`
|
||||
MetricsProtected bool `json:"metrics_protected"`
|
||||
}
|
||||
|
||||
// Healthcheck answers with the process's own view of whether it is
|
||||
// working. It touches the database on purpose: a health endpoint that
|
||||
// only proves the HTTP server is up will report healthy through the
|
||||
// entire outage that matters.
|
||||
//
|
||||
// A failure answers 503, not 200-with-ok-false. Everything that reads
|
||||
// this — a load balancer, a container runtime, a monitoring probe —
|
||||
// looks at the status code first, and several look at nothing else.
|
||||
func (h *Handlers) Healthcheck() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := HealthResponse{
|
||||
App: h.params.Globals.Appname,
|
||||
Version: h.params.Globals.Version,
|
||||
SentryEnabled: h.params.Sentry.Enabled(),
|
||||
MetricsProtected: h.params.Config.MetricsUser != "",
|
||||
}
|
||||
|
||||
versions, err := h.params.Database.AppliedVersions(r.Context())
|
||||
if err == nil {
|
||||
resp.DatabaseOK = true
|
||||
resp.OK = true
|
||||
|
||||
if len(versions) > 0 {
|
||||
resp.SchemaVersion = slices.Max(versions)
|
||||
}
|
||||
} else {
|
||||
h.log.Error("healthcheck: database unreachable", "error", err)
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
if !resp.OK {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
// A cached healthcheck is a healthcheck that reports the past.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
|
||||
encodeErr := json.NewEncoder(w).Encode(resp)
|
||||
if encodeErr != nil {
|
||||
// The status and headers are already sent, so there is
|
||||
// nothing to answer with; the log is the only record left.
|
||||
h.log.Error("healthcheck: encoding response failed", "error", encodeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Panic is a route that panics, mounted only when DEBUG is on. It is
|
||||
// how the panic recoverer is exercised by hand in a running process;
|
||||
// the automated proof is in the middleware tests.
|
||||
func (h *Handlers) Panic() http.HandlerFunc {
|
||||
return func(_ http.ResponseWriter, _ *http.Request) {
|
||||
panic("deliberate panic from the debug route")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"sneak.berlin/go/simplexcalc/internal/render"
|
||||
)
|
||||
|
||||
// widgetListLimit bounds the index query. An unbounded SELECT is fine
|
||||
// on the day it is written and is the outage two years later.
|
||||
const widgetListLimit = 50
|
||||
|
||||
// maxWidgetNameLen matches the maxlength on the form input. The form is
|
||||
// a courtesy; this is the rule.
|
||||
const maxWidgetNameLen = 200
|
||||
|
||||
// Index renders the front page from the embedded template, listing the
|
||||
// most recent widgets.
|
||||
func (h *Handlers) Index() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
widgets, err := h.params.Database.ListWidgets(ctx, widgetListLimit)
|
||||
if err != nil {
|
||||
h.fail(w, r, http.StatusInternalServerError, "Could not load widgets.", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.params.Database.CountWidgets(ctx)
|
||||
if err != nil {
|
||||
h.fail(w, r, http.StatusInternalServerError, "Could not count widgets.", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
data := render.IndexPage{
|
||||
Page: h.page(r),
|
||||
WidgetCount: count,
|
||||
Widgets: widgets,
|
||||
}
|
||||
|
||||
err = h.params.Renderer.HTML(w, http.StatusOK, "index.html", data)
|
||||
if err != nil {
|
||||
h.fail(w, r, http.StatusInternalServerError, "Could not render the page.", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWidget handles the form POST. State-changing, so it is behind
|
||||
// CSRF; see internal/server/routes.go for where that is applied.
|
||||
func (h *Handlers) CreateWidget() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// ParseForm reads the body, which BodyLimit has already capped:
|
||||
// an oversized submission fails here rather than being buffered
|
||||
// in full first.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
h.fail(w, r, http.StatusBadRequest, "Could not read the form.", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.PostFormValue("name"))
|
||||
if name == "" || len(name) > maxWidgetNameLen {
|
||||
h.fail(w, r, http.StatusBadRequest,
|
||||
"A widget needs a name of 1 to 200 characters.", errBadWidgetName)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
size, err := parseSize(r.PostFormValue("size"))
|
||||
if err != nil {
|
||||
h.fail(w, r, http.StatusBadRequest,
|
||||
"Size must be a byte count, like 4096 or 4KiB.", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.params.Database.CreateWidget(r.Context(), name, size)
|
||||
if err != nil {
|
||||
h.fail(w, r, http.StatusInternalServerError, "Could not save the widget.", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// POST/redirect/GET: a reload must not repeat the write.
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// parseSize accepts an empty value as zero and anything else as a
|
||||
// human-readable byte size.
|
||||
func parseSize(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
n, err := humanize.ParseBytes(s)
|
||||
if err != nil {
|
||||
return 0, errBadWidgetSize
|
||||
}
|
||||
|
||||
// A size beyond this is not a widget, it is a typo with a suffix.
|
||||
const maxWidgetSize = uint64(1) << 50
|
||||
if n > maxWidgetSize {
|
||||
return 0, errBadWidgetSize
|
||||
}
|
||||
|
||||
return int64(n), nil
|
||||
}
|
||||
Reference in New Issue
Block a user