diff --git a/internal/apierror/apierror.go b/internal/apierror/apierror.go new file mode 100644 index 00000000..6ee53801 --- /dev/null +++ b/internal/apierror/apierror.go @@ -0,0 +1,65 @@ +package apierror + +import ( + "errors" + "fmt" +) + +type Error struct { + Status int + Code string + Message string + Cause error +} + +func (e *Error) Error() string { + if e.Cause != nil { + return fmt.Sprintf("%s: %v", e.Message, e.Cause) + } + return e.Message +} + +func (e *Error) Unwrap() error { return e.Cause } + +// From returns err as *Error if it (or anything in its chain) is one; +// otherwise wraps err in an Internal. +func From(err error) *Error { + if err == nil { + return nil + } + var apiErr *Error + if errors.As(err, &apiErr) { + return apiErr + } + return Internal(err) +} + +func NotFound(what string) *Error { + return &Error{Status: 404, Code: what + "_not_found", Message: what + " not found"} +} + +func BadRequest(code, message string) *Error { + return &Error{Status: 400, Code: code, Message: message} +} + +func Conflict(code, message string) *Error { + return &Error{Status: 409, Code: code, Message: message} +} + +func Forbidden(code, message string) *Error { + return &Error{Status: 403, Code: code, Message: message} +} + +func Unauthorized(code, message string) *Error { + return &Error{Status: 401, Code: code, Message: message} +} + +func Internal(cause error) *Error { + return &Error{Status: 500, Code: "server_error", Message: "internal server error", Cause: cause} +} + +var ( + ErrNotFound = &Error{Status: 404, Code: "not_found", Message: "not found"} + ErrForbidden = &Error{Status: 403, Code: "forbidden", Message: "forbidden"} + ErrUnauthorized = &Error{Status: 401, Code: "unauthorized", Message: "authentication required"} +) diff --git a/internal/apierror/apierror_test.go b/internal/apierror/apierror_test.go new file mode 100644 index 00000000..1a0b212f --- /dev/null +++ b/internal/apierror/apierror_test.go @@ -0,0 +1,156 @@ +package apierror + +import ( + "errors" + "fmt" + "testing" +) + +func TestError_ErrorString_NoCause(t *testing.T) { + e := &Error{Message: "x"} + if got := e.Error(); got != "x" { + t.Fatalf("Error() = %q, want %q", got, "x") + } +} + +func TestError_ErrorString_WithCause(t *testing.T) { + e := &Error{Message: "x", Cause: errors.New("y")} + if got := e.Error(); got != "x: y" { + t.Fatalf("Error() = %q, want %q", got, "x: y") + } +} + +func TestError_Unwrap(t *testing.T) { + cause := errors.New("boom") + e := &Error{Message: "x", Cause: cause} + if got := e.Unwrap(); got != cause { + t.Fatalf("Unwrap() = %v, want %v", got, cause) + } + + e2 := &Error{Message: "x"} + if got := e2.Unwrap(); got != nil { + t.Fatalf("Unwrap() with nil Cause = %v, want nil", got) + } +} + +func TestFrom_NilReturnsNil(t *testing.T) { + if got := From(nil); got != nil { + t.Fatalf("From(nil) = %v, want nil", got) + } +} + +func TestFrom_PreservesAPIError(t *testing.T) { + orig := NotFound("track") + got := From(orig) + if got != orig { + t.Fatalf("From(orig) returned different pointer: got %p, want %p", got, orig) + } +} + +func TestFrom_PreservesWrappedAPIError(t *testing.T) { + inner := NotFound("track") + wrapped := fmt.Errorf("ctx: %w", inner) + got := From(wrapped) + if got == nil { + t.Fatal("From(wrapped) = nil, want *Error") + } + if got.Code != "track_not_found" { + t.Fatalf("got.Code = %q, want %q", got.Code, "track_not_found") + } +} + +func TestFrom_WrapsPlainError(t *testing.T) { + plain := errors.New("boom") + got := From(plain) + if got == nil { + t.Fatal("From(plain) = nil, want *Error") + } + if got.Status != 500 { + t.Errorf("Status = %d, want 500", got.Status) + } + if got.Code != "server_error" { + t.Errorf("Code = %q, want %q", got.Code, "server_error") + } + if got.Cause != plain { + t.Errorf("Cause = %v, want %v", got.Cause, plain) + } +} + +func TestConstructors_FieldsCorrect(t *testing.T) { + tests := []struct { + name string + err *Error + wantStatus int + wantCode string + wantMessage string + }{ + { + name: "NotFound", + err: NotFound("track"), + wantStatus: 404, + wantCode: "track_not_found", + wantMessage: "track not found", + }, + { + name: "BadRequest", + err: BadRequest("invalid_input", "invalid input"), + wantStatus: 400, + wantCode: "invalid_input", + wantMessage: "invalid input", + }, + { + name: "Conflict", + err: Conflict("already_exists", "already exists"), + wantStatus: 409, + wantCode: "already_exists", + wantMessage: "already exists", + }, + { + name: "Forbidden", + err: Forbidden("forbidden", "forbidden"), + wantStatus: 403, + wantCode: "forbidden", + wantMessage: "forbidden", + }, + { + name: "Unauthorized", + err: Unauthorized("unauthorized", "authentication required"), + wantStatus: 401, + wantCode: "unauthorized", + wantMessage: "authentication required", + }, + { + name: "Internal", + err: Internal(errors.New("boom")), + wantStatus: 500, + wantCode: "server_error", + wantMessage: "internal server error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.err.Status != tt.wantStatus { + t.Errorf("Status = %d, want %d", tt.err.Status, tt.wantStatus) + } + if tt.err.Code != tt.wantCode { + t.Errorf("Code = %q, want %q", tt.err.Code, tt.wantCode) + } + if tt.err.Message != tt.wantMessage { + t.Errorf("Message = %q, want %q", tt.err.Message, tt.wantMessage) + } + }) + } +} + +func TestSentinels_Identity(t *testing.T) { + wrapped := fmt.Errorf("ctx: %w", ErrNotFound) + + var apiErr *Error + if !errors.As(wrapped, &apiErr) { + t.Fatal("errors.As(wrapped, &apiErr) = false, want true") + } + if apiErr != ErrNotFound { + t.Fatalf("recovered error pointer = %p, want %p", apiErr, ErrNotFound) + } +}