feat: add structured error response helpers
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/server"
|
||||||
"mal/pkg/net/limits"
|
"mal/pkg/net/limits"
|
||||||
"mal/pkg/net/proxytransport"
|
"mal/pkg/net/proxytransport"
|
||||||
"mal/pkg/net/useragent"
|
"mal/pkg/net/useragent"
|
||||||
@@ -86,13 +87,13 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
|
|||||||
func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
|
func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
|
||||||
animeID, err := strconv.Atoi(c.Param("animeId"))
|
animeID, err := strconv.Atoi(c.Param("animeId"))
|
||||||
if err != nil || animeID <= 0 {
|
if err != nil || animeID <= 0 {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
episode := c.Param("episode")
|
episode := c.Param("episode")
|
||||||
if episode == "" {
|
if episode == "" {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "missing episode")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +107,15 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
|
|||||||
|
|
||||||
data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID)
|
data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"watch_episode_data_build_failed",
|
||||||
|
"playback",
|
||||||
|
"failed to load episode data",
|
||||||
|
map[string]any{"anime_id": animeID, "episode": episode, "mode": mode, "user_id": userID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +157,7 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
// Avoid spamming 500s for anonymous playback; progress is user-scoped.
|
// Avoid spamming 500s for anonymous playback; progress is user-scoped.
|
||||||
c.Status(http.StatusUnauthorized)
|
server.RespondHTMLOrJSONError(c, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,13 +168,21 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds)
|
err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"watch_progress_save_failed",
|
||||||
|
"playback",
|
||||||
|
"failed to save progress",
|
||||||
|
map[string]any{"mal_id": req.MalID, "episode": req.Episode, "user_id": userID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,13 +202,21 @@ func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.Status(http.StatusBadRequest)
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID)
|
err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"watch_complete_failed",
|
||||||
|
"playback",
|
||||||
|
"failed to complete anime",
|
||||||
|
map[string]any{"mal_id": req.MalID, "user_id": userID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
internal/server/respond.go
Normal file
43
internal/server/respond.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mal/internal/observability"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func RespondHTMLOrJSONError(c *gin.Context, status int, message string) {
|
||||||
|
if acceptsHTML(c) {
|
||||||
|
c.String(status, message)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(status, ErrorResponse{Error: message})
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RespondError(c *gin.Context, status int, event string, component string, message string, fields map[string]any, err error) {
|
||||||
|
level := observability.LogLevelWarn
|
||||||
|
if status >= http.StatusInternalServerError {
|
||||||
|
level = observability.LogLevelError
|
||||||
|
}
|
||||||
|
observability.LogJSON(level, event, component, "", fields, err)
|
||||||
|
RespondHTMLOrJSONError(c, status, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptsHTML(c *gin.Context) bool {
|
||||||
|
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(c.GetHeader("HX-Request")), "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user