diff --git a/pkg/sdk/client.go b/pkg/sdk/client.go index 25f1bdca..7009a2f8 100644 --- a/pkg/sdk/client.go +++ b/pkg/sdk/client.go @@ -3,11 +3,13 @@ package sdk import ( "context" + "encoding/json" "fmt" "strings" "github.com/go-resty/resty/v2" "github.com/google/uuid" + "github.com/poyrazk/thecloud/internal/errors" ) // Client is the API client for the platform. @@ -50,6 +52,67 @@ type ErrorResponse struct { Message string `json:"message"` } +// APIErrorType maps API error type strings (case-insensitive) to internal error types. +var APIErrorType = map[string]errors.Type{ + "BUCKET_NOT_FOUND": errors.BucketNotFound, + "OBJECT_NOT_FOUND": errors.ObjectNotFound, + "INVALID_INPUT": errors.InvalidInput, + "BAD_REQUEST": errors.InvalidInput, + "NOT_FOUND": errors.NotFound, + "INTERNAL": errors.Internal, + "FORBIDDEN": errors.Forbidden, + "UNAUTHORIZED": errors.Unauthorized, + "CONFLICT": errors.Conflict, + "RESOURCE_LIMIT_EXCEEDED": errors.ResourceLimitExceeded, + "QUOTA_EXCEEDED": errors.QuotaExceeded, + "NOT_IMPLEMENTED": errors.NotImplemented, + "PORT_CONFLICT": errors.PortConflict, + "TOO_MANY_PORTS": errors.TooManyPorts, + "INSTANCE_NOT_RUNNING": errors.InstanceNotRunning, + "LB_NOT_FOUND": errors.LBNotFound, + "LB_TARGET_EXISTS": errors.LBTargetExists, + "LB_CROSS_VPC": errors.LBCrossVPC, + "PERMISSION_DENIED": errors.PermissionDenied, + // Lowercase variants + "bucket_not_found": errors.BucketNotFound, + "object_not_found": errors.ObjectNotFound, + "invalid_input": errors.InvalidInput, + "bad_request": errors.InvalidInput, + "not_found": errors.NotFound, + "internal": errors.Internal, + "forbidden": errors.Forbidden, + "unauthorized": errors.Unauthorized, + "conflict": errors.Conflict, + "resource_limit_exceeded": errors.ResourceLimitExceeded, + "quota_exceeded": errors.QuotaExceeded, + "not_implemented": errors.NotImplemented, + "port_conflict": errors.PortConflict, + "too_many_ports": errors.TooManyPorts, + "instance_not_running": errors.InstanceNotRunning, + "lb_not_found": errors.LBNotFound, + "lb_target_exists": errors.LBTargetExists, + "lb_cross_vpc": errors.LBCrossVPC, + "permission_denied": errors.PermissionDenied, +} + +// parseAPIError parses the response body as an error response and returns a typed error. +func parseAPIError(body []byte) error { + var apiResp Response[any] + if err := json.Unmarshal(body, &apiResp); err != nil { + return errors.New(errors.Internal, fmt.Sprintf("api error: %s", string(body))) + } + if apiResp.Error == nil { + return errors.New(errors.Internal, fmt.Sprintf("api error: %s", string(body))) + } + + errType := errors.Internal + if t, ok := APIErrorType[apiResp.Error.Type]; ok { + errType = t + } + + return errors.New(errType, apiResp.Error.Message) +} + // get performs a GET request against the API. func (c *Client) get(path string, result interface{}) error { return c.getContext(context.Background(), path, result) @@ -66,7 +129,7 @@ func (c *Client) getContext(ctx context.Context, path string, result interface{} } if resp.IsError() { - return fmt.Errorf(errAPIError, resp.String()) + return parseAPIError(resp.Body()) } return nil @@ -95,7 +158,7 @@ func (c *Client) postContext(ctx context.Context, path string, body interface{}, } if resp.IsError() { - return fmt.Errorf(errAPIError, resp.String()) + return parseAPIError(resp.Body()) } return nil @@ -121,7 +184,7 @@ func (c *Client) deleteWithContext(ctx context.Context, path string, result inte } if resp.IsError() { - return fmt.Errorf(errAPIError, resp.String()) + return parseAPIError(resp.Body()) } return nil @@ -146,7 +209,7 @@ func (c *Client) putWithContext(ctx context.Context, path string, body interface } if resp.IsError() { - return fmt.Errorf(errAPIError, resp.String()) + return parseAPIError(resp.Body()) } return nil @@ -171,7 +234,7 @@ func (c *Client) patchWithContext(ctx context.Context, path string, body interfa } if resp.IsError() { - return fmt.Errorf(errAPIError, resp.String()) + return parseAPIError(resp.Body()) } return nil diff --git a/pkg/sdk/client_test.go b/pkg/sdk/client_test.go index c59d585e..442ec746 100644 --- a/pkg/sdk/client_test.go +++ b/pkg/sdk/client_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "testing" + "github.com/poyrazk/thecloud/internal/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -190,3 +191,106 @@ func TestResolveID_ListError(t *testing.T) { "abc") require.Error(t, err) } + +func TestParseAPIError_BucketNotFound(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "BUCKET_NOT_FOUND", "message": "bucket not found"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.BucketNotFound), "expected BucketNotFound, got: %v", err) + assert.Contains(t, err.Error(), "bucket not found") +} + +func TestParseAPIError_NotFound(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "NOT_FOUND", "message": "resource not found"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.NotFound), "expected NotFound, got: %v", err) + assert.Contains(t, err.Error(), "resource not found") +} + +func TestParseAPIError_InvalidInput(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "INVALID_INPUT", "message": "invalid input provided"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.InvalidInput), "expected InvalidInput, got: %v", err) + assert.Contains(t, err.Error(), "invalid input provided") +} + +func TestParseAPIError_Forbidden(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "FORBIDDEN", "message": "access denied"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.Forbidden), "expected Forbidden, got: %v", err) + assert.Contains(t, err.Error(), "access denied") +} + +func TestParseAPIError_LowercaseType(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "not_found", "message": "item not found"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.NotFound), "expected NotFound (lowercase), got: %v", err) +} + +func TestParseAPIError_UnknownType(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "SOME_UNKNOWN_ERROR", "message": "something went wrong"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.Internal), "expected Internal for unknown type, got: %v", err) + assert.Contains(t, err.Error(), "something went wrong") +} + +func TestParseAPIError_InvalidJSON(t *testing.T) { + jsonBody := []byte(`not valid json`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.Internal), "expected Internal for invalid JSON, got: %v", err) +} + +func TestParseAPIError_MissingErrorField(t *testing.T) { + jsonBody := []byte(`{"data": "something"}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.Internal), "expected Internal for missing error field, got: %v", err) +} + +func TestClientGet_ParsesAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error": {"type": "BUCKET_NOT_FOUND", "message": "bucket not found"}}`)) + })) + defer server.Close() + + client := NewClient(server.URL, clientTestAPIKey) + var res Response[any] + err := client.get("/buckets/test", &res) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.BucketNotFound), "expected BucketNotFound, got: %v", err) +} + +func TestClientPost_ParsesAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": {"type": "INVALID_INPUT", "message": "missing required field"}}`)) + })) + defer server.Close() + + client := NewClient(server.URL, clientTestAPIKey) + var res Response[any] + err := client.post("/test", map[string]string{"a": "b"}, &res) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.InvalidInput), "expected InvalidInput, got: %v", err) +} + +func TestClientDelete_ParsesAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error": {"type": "FORBIDDEN", "message": "cannot delete"}}`)) + })) + defer server.Close() + + client := NewClient(server.URL, clientTestAPIKey) + var res Response[any] + err := client.delete("/test/123", &res) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.Forbidden), "expected Forbidden, got: %v", err) +} diff --git a/pkg/sdk/compute_test.go b/pkg/sdk/compute_test.go index 14f1388a..fdaf5e12 100644 --- a/pkg/sdk/compute_test.go +++ b/pkg/sdk/compute_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "testing" + "github.com/poyrazk/thecloud/internal/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -229,7 +230,7 @@ func TestClientAPIError(t *testing.T) { _, err := client.ListInstances() require.Error(t, err) - assert.Contains(t, err.Error(), "api error") + assert.True(t, errors.Is(err, errors.InvalidInput), "expected InvalidInput error, got: %v", err) assert.Contains(t, err.Error(), "invalid input") }