AzureAD\AdityaDevarshi
backend: skip logging client-canceled requests + rich dairy demo seed
25e051f
Raw
History Blame Contribute Delete
7.23 kB
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"strings"
"time"
"milkway/internal/cache"
"milkway/internal/config"
"milkway/internal/models"
"milkway/internal/ratelimit"
"milkway/internal/store"
)
// maxBodyBytes caps request body size to defend against oversized-payload DoS.
const maxBodyBytes = 1 << 20 // 1 MiB
// cacheMaxEntries bounds the in-memory read cache. Generous for the handful of
// org-scoped entities/rates/analytics rollups we memoize.
const cacheMaxEntries = 10000
// Server holds dependencies shared by all HTTP handlers.
type Server struct {
cfg *config.Config
store *store.Store
cache cache.Cache
// Rate limiters, per client IP. They are nil when their configured limit is
// 0 (disabled), and the middleware treats a nil limiter as a no-op so test
// traffic (which constructs config.Config{} with zero values) is never
// throttled. Both use a fixed 1-minute window (the config is "per minute").
rateLimiter ratelimit.Limiter // global cap across all /api/* requests
loginLimiter ratelimit.Limiter // stricter cap on POST /api/auth/login
}
func NewServer(cfg *config.Config, st *store.Store) *Server {
// The cache is constructed here (not injected) so NewServer's signature is
// unchanged and a Redis-backed cache.Cache can later replace NewMemory.
s := &Server{cfg: cfg, store: st, cache: cache.NewMemory(cacheMaxEntries)}
// Only build limiters when configured (>0). Leaving them nil keeps the
// middleware a no-op, which is exactly what the zero-value test config wants.
if cfg.RateLimitRPM > 0 {
s.rateLimiter = ratelimit.NewWindow(cfg.RateLimitRPM, time.Minute)
}
if cfg.LoginRateLimitRPM > 0 {
s.loginLimiter = ratelimit.NewWindow(cfg.LoginRateLimitRPM, time.Minute)
}
return s
}
// ---------- error envelope ----------
// Error codes returned in the error envelope. Each maps to an HTTP status.
const (
CodeValidation = "VALIDATION"
CodeUnauthenticated = "UNAUTHENTICATED"
CodeForbidden = "FORBIDDEN"
CodeNotFound = "NOT_FOUND"
CodeConflict = "CONFLICT"
CodeRateLimited = "RATE_LIMITED"
CodeInternal = "INTERNAL"
)
// apiError is the body of every error response, wrapped as {"error": apiError}.
// Field names a specific input that failed validation; Ref correlates a 500 to
// a server-side log entry and is only set for INTERNAL errors.
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Field string `json:"field,omitempty"`
Ref string `json:"ref,omitempty"`
}
// codeForStatus derives the error code from the HTTP status.
func codeForStatus(status int) string {
switch status {
case http.StatusBadRequest:
return CodeValidation
case http.StatusUnauthorized:
return CodeUnauthenticated
case http.StatusForbidden:
return CodeForbidden
case http.StatusNotFound:
return CodeNotFound
case http.StatusConflict:
return CodeConflict
case http.StatusTooManyRequests:
return CodeRateLimited
default:
return CodeInternal
}
}
// ---------- response helpers ----------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
}
}
// writeError emits the error envelope, deriving the code from the HTTP status.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]apiError{"error": {
Code: codeForStatus(status),
Message: msg,
}})
}
// writeFieldError emits the error envelope naming a specific input field.
func writeFieldError(w http.ResponseWriter, status int, code, message, field string) {
writeJSON(w, status, map[string]apiError{"error": {
Code: code,
Message: message,
Field: field,
}})
}
// internalError logs the real error server-side with a short correlation ref and
// returns a generic message to the client, so internal/DB details never leak.
func internalError(w http.ResponseWriter, err error) {
// A canceled request context means the client hung up before we finished —
// e.g. the browser (or TanStack Query) aborted an in-flight fetch on navigation.
// That is not a server fault: don't log it as one, and don't bother writing a
// 500 nobody will read. (Deadline-exceeded is a real timeout and still logged.)
if errors.Is(err, context.Canceled) {
return
}
buf := make([]byte, 4)
_, _ = rand.Read(buf)
ref := hex.EncodeToString(buf)
log.Printf("internal error [ref=%s]: %v", ref, err)
writeJSON(w, http.StatusInternalServerError, map[string]apiError{"error": {
Code: CodeInternal,
Message: "internal error",
Ref: ref,
}})
}
// handleStoreErr maps store errors to HTTP responses. Returns true if handled.
// ErrNotFound becomes a 404; any other error is logged server-side and surfaced
// to the client only as a generic message (no raw DB strings).
func handleStoreErr(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not found")
return true
}
internalError(w, err)
return true
}
// decodeJSON reads a size-capped JSON body into dst. On any failure it returns a
// generic message (parser internals are not leaked to the client).
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return false
}
return true
}
// pathID parses the {id} path value as int64.
func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return 0, false
}
return id, true
}
func queryInt(r *http.Request, key string, fallback int) int {
return config.MustAtoi(r.URL.Query().Get(key), fallback)
}
func queryInt64(r *http.Request, key string) int64 {
v, _ := strconv.ParseInt(r.URL.Query().Get(key), 10, 64)
return v
}
// pageParams parses the shared pagination/search query params for list
// endpoints: ?q= (trimmed text search, optional), ?limit= (default 50, clamped
// to 1..200) and ?offset= (default 0, floored at 0).
func pageParams(r *http.Request) (q string, limit, offset int) {
q = strings.TrimSpace(r.URL.Query().Get("q"))
limit = queryInt(r, "limit", 50)
if limit < 1 {
limit = 1
}
if limit > 200 {
limit = 200
}
offset = queryInt(r, "offset", 0)
if offset < 0 {
offset = 0
}
return q, limit, offset
}
// audit records a mutating action against the authenticated user/org. It is
// best-effort: a logging failure never affects the client response. entityID 0
// is recorded as an empty entity_id.
func (s *Server) audit(r *http.Request, action, entity string, entityID int64) {
c, ok := claimsFrom(r)
if !ok {
return
}
a := models.AuditLog{
OrgID: c.OrgID,
UserID: &c.UserID,
Action: action,
Entity: entity,
}
if entityID != 0 {
a.EntityID = strconv.FormatInt(entityID, 10)
}
_ = s.store.InsertAuditLog(r.Context(), a)
}