feat(internal): add foundational packages — config, logger, errors
- config: env-based loading with startup validation, 13 tests - logger: structured slog wrapper with redaction, 10 tests - errors: unified AppError model with HTTP mapping, 18 tests - 41 tests total, build + vet clean
This commit is contained in:
1 parent
b69b74435a
commit
7943e290ec
11 files changed
+1590
No files matched your search
@@ -0,0 +1,159 @@
|
||||
// Package errors provides a unified error model for the Cyrene platform.
|
||||
// All errors flowing through the system should use AppError or its subtypes.
|
||||
// This ensures consistent error codes, user-friendly messages, and HTTP status mapping.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AppError is the unified application error type.
|
||||
type AppError struct {
|
||||
Code string // Machine-readable error code (e.g., "USER_NOT_FOUND")
|
||||
Message string // Human-readable message (safe to show to users)
|
||||
Cause error // Original error (internal only, not exposed to clients)
|
||||
HTTPCode int // HTTP status code
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *AppError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying cause error.
|
||||
func (e *AppError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// --- Pre-defined common errors ---
|
||||
|
||||
// NewNotFound creates a NOT_FOUND error.
|
||||
func NewNotFound(message string) *AppError {
|
||||
return &AppError{Code: "NOT_FOUND", Message: message, HTTPCode: http.StatusNotFound}
|
||||
}
|
||||
|
||||
// NewUnauthorized creates an UNAUTHORIZED error.
|
||||
func NewUnauthorized(message string) *AppError {
|
||||
return &AppError{Code: "UNAUTHORIZED", Message: message, HTTPCode: http.StatusUnauthorized}
|
||||
}
|
||||
|
||||
// NewForbidden creates a FORBIDDEN error.
|
||||
func NewForbidden(message string) *AppError {
|
||||
return &AppError{Code: "FORBIDDEN", Message: message, HTTPCode: http.StatusForbidden}
|
||||
}
|
||||
|
||||
// NewBadRequest creates a BAD_REQUEST error.
|
||||
func NewBadRequest(message string) *AppError {
|
||||
return &AppError{Code: "BAD_REQUEST", Message: message, HTTPCode: http.StatusBadRequest}
|
||||
}
|
||||
|
||||
// NewConflict creates a CONFLICT error.
|
||||
func NewConflict(message string) *AppError {
|
||||
return &AppError{Code: "CONFLICT", Message: message, HTTPCode: http.StatusConflict}
|
||||
}
|
||||
|
||||
// NewTooManyRequests creates a TOO_MANY_REQUESTS error.
|
||||
func NewTooManyRequests(message string) *AppError {
|
||||
return &AppError{Code: "TOO_MANY_REQUESTS", Message: message, HTTPCode: http.StatusTooManyRequests}
|
||||
}
|
||||
|
||||
// NewInternal creates an INTERNAL error with a generic message for clients.
|
||||
// The cause is logged internally but not exposed.
|
||||
func NewInternal(message string, cause error) *AppError {
|
||||
return &AppError{Code: "INTERNAL", Message: message, HTTPCode: http.StatusInternalServerError, Cause: cause}
|
||||
}
|
||||
|
||||
// NewValidation creates a VALIDATION error.
|
||||
func NewValidation(message string) *AppError {
|
||||
return &AppError{Code: "VALIDATION", Message: message, HTTPCode: http.StatusBadRequest}
|
||||
}
|
||||
|
||||
// --- Constructors with cause ---
|
||||
|
||||
// Wrap wraps an existing error with an application error.
|
||||
func Wrap(err error, code, message string, httpCode int) *AppError {
|
||||
return &AppError{Code: code, Message: message, HTTPCode: httpCode, Cause: err}
|
||||
}
|
||||
|
||||
// WrapNotFound wraps an error as NOT_FOUND.
|
||||
func WrapNotFound(err error, message string) *AppError {
|
||||
return &AppError{Code: "NOT_FOUND", Message: message, HTTPCode: http.StatusNotFound, Cause: err}
|
||||
}
|
||||
|
||||
// WrapBadRequest wraps an error as BAD_REQUEST.
|
||||
func WrapBadRequest(err error, message string) *AppError {
|
||||
return &AppError{Code: "BAD_REQUEST", Message: message, HTTPCode: http.StatusBadRequest, Cause: err}
|
||||
}
|
||||
|
||||
// WrapInternal wraps an error as INTERNAL.
|
||||
func WrapInternal(err error, message string) *AppError {
|
||||
return &AppError{Code: "INTERNAL", Message: message, HTTPCode: http.StatusInternalServerError, Cause: err}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// IsAppError checks if the error is an AppError.
|
||||
func IsAppError(err error) (*AppError, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
ae, ok := err.(*AppError)
|
||||
return ae, ok
|
||||
}
|
||||
|
||||
// StatusCode extracts the HTTP status code from an error.
|
||||
// Returns 500 if the error is not an AppError.
|
||||
func StatusCode(err error) int {
|
||||
if ae, ok := IsAppError(err); ok {
|
||||
return ae.HTTPCode
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// Message extracts the user-safe message from an error.
|
||||
// Returns a generic message if the error is not an AppError.
|
||||
func Message(err error) string {
|
||||
if ae, ok := IsAppError(err); ok {
|
||||
return ae.Message
|
||||
}
|
||||
return "Internal server error"
|
||||
}
|
||||
|
||||
// Code extracts the machine-readable error code.
|
||||
// Returns "INTERNAL" if the error is not an AppError.
|
||||
func Code(err error) string {
|
||||
if ae, ok := IsAppError(err); ok {
|
||||
return ae.Code
|
||||
}
|
||||
return "INTERNAL"
|
||||
}
|
||||
|
||||
// --- Sentinel errors for quick checks ---
|
||||
|
||||
var (
|
||||
// ErrUnauthorized is a pre-built unauthorized error.
|
||||
ErrUnauthorized = &AppError{Code: "UNAUTHORIZED", Message: "Authentication required", HTTPCode: http.StatusUnauthorized}
|
||||
|
||||
// ErrForbidden is a pre-built forbidden error.
|
||||
ErrForbidden = &AppError{Code: "FORBIDDEN", Message: "Insufficient permissions", HTTPCode: http.StatusForbidden}
|
||||
|
||||
// ErrInternal is a pre-built internal error.
|
||||
ErrInternal = &AppError{Code: "INTERNAL", Message: "Internal server error", HTTPCode: http.StatusInternalServerError}
|
||||
)
|
||||
|
||||
// IsCode checks if the error has the given error code.
|
||||
func IsCode(err error, code string) bool {
|
||||
if ae, ok := IsAppError(err); ok {
|
||||
return ae.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsNotFound checks if the error is a NOT_FOUND error.
|
||||
func IsNotFound(err error) bool {
|
||||
return IsCode(err, "NOT_FOUND")
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewNotFound(t *testing.T) {
|
||||
err := NewNotFound("user not found")
|
||||
|
||||
if err.Code != "NOT_FOUND" {
|
||||
t.Errorf("expected code NOT_FOUND, got %s", err.Code)
|
||||
}
|
||||
if err.Message != "user not found" {
|
||||
t.Errorf("expected message 'user not found', got %s", err.Message)
|
||||
}
|
||||
if err.HTTPCode != http.StatusNotFound {
|
||||
t.Errorf("expected HTTP 404, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUnauthorized(t *testing.T) {
|
||||
err := NewUnauthorized("invalid token")
|
||||
if err.Code != "UNAUTHORIZED" {
|
||||
t.Errorf("expected code UNAUTHORIZED, got %s", err.Code)
|
||||
}
|
||||
if err.HTTPCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected HTTP 401, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForbidden(t *testing.T) {
|
||||
err := NewForbidden("admin only")
|
||||
if err.HTTPCode != http.StatusForbidden {
|
||||
t.Errorf("expected HTTP 403, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBadRequest(t *testing.T) {
|
||||
err := NewBadRequest("invalid input")
|
||||
if err.HTTPCode != http.StatusBadRequest {
|
||||
t.Errorf("expected HTTP 400, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConflict(t *testing.T) {
|
||||
err := NewConflict("duplicate entry")
|
||||
if err.HTTPCode != http.StatusConflict {
|
||||
t.Errorf("expected HTTP 409, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTooManyRequests(t *testing.T) {
|
||||
err := NewTooManyRequests("rate limit")
|
||||
if err.HTTPCode != http.StatusTooManyRequests {
|
||||
t.Errorf("expected HTTP 429, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInternal(t *testing.T) {
|
||||
cause := fmt.Errorf("db connection refused")
|
||||
err := NewInternal("database error", cause)
|
||||
|
||||
if err.Code != "INTERNAL" {
|
||||
t.Errorf("expected code INTERNAL, got %s", err.Code)
|
||||
}
|
||||
if err.HTTPCode != http.StatusInternalServerError {
|
||||
t.Errorf("expected HTTP 500, got %d", err.HTTPCode)
|
||||
}
|
||||
if err.Cause != cause {
|
||||
t.Errorf("expected cause to be preserved")
|
||||
}
|
||||
// Check that Error() includes the cause
|
||||
errStr := err.Error()
|
||||
if errStr[len(errStr)-1] == '.' {
|
||||
// just check it doesn't panic
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidation(t *testing.T) {
|
||||
err := NewValidation("field required: email")
|
||||
if err.Code != "VALIDATION" {
|
||||
t.Errorf("expected code VALIDATION, got %s", err.Code)
|
||||
}
|
||||
if err.HTTPCode != http.StatusBadRequest {
|
||||
t.Errorf("expected HTTP 400, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap(t *testing.T) {
|
||||
cause := fmt.Errorf("raw error")
|
||||
err := Wrap(cause, "CUSTOM", "custom message", http.StatusTeapot)
|
||||
|
||||
if err.Code != "CUSTOM" {
|
||||
t.Errorf("expected code CUSTOM, got %s", err.Code)
|
||||
}
|
||||
if err.Cause != cause {
|
||||
t.Errorf("expected cause to be preserved")
|
||||
}
|
||||
if err.HTTPCode != http.StatusTeapot {
|
||||
t.Errorf("expected HTTP 418, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapConvenienceFuncs(t *testing.T) {
|
||||
cause := fmt.Errorf("raw error")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err *AppError
|
||||
code string
|
||||
httpCode int
|
||||
}{
|
||||
{"WrapNotFound", WrapNotFound(cause, "not found"), "NOT_FOUND", 404},
|
||||
{"WrapBadRequest", WrapBadRequest(cause, "bad input"), "BAD_REQUEST", 400},
|
||||
{"WrapInternal", WrapInternal(cause, "oops"), "INTERNAL", 500},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.err.Code != tt.code {
|
||||
t.Errorf("expected code %s, got %s", tt.code, tt.err.Code)
|
||||
}
|
||||
if tt.err.HTTPCode != tt.httpCode {
|
||||
t.Errorf("expected HTTP %d, got %d", tt.httpCode, tt.err.HTTPCode)
|
||||
}
|
||||
if tt.err.Cause != cause {
|
||||
t.Errorf("expected cause to be preserved")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrap(t *testing.T) {
|
||||
cause := fmt.Errorf("original")
|
||||
err := WrapInternal(cause, "wrapped")
|
||||
unwrapped := err.Unwrap()
|
||||
if unwrapped != cause {
|
||||
t.Errorf("Unwrap() = %v, want %v", unwrapped, cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAppError(t *testing.T) {
|
||||
ae := NewNotFound("test")
|
||||
_, ok := IsAppError(ae)
|
||||
if !ok {
|
||||
t.Error("expected AppError to be identified")
|
||||
}
|
||||
|
||||
plain := fmt.Errorf("plain error")
|
||||
_, ok = IsAppError(plain)
|
||||
if ok {
|
||||
t.Error("plain error should not be AppError")
|
||||
}
|
||||
|
||||
_, ok = IsAppError(nil)
|
||||
if ok {
|
||||
t.Error("nil should not be AppError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCode(t *testing.T) {
|
||||
if sc := StatusCode(NewNotFound("x")); sc != 404 {
|
||||
t.Errorf("expected 404, got %d", sc)
|
||||
}
|
||||
if sc := StatusCode(fmt.Errorf("plain")); sc != 500 {
|
||||
t.Errorf("expected 500 for plain error, got %d", sc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessage(t *testing.T) {
|
||||
if msg := Message(NewNotFound("user x not found")); msg != "user x not found" {
|
||||
t.Errorf("expected message preserved, got %s", msg)
|
||||
}
|
||||
if msg := Message(fmt.Errorf("raw")); msg != "Internal server error" {
|
||||
t.Errorf("expected generic message for unknown error, got %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCode(t *testing.T) {
|
||||
if c := Code(NewForbidden("nope")); c != "FORBIDDEN" {
|
||||
t.Errorf("expected FORBIDDEN, got %s", c)
|
||||
}
|
||||
if c := Code(fmt.Errorf("raw")); c != "INTERNAL" {
|
||||
t.Errorf("expected INTERNAL for unknown error, got %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCode(t *testing.T) {
|
||||
err := NewNotFound("test")
|
||||
if !IsCode(err, "NOT_FOUND") {
|
||||
t.Error("expected IsCode true for NOT_FOUND")
|
||||
}
|
||||
if IsCode(err, "FORBIDDEN") {
|
||||
t.Error("expected IsCode false for wrong code")
|
||||
}
|
||||
if IsCode(nil, "NOT_FOUND") {
|
||||
t.Error("expected IsCode false for nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNotFound(t *testing.T) {
|
||||
if !IsNotFound(NewNotFound("x")) {
|
||||
t.Error("expected IsNotFound true")
|
||||
}
|
||||
if IsNotFound(NewBadRequest("x")) {
|
||||
t.Error("expected IsNotFound false for bad request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelErrors(t *testing.T) {
|
||||
if ErrUnauthorized.Code != "UNAUTHORIZED" {
|
||||
t.Errorf("expected UNAUTHORIZED sentinel")
|
||||
}
|
||||
if ErrForbidden.Code != "FORBIDDEN" {
|
||||
t.Errorf("expected FORBIDDEN sentinel")
|
||||
}
|
||||
if ErrInternal.Code != "INTERNAL" {
|
||||
t.Errorf("expected INTERNAL sentinel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorFormatting(t *testing.T) {
|
||||
// Without cause
|
||||
err := NewNotFound("user not found")
|
||||
str := err.Error()
|
||||
if str != "[NOT_FOUND] user not found" {
|
||||
t.Errorf("unexpected format: %s", str)
|
||||
}
|
||||
|
||||
// With cause
|
||||
cause := fmt.Errorf("sql: no rows")
|
||||
err2 := WrapInternal(cause, "database error")
|
||||
str2 := err2.Error()
|
||||
if str2[:len("[INTERNAL]")] != "[INTERNAL]" {
|
||||
t.Errorf("unexpected format: %s", str2)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user