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.
This commit is contained in:
+70
-16
@@ -21,48 +21,102 @@ type ErrorResponse struct {
|
||||
// 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 expects *model.AppError; unexpected non-AppError
|
||||
// values are treated as 500 with a safe message. For 500+ errors, a
|
||||
// random reference hash is included in the response so operators can
|
||||
// correlate it with server logs.
|
||||
// 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) {
|
||||
ref := randomHex(8)
|
||||
logID := randomHex(8)
|
||||
slog.ErrorContext(c.Request.Context(), "non-AppError escaped to handler",
|
||||
"ref", ref, "error", err, "type", fmt.Sprintf("%T", err))
|
||||
Error(c, http.StatusInternalServerError, "internal server error (ref: "+ref+")")
|
||||
"log_id", logID, "error", err, "type", fmt.Sprintf("%T", err))
|
||||
writeError(c, http.StatusInternalServerError, "internal server error", logID)
|
||||
return
|
||||
}
|
||||
|
||||
// 500+ errors: log full detail with a reference hash, return sanitized message.
|
||||
if ae.Status >= 500 {
|
||||
ref := randomHex(8)
|
||||
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",
|
||||
"ref", ref, slog.Any("error", ae.Err), "message", ae.Message)
|
||||
Error(c, ae.Status, "internal server error (ref: "+ref+")")
|
||||
"log_id", logID, slog.Any("error", ae.Err), "message", ae.Message)
|
||||
writeError(c, status, message, logID)
|
||||
return
|
||||
}
|
||||
|
||||
// 4xx errors with internal cause: log at WARN for diagnostics.
|
||||
if ae.Err != nil {
|
||||
// 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,
|
||||
"status", ae.Status, slog.Any("error", ae.Err))
|
||||
"log_id", logID, "status", status, slog.Any("error", ae.Err))
|
||||
writeError(c, status, message, logID)
|
||||
return
|
||||
}
|
||||
|
||||
Error(c, ae.Status, ae.Message)
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user