feat(apierr): map upstream codes to API status/code and add Respond helper

This commit is contained in:
Noooste
2026-04-20 00:05:59 +02:00
parent caf8d9fd2e
commit 691ed8db4d
2 changed files with 163 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
package apierr
import (
"errors"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// codeEntry defines how an upstream code translates to the API surface.
type codeEntry struct {
HTTPStatus int
APICode string
}
// upstreamCodeTable maps upstream codes (Garage + S3 share the same naming for
// most codes) to (httpStatus, apiCode). Extend here as new upstream codes are
// discovered.
var upstreamCodeTable = map[string]codeEntry{
"BucketNotEmpty": {409, models.ErrCodeBucketNotEmpty},
"NoSuchBucket": {404, models.ErrCodeBucketNotFound},
"BucketAlreadyExists": {409, models.ErrCodeBucketExists},
"BucketAlreadyOwnedByYou": {409, models.ErrCodeBucketExists},
"NoSuchKey": {404, models.ErrCodeObjectNotFound},
"InvalidBucketName": {400, models.ErrCodeInvalidBucketName},
"AccessDenied": {403, models.ErrCodeForbidden},
}
// Map translates an error into (httpStatus, apiCode, message) for the API
// response. Falls back to 500 / INTERNAL_ERROR for non-UpstreamError values.
func Map(err error) (int, string, string) {
var ue *UpstreamError
if !errors.As(err, &ue) {
return 500, models.ErrCodeInternalError, err.Error()
}
if entry, ok := upstreamCodeTable[ue.Code]; ok {
return entry.HTTPStatus, entry.APICode, ue.Message
}
status := ue.HTTPStatus
if status == 0 {
status = 500
}
msg := ue.Message
if msg == "" {
msg = ue.Error()
}
return status, models.ErrCodeInternalError, msg
}
// Respond writes the mapped error as a Fiber JSON response using the standard
// APIResponse envelope. Handlers should call this from their err != nil
// branches for all upstream failures.
func Respond(c fiber.Ctx, err error) error {
status, code, msg := Map(err)
return c.Status(status).JSON(models.ErrorResponse(code, msg))
}
+104
View File
@@ -0,0 +1,104 @@
package apierr
import (
"errors"
"testing"
"Noooste/garage-ui/internal/models"
)
func TestMap_TableDriven(t *testing.T) {
cases := []struct {
name string
err error
wantStatus int
wantCode string
wantMsg string
}{
{
name: "BucketNotEmpty",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketNotEmpty", Message: "Tried to delete a non-empty bucket", Source: "garage"},
wantStatus: 409,
wantCode: models.ErrCodeBucketNotEmpty,
wantMsg: "Tried to delete a non-empty bucket",
},
{
name: "NoSuchBucket",
err: &UpstreamError{HTTPStatus: 404, Code: "NoSuchBucket", Message: "missing", Source: "s3"},
wantStatus: 404,
wantCode: models.ErrCodeBucketNotFound,
wantMsg: "missing",
},
{
name: "BucketAlreadyExists",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketAlreadyExists", Message: "dup", Source: "s3"},
wantStatus: 409,
wantCode: models.ErrCodeBucketExists,
wantMsg: "dup",
},
{
name: "BucketAlreadyOwnedByYou",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketAlreadyOwnedByYou", Message: "yours", Source: "s3"},
wantStatus: 409,
wantCode: models.ErrCodeBucketExists,
wantMsg: "yours",
},
{
name: "NoSuchKey",
err: &UpstreamError{HTTPStatus: 404, Code: "NoSuchKey", Message: "gone", Source: "s3"},
wantStatus: 404,
wantCode: models.ErrCodeObjectNotFound,
wantMsg: "gone",
},
{
name: "InvalidBucketName",
err: &UpstreamError{HTTPStatus: 400, Code: "InvalidBucketName", Message: "bad", Source: "s3"},
wantStatus: 400,
wantCode: models.ErrCodeInvalidBucketName,
wantMsg: "bad",
},
{
name: "AccessDenied",
err: &UpstreamError{HTTPStatus: 403, Code: "AccessDenied", Message: "nope", Source: "garage"},
wantStatus: 403,
wantCode: models.ErrCodeForbidden,
wantMsg: "nope",
},
{
name: "UnknownCodeWithStatus",
err: &UpstreamError{HTTPStatus: 503, Code: "SomethingWeird", Message: "weird", Source: "garage"},
wantStatus: 503,
wantCode: models.ErrCodeInternalError,
wantMsg: "weird",
},
{
name: "UnknownCodeNoStatus",
err: &UpstreamError{HTTPStatus: 0, Code: "", Message: "boom", Source: "garage"},
wantStatus: 500,
wantCode: models.ErrCodeInternalError,
wantMsg: "boom",
},
{
name: "NonUpstreamError",
err: errors.New("plain"),
wantStatus: 500,
wantCode: models.ErrCodeInternalError,
wantMsg: "plain",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, code, msg := Map(tc.err)
if status != tc.wantStatus {
t.Errorf("status = %d, want %d", status, tc.wantStatus)
}
if code != tc.wantCode {
t.Errorf("code = %q, want %q", code, tc.wantCode)
}
if msg != tc.wantMsg {
t.Errorf("msg = %q, want %q", msg, tc.wantMsg)
}
})
}
}