Backend Phase A: foundation, migrations, Docker setup
- Go backend skeleton: config (caarlos0/env), slog JSON logging, pgxpool store, HTTP server with graceful shutdown. - httpapi: render helpers, Problem errors, middleware chain (requestID / logging / recover / CORS), /healthz and /readyz. - Migrations: full initial schema (users, sessions, lists, list_members, items, op_log SOURCE OF TRUTH, item_names) + golang-migrate runner binary using source/iofs (embedded). - Docker: multi-stage Dockerfile (Go 1.26 -> distroless nonroot), builds both server and migrate binaries. - deploy: docker-compose (caddy + backend + migrate + postgres:16), Caddyfile (auto-HTTPS), .env.example, pg extensions init script. - AGENTS.md: project context + roadmap for AI agents. Verified: image builds, both binaries run in container (smoke test).
This commit is contained in:
commit
2899eb205b
23 changed files with 1372 additions and 0 deletions
78
backend/internal/httpapi/render.go
Normal file
78
backend/internal/httpapi/render.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Package httpapi contains HTTP handlers, routing and middleware.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Problem is a small RFC 7807-ish error body.
|
||||
type Problem struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// renderJSON writes v as JSON with the given status code.
|
||||
func renderJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("write json response failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// renderError writes a Problem response.
|
||||
func renderError(w http.ResponseWriter, status int, title, detail string) {
|
||||
renderJSON(w, status, Problem{
|
||||
Title: title,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// apiError maps well-known errors to HTTP status codes. Returns true if handled.
|
||||
func apiError(w http.ResponseWriter, err error) bool {
|
||||
switch {
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
renderError(w, http.StatusNotFound, "Not found", "The requested resource does not exist.")
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
renderError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
|
||||
case errors.Is(err, ErrForbidden):
|
||||
renderError(w, http.StatusForbidden, "Forbidden", err.Error())
|
||||
case errors.Is(err, ErrConflict):
|
||||
renderError(w, http.StatusConflict, "Conflict", err.Error())
|
||||
case errors.Is(err, ErrBadRequest):
|
||||
renderError(w, http.StatusBadRequest, "Bad request", err.Error())
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Sentinel domain errors mapped by apiError.
|
||||
var (
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
|
||||
// decodeJSON decodes r.Body into v. Returns false and writes an error on failure.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
renderError(w, http.StatusBadRequest, "Invalid JSON", err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue