Files
mygo/internal/api/response.go
T
ld 63ede5c237 refactor(api): enforce protocol-neutral service boundaries
- refactor: move HTTP status and DTO concerns out of model and service
  layers into API and handler code.
- feat: add admin service boundary and route auth through services
  instead of direct repository access.
- test: add architecture and error tests covering package boundaries,
  redaction, and service behavior.
- docs: record the protocol-neutral boundary decision and update
  architecture and roadmap notes.
2026-07-05 23:30:17 +08:00

127 lines
3.2 KiB
Go

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)
}