feat(server/apierror): typed Error + sentinels for /api/* envelope
This commit is contained in:
@@ -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"}
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user