package httputil import ( "encoding/json" "errors" "net/http" "code.nochebuena.dev/einherjar/contracts/logging" "code.nochebuena.dev/einherjar/core/xerrors" ) // JSON encodes v as JSON and writes it with the given status code. // Sets Content-Type: application/json. func JSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // NoContent writes a 204 No Content response. func NoContent(w http.ResponseWriter) { w.WriteHeader(http.StatusNoContent) } // Error is the centralized error handler. It logs at the appropriate level and // writes a standardized JSON error body. // // Log level is derived from the HTTP status: // - 5xx → Error (unexpected server failure; logz auto-enriches with error_code and context fields) // - 4xx → Warn (client mistake — not a server failure) // - 499 → Info (client cancelled the request intentionally) // // The response body always contains code and message; platform_code and context // fields attached via [xerrors.Err.WithContext] are included when present. func Error(logger logging.Logger, w http.ResponseWriter, r *http.Request, err error) { reqLogger := logger.WithContext(r.Context()) var xe *xerrors.Err switch { case err == nil: reqLogger.Error("handler error", xerrors.Internal("nil error passed to httputil.Error")) case errors.As(err, &xe): status := codeToStatus(xe.Code()) switch { case status >= 500: reqLogger.Error("handler error", err) case status == 499: reqLogger.Info("handler: request cancelled", "error_code", string(xe.Code())) default: args := []any{"error_code", string(xe.Code()), "status", status} for k, v := range xe.Fields() { args = append(args, k, v) } reqLogger.Warn("handler error", args...) } default: reqLogger.Error("handler error", err) } writeError(w, err) } // writeError writes the HTTP error response without logging. // Used by [HandlerFunc].ServeHTTP where no logger is in scope. func writeError(w http.ResponseWriter, err error) { var xe *xerrors.Err if errors.As(err, &xe) { JSON(w, codeToStatus(xe.Code()), errorBody(string(xe.Code()), xe.PlatformCode(), xe.Message(), xe.Fields())) return } JSON(w, http.StatusInternalServerError, errorBody("INTERNAL", "", "internal server error", nil)) } func errorBody(code, platformCode, message string, fields map[string]any) map[string]any { m := map[string]any{ "code": code, "message": message, } if platformCode != "" { m["platform_code"] = platformCode } for k, v := range fields { m[k] = v } return m } func codeToStatus(code xerrors.Code) int { switch code { case xerrors.ErrInvalidInput, xerrors.ErrOutOfRange: return http.StatusBadRequest case xerrors.ErrUnauthorized: return http.StatusUnauthorized case xerrors.ErrPermissionDenied: return http.StatusForbidden case xerrors.ErrNotFound: return http.StatusNotFound case xerrors.ErrAlreadyExists, xerrors.ErrAborted: return http.StatusConflict case xerrors.ErrGone: return http.StatusGone case xerrors.ErrPreconditionFailed: return http.StatusPreconditionFailed case xerrors.ErrRateLimited: return http.StatusTooManyRequests case xerrors.ErrCancelled: return 499 case xerrors.ErrInternal, xerrors.ErrDataLoss: return http.StatusInternalServerError case xerrors.ErrNotImplemented: return http.StatusNotImplemented case xerrors.ErrUnavailable: return http.StatusServiceUnavailable case xerrors.ErrDeadlineExceeded: return http.StatusGatewayTimeout default: return http.StatusInternalServerError } }