Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 68 additions & 5 deletions pkg/sdk/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
104 changes: 104 additions & 0 deletions pkg/sdk/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
3 changes: 2 additions & 1 deletion pkg/sdk/compute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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")
}

Expand Down
Loading