diff --git a/internal/infra/errs/errs.go b/internal/infra/errs/errs.go new file mode 100644 index 0000000..a498fd6 --- /dev/null +++ b/internal/infra/errs/errs.go @@ -0,0 +1,111 @@ +// Package errs defines the application's error type (AppError) and helpers +// to map domain error codes to HTTP status codes. Service layers wrap +// underlying errors with errs.Wrap / errs.New; transport layers use errs.As +// + errs.HTTPStatus to translate them into Google API style responses +// ({code, message, fields, request_id}). +package errs + +import ( + "errors" + "fmt" + "net/http" +) + +// Code is a stable, machine-readable identifier for a class of error. +// Codes are part of the public API contract surfaced by HTTP responses. +type Code string + +// Canonical error codes. Keep this list in sync with HTTPStatus and any +// downstream consumers (e.g., HTTP transport, OpenAPI schemas). +const ( + CodeInvalidInput Code = "invalid_input" + CodeUnauthorized Code = "unauthorized" + CodeForbidden Code = "forbidden" + CodeNotFound Code = "not_found" + CodeConflict Code = "conflict" + CodeRateLimited Code = "rate_limited" + CodeUpstream Code = "upstream_error" + CodeUnavailable Code = "unavailable" + CodeInternal Code = "internal" +) + +// AppError is the application's structured error type. It carries a stable +// Code, a human-readable Message, an optional underlying Cause, and an +// optional Fields map for per-field validation details. +type AppError struct { + Code Code + Message string + Cause error + Fields map[string]any +} + +// Error implements the error interface. When a Cause is present the format +// is ": "; otherwise just the message is returned. +func (e *AppError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("%s: %s", e.Message, e.Cause.Error()) + } + return e.Message +} + +// Unwrap exposes the underlying cause so errors.Is / errors.As can traverse +// the chain. +func (e *AppError) Unwrap() error { return e.Cause } + +// WithFields attaches per-field validation details and returns the receiver +// for chaining. +func (e *AppError) WithFields(f map[string]any) *AppError { + e.Fields = f + return e +} + +// New constructs a new AppError with the given code and message. +func New(code Code, msg string) *AppError { + return &AppError{Code: code, Message: msg} +} + +// Wrap wraps an existing error with an AppError carrying the given code and +// message. The original error is preserved as Cause and remains discoverable +// via errors.Is / errors.As. +func Wrap(cause error, code Code, msg string) *AppError { + return &AppError{Code: code, Message: msg, Cause: cause} +} + +// As is a convenience wrapper around errors.As that extracts the first +// *AppError in err's chain. It returns (nil, false) when err is nil or no +// AppError is found. +func As(err error) (*AppError, bool) { + if err == nil { + return nil, false + } + var ae *AppError + if errors.As(err, &ae) { + return ae, true + } + return nil, false +} + +// HTTPStatus maps an error Code to its canonical HTTP status code. Unknown +// codes default to 500 Internal Server Error. +func HTTPStatus(code Code) int { + switch code { + case CodeInvalidInput: + return http.StatusBadRequest + case CodeUnauthorized: + return http.StatusUnauthorized + case CodeForbidden: + return http.StatusForbidden + case CodeNotFound: + return http.StatusNotFound + case CodeConflict: + return http.StatusConflict + case CodeRateLimited: + return http.StatusTooManyRequests + case CodeUpstream: + return http.StatusBadGateway + case CodeUnavailable: + return http.StatusServiceUnavailable + default: + return http.StatusInternalServerError + } +} diff --git a/internal/infra/errs/errs_test.go b/internal/infra/errs/errs_test.go new file mode 100644 index 0000000..27f2837 --- /dev/null +++ b/internal/infra/errs/errs_test.go @@ -0,0 +1,45 @@ +package errs + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNew_BasicShape(t *testing.T) { + e := New(CodeInvalidInput, "字段缺失") + require.Equal(t, CodeInvalidInput, e.Code) + require.Equal(t, "字段缺失", e.Message) + require.Nil(t, e.Cause) +} + +func TestWithFields(t *testing.T) { + e := New(CodeInvalidInput, "x").WithFields(map[string]any{"name": "required"}) + require.Equal(t, "required", e.Fields["name"]) +} + +func TestWrap_PreservesCause(t *testing.T) { + cause := errors.New("boom") + e := Wrap(cause, CodeInternal, "wrapped") + require.ErrorIs(t, e, cause) + require.Equal(t, "wrapped: boom", e.Error()) +} + +func TestHTTPStatus(t *testing.T) { + cases := map[Code]int{ + CodeInvalidInput: http.StatusBadRequest, + CodeUnauthorized: http.StatusUnauthorized, + CodeForbidden: http.StatusForbidden, + CodeNotFound: http.StatusNotFound, + CodeConflict: http.StatusConflict, + CodeRateLimited: http.StatusTooManyRequests, + CodeUpstream: http.StatusBadGateway, + CodeUnavailable: http.StatusServiceUnavailable, + CodeInternal: http.StatusInternalServerError, + } + for code, want := range cases { + require.Equal(t, want, HTTPStatus(code), "code=%s", code) + } +}