63ede5c237
- 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.
127 lines
3.2 KiB
Go
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)
|
|
}
|