- remove the events/outages module, ArcGIS adapter, portal config and event statuses - add a configurable city dictionary/parser and make City part of StreetKey - seed: read city/city_name, namespace IDs by city, keep structures distinct - fix reliability: schema v2 with fail-closed load, deterministic ordering, input limits, config validation, atomic DB publish, LLM response caps - add read-only shadow mode comparing the old ES path with addrkit - breaking: registry.db must be re-seeded; config has no portal section
124 lines
3.4 KiB
Go
124 lines
3.4 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
type ctxKey int
|
|
|
|
const requestIDKey ctxKey = 0
|
|
|
|
// requestIDMW assigns a request id (for correlation in logs) and echoes it.
|
|
// A client-supplied id is accepted only when it is short and well-formed; the
|
|
// header ends up in logs, so accepting arbitrary bytes would be an injection
|
|
// and log-flooding vector.
|
|
func requestIDMW(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.Header.Get("X-Request-Id")
|
|
if !validRequestID(id) {
|
|
id = newRequestID()
|
|
}
|
|
w.Header().Set("X-Request-Id", id)
|
|
ctx := context.WithValue(r.Context(), requestIDKey, id)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
const maxRequestIDLen = 128
|
|
|
|
func validRequestID(id string) bool {
|
|
if id == "" || len(id) > maxRequestIDLen {
|
|
return false
|
|
}
|
|
for _, r := range id {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
|
case r == '-', r == '_', r == '.':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// loggingMW logs one line per request with method, path, status and duration.
|
|
// It records no addresses (PII) — only routing metadata.
|
|
func loggingMW(log *slog.Logger) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(rec, r)
|
|
log.Info("http",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", rec.status,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"request_id", requestIDFrom(r.Context()),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// timeoutMW bounds the whole request by setting a context deadline. Handlers
|
|
// and adapters that talk to the network honour it; purely CPU-bound handlers
|
|
// are additionally bounded by input limits and the HTTP server's read/write
|
|
// timeouts in cmd/addrkitd.
|
|
func timeoutMW(d time.Duration) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), d)
|
|
defer cancel()
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// recoverMW turns a panic into a 500 instead of crashing the process.
|
|
func recoverMW(log *slog.Logger) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
log.Error("panic", "value", rec, "request_id", requestIDFrom(r.Context()))
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func requestIDFrom(ctx context.Context) string {
|
|
if v, ok := ctx.Value(requestIDKey).(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (s *statusRecorder) WriteHeader(code int) {
|
|
s.status = code
|
|
s.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// newRequestID returns a short random hex id. crypto/rand never fails in
|
|
// practice; on the off chance it does we fall back to a constant so the
|
|
// request still completes.
|
|
func newRequestID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "unknown"
|
|
}
|
|
return hex.EncodeToString(b[:])
|
|
}
|