package api import ( "crypto/rand" "encoding/hex" "errors" "fmt" "log/slog" "net/http" "github.com/gin-gonic/gin" "github.com/dhao2001/mygo/internal/model" ) // ErrorResponse is the standard JSON body for HTTP API errors. type ErrorResponse struct { Error ErrorBody `json:"error"` } // ErrorBody contains human-readable error details. type ErrorBody struct { Message string `json:"message"` LogID string `json:"log_id,omitempty"` } // Error writes a JSON error response. func Error(c *gin.Context, status int, message string) { writeError(c, status, message, "") } func writeError(c *gin.Context, status int, message, logID string) { c.JSON(status, ErrorResponse{ Error: ErrorBody{ Message: message, LogID: logID, }, }) } // RespondError unpacks an error from the service layer and writes a JSON // error response. It maps protocol-neutral domain error kinds to HTTP status // codes at the API boundary. Unexpected non-AppError values are treated as 500 // with a safe message. Recorded errors return a log_id for correlation. func RespondError(c *gin.Context, err error) { var ae *model.AppError if !errors.As(err, &ae) { logID := randomHex(8) slog.ErrorContext(c.Request.Context(), "non-AppError escaped to handler", "log_id", logID, "error", err, "type", fmt.Sprintf("%T", err)) writeError(c, http.StatusInternalServerError, "internal server error", logID) return } status := StatusForErrorKind(ae.Kind) message := ae.Message if status >= http.StatusInternalServerError { message = "internal server error" } if status >= http.StatusInternalServerError { logID := randomHex(8) slog.ErrorContext(c.Request.Context(), "internal server error", "log_id", logID, slog.Any("error", ae.Err), "message", ae.Message) writeError(c, status, message, logID) return } // 4xx errors with unexpected causes are recorded for diagnostics and return log_id. if isLoggableCause(ae.Err) { logID := randomHex(8) slog.WarnContext(c.Request.Context(), ae.Message, "log_id", logID, "status", status, slog.Any("error", ae.Err)) writeError(c, status, message, logID) return } writeError(c, status, message, "") } func isLoggableCause(err error) bool { if err == nil { return false } for _, expected := range []error{ model.ErrNotFound, model.ErrDuplicate, model.ErrUnauthorized, model.ErrForbidden, model.ErrUploadTooLarge, } { if errors.Is(err, expected) { return false } } return true } // StatusForErrorKind maps a protocol-neutral error kind to a REST status code. func StatusForErrorKind(kind model.ErrorKind) int { switch kind { case model.KindInvalidArgument: return http.StatusBadRequest case model.KindUnauthenticated: return http.StatusUnauthorized case model.KindPermissionDenied: return http.StatusForbidden case model.KindNotFound: return http.StatusNotFound case model.KindConflict: return http.StatusConflict case model.KindPayloadTooLarge: return http.StatusRequestEntityTooLarge case model.KindInternal: return http.StatusInternalServerError default: return http.StatusInternalServerError } } func randomHex(n int) string { b := make([]byte, n) rand.Read(b) return hex.EncodeToString(b) }