diff --git a/backend/internal/apierr/map.go b/backend/internal/apierr/map.go new file mode 100644 index 0000000..88c1cd6 --- /dev/null +++ b/backend/internal/apierr/map.go @@ -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)) +} diff --git a/backend/internal/apierr/map_test.go b/backend/internal/apierr/map_test.go new file mode 100644 index 0000000..74da50e --- /dev/null +++ b/backend/internal/apierr/map_test.go @@ -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) + } + }) + } +}