package server import ( "encoding/json" "errors" "log/slog" "net" "net/http" "strings" "time" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) // writeJSON serializes body as JSON with the supplied status. It ignores // encode errors — by the time we're encoding we've already committed to a // response status, so a half-written body is the least-bad outcome. func writeJSON(w http.ResponseWriter, code int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(body) } // writeError emits the platform-standard error envelope. func writeError(w http.ResponseWriter, code int, kind, msg string) { writeJSON(w, code, errorEnvelope{Error: kind, Message: msg}) } type errorEnvelope struct { Error string `json:"error"` Message string `json:"message,omitempty"` } // mapStoreError converts a store-layer sentinel into the right HTTP // envelope. Returns true if the error was handled. func mapStoreError(w http.ResponseWriter, err error) bool { switch { case errors.Is(err, store.ErrNotFound): writeError(w, http.StatusNotFound, "not_found", "resource does not exist") case errors.Is(err, store.ErrConflict): writeError(w, http.StatusConflict, "conflict", "resource already exists") case errors.Is(err, store.ErrInvalidInput): writeError(w, http.StatusBadRequest, "invalid_input", "input failed validation") default: return false } return true } // decodeJSON unmarshals r.Body into dst. Returns true on success; if false, // the response is already written. func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { if err := json.NewDecoder(r.Body).Decode(dst); err != nil { writeError(w, http.StatusBadRequest, "invalid_body", "request body is not valid JSON") return false } return true } // logRequest is the access-log middleware: one structured line per request. func logRequest(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() rr := &statusRecorder{ResponseWriter: w, code: 200} next.ServeHTTP(rr, r) log.Info("http", "method", r.Method, "path", r.URL.Path, "status", rr.code, "duration_ms", time.Since(start).Milliseconds(), "remote", clientIP(r), ) }) } } type statusRecorder struct { http.ResponseWriter code int } func (s *statusRecorder) WriteHeader(c int) { s.code = c s.ResponseWriter.WriteHeader(c) } func clientIP(r *http.Request) string { if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { if i := strings.IndexByte(fwd, ','); i > 0 { return stripBrackets(strings.TrimSpace(fwd[:i])) } return stripBrackets(strings.TrimSpace(fwd)) } if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { // net.SplitHostPort returns IPv6 without brackets already. return host } return stripBrackets(r.RemoteAddr) } // stripBrackets removes the `[...]` wrapping IPv6 hosts pick up from // net/http's RemoteAddr in some Go versions, since Postgres `inet` rejects // `[::1]` but accepts `::1`. func stripBrackets(s string) string { if len(s) >= 2 && s[0] == '[' && s[len(s)-1] == ']' { return s[1 : len(s)-1] } return s }