feat: scaffold app skeleton with config, ULID, health endpoints, compose stack (phase 1)

- internal .env loader (real env wins) + strictly validated config struct
- ULID package: 48-bit ms timestamp + 80-bit crypto randomness, sortable
- HTTP server with recovery/request-id/trusted-proxy/access-log middleware
- /healthz, /readyz (pluggable checkers), /metricsz (counter registry)
- multi-stage Dockerfile (alpine, non-root) + compose with healthchecks,
  app bound to 127.0.0.1:8787, mongo unpublished, graceful 15s shutdown

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 18:10:30 +02:00
commit 976f5238f8
26 changed files with 2478 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
package httpx
import (
"context"
"fmt"
"net/http"
"net/netip"
"runtime/debug"
"time"
"bountyboard/internal/ulid"
)
type ctxKey int
const (
ctxKeyRequestID ctxKey = iota
ctxKeyClient
)
// RequestID returns the id assigned to this request by the middleware chain.
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(ctxKeyRequestID).(string)
return id
}
// Client returns the trusted-proxy-resolved client info for this request.
func Client(ctx context.Context) clientInfo {
c, _ := ctx.Value(ctxKeyClient).(clientInfo)
return c
}
// ClientIP is a convenience accessor used by rate limiting and audit logging.
func ClientIP(ctx context.Context) netip.Addr { return Client(ctx).IP }
// statusRecorder captures the response status for logging and metrics while
// staying compatible with http.ResponseController (Unwrap).
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
if r.status == 0 {
r.status = code
}
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
return r.ResponseWriter.Write(b)
}
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
func (s *Server) withMiddleware(next http.Handler) http.Handler {
return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.accessLog(next))))
}
// recoverPanic guarantees no panic escapes a request path: it logs the stack
// and returns the standard JSON error envelope.
func (s *Server) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
if rec == http.ErrAbortHandler { // client went away mid-write
panic(rec)
}
s.log.Error("panic in request handler",
"err", fmt.Sprint(rec),
"requestId", RequestID(r.Context()),
"path", r.URL.Path,
"stack", string(debug.Stack()))
s.metrics.Inc("http_panics_total", 1)
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) assignRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := ulid.New()
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id)))
})
}
func (s *Server) resolveClientInfo(next http.Handler) http.Handler {
defaultProto := "http"
if r := s.cfg.AppBaseURL; len(r) >= 8 && r[:8] == "https://" {
defaultProto = "https"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info := resolveClient(r, s.cfg.TrustedProxyCIDRs, defaultProto)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyClient, info)))
})
}
func (s *Server) accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
if rec.status == 0 {
rec.status = http.StatusOK
}
s.metrics.Inc("http_requests_total", 1)
s.metrics.Inc(fmt.Sprintf("http_responses_%dxx_total", rec.status/100), 1)
s.log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"durationMs", time.Since(start).Milliseconds(),
"clientIp", Client(r.Context()).IP.String(),
"requestId", RequestID(r.Context()))
})
}