diff --git a/godo.go b/godo.go index 86d4255..9ce7362 100644 --- a/godo.go +++ b/godo.go @@ -88,6 +88,7 @@ type Client struct { ReservedIPV6Actions ReservedIPV6ActionsService Sizes SizesService Snapshots SnapshotsService + SpacesKeys SpacesKeysService Storage StorageService StorageActions StorageActionsService Tags TagsService @@ -303,6 +304,7 @@ func NewClient(httpClient *http.Client) *Client { c.ReservedIPV6Actions = &ReservedIPV6ActionsServiceOp{client: c} c.Sizes = &SizesServiceOp{client: c} c.Snapshots = &SnapshotsServiceOp{client: c} + c.SpacesKeys = &SpacesKeysServiceOp{client: c} c.Storage = &StorageServiceOp{client: c} c.StorageActions = &StorageActionsServiceOp{client: c} c.Tags = &TagsServiceOp{client: c} diff --git a/spaces_keys.go b/spaces_keys.go new file mode 100644 index 0000000..371d8a1 --- /dev/null +++ b/spaces_keys.go @@ -0,0 +1,165 @@ +package godo + +import ( + "context" + "fmt" + "net/http" +) + +const spacesKeysBasePath = "v2/spaces/keys" + +// SpacesKeysService is an interface for managing Spaces keys with the DigitalOcean API. +type SpacesKeysService interface { + List(context.Context, *ListOptions) ([]*SpacesKey, *Response, error) + Update(context.Context, string, *SpacesKeyUpdateRequest) (*SpacesKey, *Response, error) + Create(context.Context, *SpacesKeyCreateRequest) (*SpacesKey, *Response, error) + Delete(context.Context, string) (*Response, error) +} + +// SpacesKeysServiceOp handles communication with the Spaces key related methods of the +// DigitalOcean API. +type SpacesKeysServiceOp struct { + client *Client +} + +var _ SpacesKeysService = &SpacesKeysServiceOp{} + +// SpacesKeyPermission represents a permission for a Spaces grant +type SpacesKeyPermission string + +const ( + // SpacesKeyRead grants read-only access to the Spaces bucket + SpacesKeyRead SpacesKeyPermission = "read" + // SpacesKeyReadWrite grants read and write access to the Spaces bucket + SpacesKeyReadWrite SpacesKeyPermission = "readwrite" + // SpacesKeyFullAccess grants full access to the Spaces bucket + SpacesKeyFullAccess SpacesKeyPermission = "fullaccess" +) + +// Grant represents a Grant for a Spaces key +type Grant struct { + Bucket string `json:"bucket"` + Permission SpacesKeyPermission `json:"permission"` +} + +// SpacesKey represents a DigitalOcean Spaces key +type SpacesKey struct { + Name string `json:"name"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + Grants []*Grant `json:"grants"` + CreatedAt string `json:"created_at"` +} + +// SpacesKeyRoot represents a response from the DigitalOcean API +type spacesKeyRoot struct { + Key *SpacesKey `json:"key"` +} + +// SpacesKeyCreateRequest represents a request to create a Spaces key. +type SpacesKeyCreateRequest struct { + Name string `json:"name"` + Grants []*Grant `json:"grants"` +} + +// SpacesKeyUpdateRequest represents a request to update a Spaces key. +type SpacesKeyUpdateRequest struct { + Name string `json:"name"` + Grants []*Grant `json:"grants"` +} + +// spacesListKeysRoot represents a response from the DigitalOcean API +type spacesListKeysRoot struct { + Keys []*SpacesKey `json:"keys,omitempty"` + Links *Links `json:"links,omitempty"` + Meta *Meta `json:"meta"` +} + +// Create creates a new Spaces key. +func (s *SpacesKeysServiceOp) Create(ctx context.Context, createRequest *SpacesKeyCreateRequest) (*SpacesKey, *Response, error) { + if createRequest == nil { + return nil, nil, NewArgError("createRequest", "cannot be nil") + } + + req, err := s.client.NewRequest(ctx, http.MethodPost, spacesKeysBasePath, createRequest) + if err != nil { + return nil, nil, err + } + + root := new(spacesKeyRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Key, resp, nil +} + +// Delete deletes a Spaces key. +func (s *SpacesKeysServiceOp) Delete(ctx context.Context, accessKey string) (*Response, error) { + if accessKey == "" { + return nil, NewArgError("accessKey", "cannot be empty") + } + + path := fmt.Sprintf("%s/%s", spacesKeysBasePath, accessKey) + req, err := s.client.NewRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return nil, err + } + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +// Update updates a Spaces key. +func (s *SpacesKeysServiceOp) Update(ctx context.Context, accessKey string, updateRequest *SpacesKeyUpdateRequest) (*SpacesKey, *Response, error) { + if accessKey == "" { + return nil, nil, NewArgError("accessKey", "cannot be empty") + } + if updateRequest == nil { + return nil, nil, NewArgError("updateRequest", "cannot be nil") + } + + path := fmt.Sprintf("%s/%s", spacesKeysBasePath, accessKey) + req, err := s.client.NewRequest(ctx, http.MethodPut, path, updateRequest) + if err != nil { + return nil, nil, err + } + root := new(spacesKeyRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Key, resp, nil +} + +// List returns a list of Spaces keys. +func (s *SpacesKeysServiceOp) List(ctx context.Context, opts *ListOptions) ([]*SpacesKey, *Response, error) { + path, err := addOptions(spacesKeysBasePath, opts) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(spacesListKeysRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + if root.Links != nil { + resp.Links = root.Links + } + if root.Meta != nil { + resp.Meta = root.Meta + } + + return root.Keys, resp, nil +} diff --git a/spaces_keys_test.go b/spaces_keys_test.go new file mode 100644 index 0000000..3b81254 --- /dev/null +++ b/spaces_keys_test.go @@ -0,0 +1,145 @@ +package godo + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSpacesKeyCreate(t *testing.T) { + setup() + defer teardown() + + createRequest := &SpacesKeyCreateRequest{ + Name: "test-key", + Grants: []*Grant{ + { + Bucket: "test-bucket", + Permission: SpacesKeyRead, + }, + }, + } + + mux.HandleFunc("/v2/spaces/keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"key":{"name":"test-key","access_key":"test-access-key","secret_key":"test-secret-key","created_at":"2023-10-01T00:00:00Z","grants":[{"bucket":"test-bucket","permission":"read"}]}}`) + }) + + key, resp, err := client.SpacesKeys.Create(context.Background(), createRequest) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, "test-key", key.Name) + assert.Equal(t, "test-access-key", key.AccessKey) + assert.Equal(t, "test-secret-key", key.SecretKey) + assert.Len(t, key.Grants, 1) + assert.Equal(t, "test-bucket", key.Grants[0].Bucket) + assert.Equal(t, SpacesKeyRead, key.Grants[0].Permission) +} + +func TestSpacesKeyDelete(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/spaces/keys/test-access-key", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := client.SpacesKeys.Delete(context.Background(), "test-access-key") + assert.NoError(t, err) + assert.NotNil(t, resp) +} + +func TestSpacesKeyUpdate(t *testing.T) { + setup() + defer teardown() + + updateRequest := &SpacesKeyUpdateRequest{ + Name: "updated-key", + Grants: []*Grant{ + { + Bucket: "updated-bucket", + Permission: SpacesKeyReadWrite, + }, + }, + } + + mux.HandleFunc("/v2/spaces/keys/test-access-key", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"key":{"name":"updated-key","access_key":"test-access-key","created_at":"2023-10-01T00:00:00Z","grants":[{"bucket":"updated-bucket","permission":"readwrite"}]}}`) + }) + + key, resp, err := client.SpacesKeys.Update(context.Background(), "test-access-key", updateRequest) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, "updated-key", key.Name) + assert.Equal(t, "test-access-key", key.AccessKey) + assert.Len(t, key.Grants, 1) + assert.Equal(t, "updated-bucket", key.Grants[0].Bucket) + assert.Equal(t, SpacesKeyReadWrite, key.Grants[0].Permission) +} + +func TestSpacesKeyList(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/spaces/keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"keys":[{"name":"test-key","access_key":"test-access-key","created_at":"2023-10-01T00:00:00Z","grants":[{"bucket":"test-bucket","permission":"read"}]}]}`) + }) + + keys, resp, err := client.SpacesKeys.List(context.Background(), nil) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Len(t, keys, 1) + assert.Equal(t, "test-key", keys[0].Name) + assert.Equal(t, "test-access-key", keys[0].AccessKey) + assert.Len(t, keys[0].Grants, 1) + assert.Equal(t, "test-bucket", keys[0].Grants[0].Bucket) + assert.Equal(t, SpacesKeyRead, keys[0].Grants[0].Permission) +} + +func TestSpacesKeyList_Pagination(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/spaces/keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + page := r.URL.Query().Get("page") + if page == "2" { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"keys":[{"name":"test-key-2","access_key":"test-access-key-2","created_at":"2023-10-02T00:00:00Z","grants":[{"bucket":"test-bucket-2","permission":"readwrite"}]}]}`) + } else { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"keys":[{"name":"test-key-1","access_key":"test-access-key-1","created_at":"2023-10-01T00:00:00Z","grants":[{"bucket":"test-bucket-1","permission":"read"}]}]}`) + } + }) + + // Test first page + keys, resp, err := client.SpacesKeys.List(context.Background(), &ListOptions{Page: 1}) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Len(t, keys, 1) + assert.Equal(t, "test-key-1", keys[0].Name) + assert.Equal(t, "test-access-key-1", keys[0].AccessKey) + assert.Len(t, keys[0].Grants, 1) + assert.Equal(t, "test-bucket-1", keys[0].Grants[0].Bucket) + assert.Equal(t, SpacesKeyRead, keys[0].Grants[0].Permission) + + // Test second page + keys, resp, err = client.SpacesKeys.List(context.Background(), &ListOptions{Page: 2}) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Len(t, keys, 1) + assert.Equal(t, "test-key-2", keys[0].Name) + assert.Equal(t, "test-access-key-2", keys[0].AccessKey) + assert.Len(t, keys[0].Grants, 1) + assert.Equal(t, "test-bucket-2", keys[0].Grants[0].Bucket) + assert.Equal(t, SpacesKeyReadWrite, keys[0].Grants[0].Permission) +}