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 {
|
||||
|
||||
@@ -2,11 +2,14 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
@@ -33,4 +36,66 @@ func TestError(t *testing.T) {
|
||||
if body.Error.Message != "invalid request" {
|
||||
t.Errorf("message = %q, want %q", body.Error.Message, "invalid request")
|
||||
}
|
||||
if body.Error.LogID != "" {
|
||||
t.Errorf("log_id = %q, want empty", body.Error.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondErrorMapsDomainKinds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus int
|
||||
wantLogID bool
|
||||
}{
|
||||
{"invalid argument", model.NewInvalidArgumentError("bad input"), http.StatusBadRequest, false},
|
||||
{"unauthenticated", model.NewUnauthenticatedError("invalid token"), http.StatusUnauthorized, false},
|
||||
{"permission denied", model.NewPermissionDeniedError("access denied", nil), http.StatusForbidden, false},
|
||||
{"not found", model.NewNotFoundError("missing", nil), http.StatusNotFound, false},
|
||||
{"conflict", model.NewConflictError("duplicate"), http.StatusConflict, false},
|
||||
{"payload too large", model.NewPayloadTooLargeError("too large", nil), http.StatusRequestEntityTooLarge, false},
|
||||
{"internal", model.NewInternalError("lookup", errors.New("database offline")), http.StatusInternalServerError, true},
|
||||
{"unknown", errors.New("escaped error"), http.StatusInternalServerError, true},
|
||||
{"permission denied sentinel cause", model.NewPermissionDeniedError("access denied", model.ErrForbidden), http.StatusForbidden, false},
|
||||
{"not found sentinel cause", model.NewNotFoundError("missing", model.ErrNotFound), http.StatusNotFound, false},
|
||||
{"payload too large sentinel cause", model.NewPayloadTooLargeError("too large", model.ErrUploadTooLarge), http.StatusRequestEntityTooLarge, false},
|
||||
{"client with diagnostic cause", model.NewPermissionDeniedError("access denied", errors.New("policy backend failed")), http.StatusForbidden, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := exerciseRespondError(tt.err)
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body = %s", rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
|
||||
var body ErrorResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if tt.wantLogID && body.Error.LogID == "" {
|
||||
t.Fatalf("log_id is empty, want populated; body = %s", rec.Body.String())
|
||||
}
|
||||
if !tt.wantLogID && body.Error.LogID != "" {
|
||||
t.Fatalf("log_id = %q, want empty", body.Error.LogID)
|
||||
}
|
||||
if rec.Code >= http.StatusInternalServerError && body.Error.Message != "internal server error" {
|
||||
t.Fatalf("message = %q, want sanitized internal message", body.Error.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func exerciseRespondError(err error) *httptest.ResponseRecorder {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/error", func(c *gin.Context) {
|
||||
RespondError(c, err)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/error", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user