diff --git a/droplets.go b/droplets.go index 2ec2bbe4..c17bc3bc 100644 --- a/droplets.go +++ b/droplets.go @@ -55,6 +55,7 @@ type Droplet struct { Created string `json:"created_at,omitempty"` Kernel *Kernel `json:"kernel,omitempty"` Tags []string `json:"tags,ommitempty"` + VolumeIDs []string `json:"volumes"` } // PublicIPv4 returns the public IPv4 address for the Droplet. @@ -146,6 +147,27 @@ type DropletCreateImage struct { Slug string } +// DropletCreateVolume identifies a volume to attach for the create request. It +// prefers Name over ID, +type DropletCreateVolume struct { + ID string + Name string +} + +// MarshalJSON returns an object with either the name or id of the volume. It +// returns the id if the name is empty. +func (d DropletCreateVolume) MarshalJSON() ([]byte, error) { + if d.Name != "" { + return json.Marshal(struct { + Name string `json:"name"` + }{Name: d.Name}) + } + + return json.Marshal(struct { + ID string `json:"id"` + }{ID: d.ID}) +} + // MarshalJSON returns either the slug or id of the image. It returns the id // if the slug is empty. func (d DropletCreateImage) MarshalJSON() ([]byte, error) { @@ -183,6 +205,7 @@ type DropletCreateRequest struct { IPv6 bool `json:"ipv6"` PrivateNetworking bool `json:"private_networking"` UserData string `json:"user_data,omitempty"` + Volumes []DropletCreateVolume `json:"volumes,omitempty"` } // DropletMultiCreateRequest is a request to create multiple droplets. diff --git a/droplets_test.go b/droplets_test.go index 60423dbe..a2503092 100644 --- a/droplets_test.go +++ b/droplets_test.go @@ -24,7 +24,7 @@ func TestDroplets_ListDroplets(t *testing.T) { expected := []Droplet{{ID: 1}, {ID: 2}} if !reflect.DeepEqual(droplets, expected) { - t.Errorf("Droplets.List returned %+v, expected %+v", droplets, expected) + t.Errorf("Droplets.List\n got=%#v\nwant=%#v", droplets, expected) } } @@ -132,7 +132,7 @@ func TestDroplets_GetDroplet(t *testing.T) { expected := &Droplet{ID: 12345} if !reflect.DeepEqual(droplets, expected) { - t.Errorf("Droplets.Get returned %+v, expected %+v", droplets, expected) + t.Errorf("Droplets.Get\n got=%#v\nwant=%#v", droplets, expected) } } @@ -147,6 +147,11 @@ func TestDroplets_Create(t *testing.T) { Image: DropletCreateImage{ ID: 1, }, + Volumes: []DropletCreateVolume{ + {Name: "hello-im-a-volume"}, + {ID: "hello-im-another-volume"}, + {Name: "hello-im-still-a-volume", ID: "should be ignored due to Name"}, + }, } mux.HandleFunc("/v2/droplets", func(w http.ResponseWriter, r *http.Request) { @@ -159,6 +164,11 @@ func TestDroplets_Create(t *testing.T) { "backups": false, "ipv6": false, "private_networking": false, + "volumes": []interface{}{ + map[string]interface{}{"name": "hello-im-a-volume"}, + map[string]interface{}{"id": "hello-im-another-volume"}, + map[string]interface{}{"name": "hello-im-still-a-volume"}, + }, } var v map[string]interface{} @@ -168,7 +178,7 @@ func TestDroplets_Create(t *testing.T) { } if !reflect.DeepEqual(v, expected) { - t.Errorf("Request body = %#v, expected %#v", v, expected) + t.Errorf("Request body\n got=%#v\nwant=%#v", v, expected) } fmt.Fprintf(w, `{"droplet":{"id":1}, "links":{"actions": [{"id": 1, "href": "http://example.com", "rel": "create"}]}}`) @@ -293,7 +303,7 @@ func TestDroplets_Kernels(t *testing.T) { expected := []Kernel{{ID: 1}, {ID: 2}} if !reflect.DeepEqual(kernels, expected) { - t.Errorf("Droplets.Kernels returned %+v, expected %+v", kernels, expected) + t.Errorf("Droplets.Kernels\n got=%#v\nwant=%#v", kernels, expected) } } @@ -314,7 +324,7 @@ func TestDroplets_Snapshots(t *testing.T) { expected := []Image{{ID: 1}, {ID: 2}} if !reflect.DeepEqual(snapshots, expected) { - t.Errorf("Droplets.Snapshots returned %+v, expected %+v", snapshots, expected) + t.Errorf("Droplets.Snapshots\n got=%#v\nwant=%#v", snapshots, expected) } } @@ -335,7 +345,7 @@ func TestDroplets_Backups(t *testing.T) { expected := []Image{{ID: 1}, {ID: 2}} if !reflect.DeepEqual(backups, expected) { - t.Errorf("Droplets.Backups returned %+v, expected %+v", backups, expected) + t.Errorf("Droplets.Backups\n got=%#v\nwant=%#v", backups, expected) } } @@ -356,7 +366,7 @@ func TestDroplets_Actions(t *testing.T) { expected := []Action{{ID: 1}, {ID: 2}} if !reflect.DeepEqual(actions, expected) { - t.Errorf("Droplets.Actions returned %+v, expected %+v", actions, expected) + t.Errorf("Droplets.Actions\n got=%#v\nwant=%#v", actions, expected) } } @@ -376,7 +386,7 @@ func TestDroplets_Neighbors(t *testing.T) { expected := []Droplet{{ID: 1}, {ID: 2}} if !reflect.DeepEqual(neighbors, expected) { - t.Errorf("Droplets.Neighbors returned %+v, expected %+v", neighbors, expected) + t.Errorf("Droplets.Neighbors\n got=%#v\nwant=%#v", neighbors, expected) } } @@ -390,7 +400,7 @@ func TestNetworkV4_String(t *testing.T) { stringified := network.String() expected := `godo.NetworkV4{IPAddress:"192.168.1.2", Netmask:"255.255.255.0", Gateway:"192.168.1.1", Type:""}` if expected != stringified { - t.Errorf("NetworkV4.String returned %+v, expected %+v", stringified, expected) + t.Errorf("NetworkV4.String\n got=%#v\nwant=%#v", stringified, expected) } } @@ -404,7 +414,7 @@ func TestNetworkV6_String(t *testing.T) { stringified := network.String() expected := `godo.NetworkV6{IPAddress:"2604:A880:0800:0010:0000:0000:02DD:4001", Netmask:64, Gateway:"2604:A880:0800:0010:0000:0000:0000:0001", Type:""}` if expected != stringified { - t.Errorf("NetworkV6.String returned %+v, expected %+v", stringified, expected) + t.Errorf("NetworkV6.String\n got=%#v\nwant=%#v", stringified, expected) } } diff --git a/godo.go b/godo.go index 65a816a9..78fc8be2 100644 --- a/godo.go +++ b/godo.go @@ -55,6 +55,8 @@ type Client struct { Sizes SizesService FloatingIPs FloatingIPsService FloatingIPActions FloatingIPActionsService + Storage StorageService + StorageActions StorageActionsService Tags TagsService // Optional function called after every successful request made to the DO APIs @@ -94,7 +96,10 @@ type ErrorResponse struct { Response *http.Response // Error message - Message string + Message string `json:"message"` + + // RequestID returned from the API, useful to contact support. + RequestID string `json:"request_id"` } // Rate contains the rate limit for the current client. @@ -157,6 +162,8 @@ func NewClient(httpClient *http.Client) *Client { c.Sizes = &SizesServiceOp{client: c} c.FloatingIPs = &FloatingIPsServiceOp{client: c} c.FloatingIPActions = &FloatingIPActionsServiceOp{client: c} + c.Storage = &StorageServiceOp{client: c} + c.StorageActions = &StorageActionsServiceOp{client: c} c.Tags = &TagsServiceOp{client: c} return c @@ -318,6 +325,10 @@ func (c *Client) Do(req *http.Request, v interface{}) (*Response, error) { return response, err } func (r *ErrorResponse) Error() string { + if r.RequestID != "" { + return fmt.Sprintf("%v %v: %d (request %q) %v", + r.Response.Request.Method, r.Response.Request.URL, r.Response.StatusCode, r.RequestID, r.Message) + } return fmt.Sprintf("%v %v: %d %v", r.Response.Request.Method, r.Response.Request.URL, r.Response.StatusCode, r.Message) } diff --git a/storage.go b/storage.go new file mode 100644 index 00000000..5667ff7b --- /dev/null +++ b/storage.go @@ -0,0 +1,252 @@ +package godo + +import ( + "fmt" + "time" +) + +const ( + storageBasePath = "v2" + storageAllocPath = storageBasePath + "/volumes" + storageSnapPath = storageBasePath + "/snapshots" +) + +// StorageService is an interface for interfacing with the storage +// endpoints of the Digital Ocean API. +// See: https://developers.digitalocean.com/documentation/v2#storage +type StorageService interface { + ListVolumes(*ListOptions) ([]Volume, *Response, error) + GetVolume(string) (*Volume, *Response, error) + CreateVolume(*VolumeCreateRequest) (*Volume, *Response, error) + DeleteVolume(string) (*Response, error) +} + +// BetaStorageService is an interface for the storage services that are +// not yet stable. The interface is not exposed in the godo.Client and +// requires type-asserting the `StorageService` to make it available. +// +// Note that Beta features will change and compiling against those +// symbols (using type-assertion) is prone to breaking your build +// if you use our master. +type BetaStorageService interface { + StorageService + + ListSnapshots(volumeID string, opts *ListOptions) ([]Snapshot, *Response, error) + GetSnapshot(string) (*Snapshot, *Response, error) + CreateSnapshot(*SnapshotCreateRequest) (*Snapshot, *Response, error) + DeleteSnapshot(string) (*Response, error) +} + +// StorageServiceOp handles communication with the storage volumes related methods of the +// DigitalOcean API. +type StorageServiceOp struct { + client *Client +} + +var _ StorageService = &StorageServiceOp{} + +// Volume represents a Digital Ocean block store volume. +type Volume struct { + ID string `json:"id"` + Region *Region `json:"region"` + Name string `json:"name"` + SizeGigaBytes int64 `json:"size_gigabytes"` + Description string `json:"description"` + DropletIDs []int `json:"droplet_ids"` + CreatedAt time.Time `json:"created_at"` +} + +func (f Volume) String() string { + return Stringify(f) +} + +type storageVolumesRoot struct { + Volumes []Volume `json:"volumes"` + Links *Links `json:"links"` +} + +type storageVolumeRoot struct { + Volume *Volume `json:"volume"` + Links *Links `json:"links,omitempty"` +} + +// VolumeCreateRequest represents a request to create a block store +// volume. +type VolumeCreateRequest struct { + Region string `json:"region"` + Name string `json:"name"` + Description string `json:"description"` + SizeGigaBytes int64 `json:"size_gigabytes"` +} + +// ListVolumes lists all storage volumes. +func (svc *StorageServiceOp) ListVolumes(opt *ListOptions) ([]Volume, *Response, error) { + path, err := addOptions(storageAllocPath, opt) + if err != nil { + return nil, nil, err + } + + req, err := svc.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + root := new(storageVolumesRoot) + resp, err := svc.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + if l := root.Links; l != nil { + resp.Links = l + } + + return root.Volumes, resp, nil +} + +// CreateVolume creates a storage volume. The name must be unique. +func (svc *StorageServiceOp) CreateVolume(createRequest *VolumeCreateRequest) (*Volume, *Response, error) { + path := storageAllocPath + + req, err := svc.client.NewRequest("POST", path, createRequest) + if err != nil { + return nil, nil, err + } + + root := new(storageVolumeRoot) + resp, err := svc.client.Do(req, root) + if err != nil { + return nil, resp, err + } + return root.Volume, resp, nil +} + +// GetVolume retrieves an individual storage volume. +func (svc *StorageServiceOp) GetVolume(id string) (*Volume, *Response, error) { + path := fmt.Sprintf("%s/%s", storageAllocPath, id) + + req, err := svc.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + root := new(storageVolumeRoot) + resp, err := svc.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return root.Volume, resp, nil +} + +// DeleteVolume deletes a storage volume. +func (svc *StorageServiceOp) DeleteVolume(id string) (*Response, error) { + path := fmt.Sprintf("%s/%s", storageAllocPath, id) + + req, err := svc.client.NewRequest("DELETE", path, nil) + if err != nil { + return nil, err + } + return svc.client.Do(req, nil) +} + +// Snapshot represents a Digital Ocean block store snapshot. +type Snapshot struct { + ID string `json:"id"` + VolumeID string `json:"volume_id"` + Region *Region `json:"region"` + Name string `json:"name"` + SizeGigaBytes int64 `json:"size_gigabytes"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` +} + +type storageSnapsRoot struct { + Snapshots []Snapshot `json:"snapshots"` + Links *Links `json:"links"` +} + +type storageSnapRoot struct { + Snapshot *Snapshot `json:"snapshot"` + Links *Links `json:"links,omitempty"` +} + +// SnapshotCreateRequest represents a request to create a block store +// volume. +type SnapshotCreateRequest struct { + VolumeID string `json:"volume_id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// ListSnapshots lists all snapshots related to a storage volume. +func (svc *StorageServiceOp) ListSnapshots(volumeID string, opt *ListOptions) ([]Snapshot, *Response, error) { + path := fmt.Sprintf("%s/%s/snapshots", storageAllocPath, volumeID) + path, err := addOptions(path, opt) + if err != nil { + return nil, nil, err + } + + req, err := svc.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + root := new(storageSnapsRoot) + resp, err := svc.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + if l := root.Links; l != nil { + resp.Links = l + } + + return root.Snapshots, resp, nil +} + +// CreateSnapshot creates a snapshot of a storage volume. +func (svc *StorageServiceOp) CreateSnapshot(createRequest *SnapshotCreateRequest) (*Snapshot, *Response, error) { + path := fmt.Sprintf("%s/%s/snapshots", storageAllocPath, createRequest.VolumeID) + + req, err := svc.client.NewRequest("POST", path, createRequest) + if err != nil { + return nil, nil, err + } + + root := new(storageSnapRoot) + resp, err := svc.client.Do(req, root) + if err != nil { + return nil, resp, err + } + return root.Snapshot, resp, nil +} + +// GetSnapshot retrieves an individual snapshot. +func (svc *StorageServiceOp) GetSnapshot(id string) (*Snapshot, *Response, error) { + path := fmt.Sprintf("%s/%s", storageSnapPath, id) + + req, err := svc.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + root := new(storageSnapRoot) + resp, err := svc.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return root.Snapshot, resp, nil +} + +// DeleteSnapshot deletes a snapshot. +func (svc *StorageServiceOp) DeleteSnapshot(id string) (*Response, error) { + path := fmt.Sprintf("%s/%s", storageSnapPath, id) + + req, err := svc.client.NewRequest("DELETE", path, nil) + if err != nil { + return nil, err + } + return svc.client.Do(req, nil) +} diff --git a/storage_actions.go b/storage_actions.go new file mode 100644 index 00000000..20dc4aa5 --- /dev/null +++ b/storage_actions.go @@ -0,0 +1,61 @@ +package godo + +import "fmt" + +// StorageActionsService is an interface for interfacing with the +// storage actions endpoints of the Digital Ocean API. +// See: https://developers.digitalocean.com/documentation/v2#storage-actions +type StorageActionsService interface { + Attach(volumeID string, dropletID int) (*Action, *Response, error) + Detach(volumeID string) (*Action, *Response, error) +} + +// StorageActionsServiceOp handles communication with the floating IPs +// action related methods of the DigitalOcean API. +type StorageActionsServiceOp struct { + client *Client +} + +// StorageAttachment represents the attachement of a block storage +// volume to a specific droplet under the device name. +type StorageAttachment struct { + DropletID int `json:"droplet_id"` +} + +// Attach a storage volume to a droplet. +func (s *StorageActionsServiceOp) Attach(volumeID string, dropletID int) (*Action, *Response, error) { + request := &ActionRequest{ + "type": "attach", + "droplet_id": dropletID, + } + return s.doAction(volumeID, request) +} + +// Detach a storage volume from a droplet. +func (s *StorageActionsServiceOp) Detach(volumeID string) (*Action, *Response, error) { + request := &ActionRequest{ + "type": "detach", + } + return s.doAction(volumeID, request) +} + +func (s *StorageActionsServiceOp) doAction(volumeID string, request *ActionRequest) (*Action, *Response, error) { + path := storageAllocationActionPath(volumeID) + + req, err := s.client.NewRequest("POST", path, request) + if err != nil { + return nil, nil, err + } + + root := new(actionRoot) + resp, err := s.client.Do(req, root) + if err != nil { + return nil, resp, err + } + + return &root.Event, resp, err +} + +func storageAllocationActionPath(volumeID string) string { + return fmt.Sprintf("%s/%s/actions", storageAllocPath, volumeID) +} diff --git a/storage_actions_test.go b/storage_actions_test.go new file mode 100644 index 00000000..14c62fbb --- /dev/null +++ b/storage_actions_test.go @@ -0,0 +1,73 @@ +package godo + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" +) + +func TestStoragesActions_Attach(t *testing.T) { + setup() + defer teardown() + const ( + volumeID = "98d414c6-295e-4e3a-ac58-eb9456c1e1d1" + dropletID = 12345 + ) + + attachRequest := &ActionRequest{ + "type": "attach", + "droplet_id": float64(dropletID), // encoding/json decodes numbers as floats + } + + mux.HandleFunc("/v2/volumes/"+volumeID+"/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + err := json.NewDecoder(r.Body).Decode(v) + if err != nil { + t.Fatalf("decode json: %v", err) + } + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, attachRequest) { + t.Errorf("want=%#v", attachRequest) + t.Errorf("got=%#v", v) + } + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + }) + + _, _, err := client.StorageActions.Attach(volumeID, dropletID) + if err != nil { + t.Errorf("StoragesActions.Attach returned error: %v", err) + } +} + +func TestStoragesActions_Detach(t *testing.T) { + setup() + defer teardown() + volumeID := "98d414c6-295e-4e3a-ac58-eb9456c1e1d1" + + detachRequest := &ActionRequest{ + "type": "detach", + } + + mux.HandleFunc("/v2/volumes/"+volumeID+"/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + err := json.NewDecoder(r.Body).Decode(v) + if err != nil { + t.Fatalf("decode json: %v", err) + } + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, detachRequest) { + t.Errorf("want=%#v", detachRequest) + t.Errorf("got=%#v", v) + } + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + }) + + _, _, err := client.StorageActions.Detach(volumeID) + if err != nil { + t.Errorf("StoragesActions.Detach returned error: %v", err) + } +} diff --git a/storage_test.go b/storage_test.go new file mode 100644 index 00000000..b1555c04 --- /dev/null +++ b/storage_test.go @@ -0,0 +1,394 @@ +package godo + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" + "time" +) + +func TestStorageVolumes_ListStorageVolumes(t *testing.T) { + setup() + defer teardown() + + jBlob := ` + { + "volumes": [ + { + "user_id": 42, + "region": {"slug": "nyc3"}, + "id": "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + "name": "my volume", + "description": "my description", + "size_gigabytes": 100, + "droplet_ids": [10], + "created_at": "2002-10-02T15:00:00.05Z" + }, + { + "user_id": 42, + "region": {"slug": "nyc3"}, + "id": "96d414c6-295e-4e3a-ac59-eb9456c1e1d1", + "name": "my other volume", + "description": "my other description", + "size_gigabytes": 100, + "created_at": "2012-10-03T15:00:01.05Z" + } + ], + "links": { + "pages": { + "last": "https://api.digitalocean.com/v2/volumes?page=2", + "next": "https://api.digitalocean.com/v2/volumes?page=2" + } + }, + "meta": { + "total": 28 + } + }` + + mux.HandleFunc("/v2/volumes/", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, jBlob) + }) + + volumes, _, err := client.Storage.ListVolumes(nil) + if err != nil { + t.Errorf("Storage.ListVolumes returned error: %v", err) + } + + expected := []Volume{ + { + Region: &Region{Slug: "nyc3"}, + ID: "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + Name: "my volume", + Description: "my description", + SizeGigaBytes: 100, + DropletIDs: []int{10}, + CreatedAt: time.Date(2002, 10, 02, 15, 00, 00, 50000000, time.UTC), + }, + { + Region: &Region{Slug: "nyc3"}, + ID: "96d414c6-295e-4e3a-ac59-eb9456c1e1d1", + Name: "my other volume", + Description: "my other description", + SizeGigaBytes: 100, + CreatedAt: time.Date(2012, 10, 03, 15, 00, 01, 50000000, time.UTC), + }, + } + if !reflect.DeepEqual(volumes, expected) { + t.Errorf("Storage.ListVolumes returned %+v, expected %+v", volumes, expected) + } +} + +func TestStorageVolumes_Get(t *testing.T) { + setup() + defer teardown() + want := &Volume{ + Region: &Region{Slug: "nyc3"}, + ID: "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + Name: "my volume", + Description: "my description", + SizeGigaBytes: 100, + CreatedAt: time.Date(2002, 10, 02, 15, 00, 00, 50000000, time.UTC), + } + jBlob := `{ + "volume":{ + "region": {"slug":"nyc3"}, + "attached_to_droplet": null, + "id": "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + "name": "my volume", + "description": "my description", + "size_gigabytes": 100, + "created_at": "2002-10-02T15:00:00.05Z" + }, + "links": { + "pages": { + "last": "https://api.digitalocean.com/v2/volumes?page=2", + "next": "https://api.digitalocean.com/v2/volumes?page=2" + } + }, + "meta": { + "total": 28 + } + }` + + mux.HandleFunc("/v2/volumes/80d414c6-295e-4e3a-ac58-eb9456c1e1d1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, jBlob) + }) + + got, _, err := client.Storage.GetVolume("80d414c6-295e-4e3a-ac58-eb9456c1e1d1") + if err != nil { + t.Errorf("Storage.GetVolume returned error: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Storage.GetVolume returned %+v, want %+v", got, want) + } +} + +func TestStorageVolumes_Create(t *testing.T) { + setup() + defer teardown() + + createRequest := &VolumeCreateRequest{ + Region: "nyc3", + Name: "my volume", + Description: "my description", + SizeGigaBytes: 100, + } + + want := &Volume{ + Region: &Region{Slug: "nyc3"}, + ID: "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + Name: "my volume", + Description: "my description", + SizeGigaBytes: 100, + CreatedAt: time.Date(2002, 10, 02, 15, 00, 00, 50000000, time.UTC), + } + jBlob := `{ + "volume":{ + "region": {"slug":"nyc3"}, + "id": "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + "name": "my volume", + "description": "my description", + "size_gigabytes": 100, + "created_at": "2002-10-02T15:00:00.05Z" + }, + "links": {} + }` + + mux.HandleFunc("/v2/volumes", func(w http.ResponseWriter, r *http.Request) { + v := new(VolumeCreateRequest) + err := json.NewDecoder(r.Body).Decode(v) + if err != nil { + t.Fatal(err) + } + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, createRequest) { + t.Errorf("Request body = %+v, expected %+v", v, createRequest) + } + + fmt.Fprint(w, jBlob) + }) + + got, _, err := client.Storage.CreateVolume(createRequest) + if err != nil { + t.Errorf("Storage.CreateVolume returned error: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Storage.CreateVolume returned %+v, want %+v", got, want) + } +} + +func TestStorageVolumes_Destroy(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/volumes/80d414c6-295e-4e3a-ac58-eb9456c1e1d1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + }) + + _, err := client.Storage.DeleteVolume("80d414c6-295e-4e3a-ac58-eb9456c1e1d1") + if err != nil { + t.Errorf("Storage.DeleteVolume returned error: %v", err) + } +} + +func TestStorageSnapshots_ListStorageSnapshots(t *testing.T) { + setup() + defer teardown() + + jBlob := ` + { + "snapshots": [ + { + "region": {"slug": "nyc3"}, + "id": "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + "volume_id": "98d414c6-295e-4e3a-ac58-eb9456c1e1d1", + "name": "my snapshot", + "description": "my description", + "size_gigabytes": 100, + "created_at": "2002-10-02T15:00:00.05Z" + }, + { + "region": {"slug": "nyc3"}, + "id": "96d414c6-295e-4e3a-ac59-eb9456c1e1d1", + "volume_id": "98d414c6-295e-4e3a-ac58-eb9456c1e1d1", + "name": "my other snapshot", + "description": "my other description", + "size_gigabytes": 100, + "created_at": "2012-10-03T15:00:01.05Z" + } + ], + "links": { + "pages": { + "last": "https://api.digitalocean.com/v2/volumes?page=2", + "next": "https://api.digitalocean.com/v2/volumes?page=2" + } + }, + "meta": { + "total": 28 + } + }` + + mux.HandleFunc("/v2/volumes/98d414c6-295e-4e3a-ac58-eb9456c1e1d1/snapshots", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, jBlob) + }) + + volumes, _, err := client.Storage.(BetaStorageService).ListSnapshots("98d414c6-295e-4e3a-ac58-eb9456c1e1d1", nil) + if err != nil { + t.Errorf("Storage.ListSnapshots returned error: %v", err) + } + + expected := []Snapshot{ + { + Region: &Region{Slug: "nyc3"}, + ID: "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + VolumeID: "98d414c6-295e-4e3a-ac58-eb9456c1e1d1", + Name: "my snapshot", + Description: "my description", + SizeGigaBytes: 100, + CreatedAt: time.Date(2002, 10, 02, 15, 00, 00, 50000000, time.UTC), + }, + { + Region: &Region{Slug: "nyc3"}, + ID: "96d414c6-295e-4e3a-ac59-eb9456c1e1d1", + VolumeID: "98d414c6-295e-4e3a-ac58-eb9456c1e1d1", + Name: "my other snapshot", + Description: "my other description", + SizeGigaBytes: 100, + CreatedAt: time.Date(2012, 10, 03, 15, 00, 01, 50000000, time.UTC), + }, + } + if !reflect.DeepEqual(volumes, expected) { + t.Errorf("Storage.ListSnapshots returned %+v, expected %+v", volumes, expected) + } +} + +func TestStorageSnapshots_Get(t *testing.T) { + setup() + defer teardown() + want := &Snapshot{ + Region: &Region{Slug: "nyc3"}, + ID: "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + VolumeID: "98d414c6-295e-4e3a-ac58-eb9456c1e1d1", + Name: "my snapshot", + Description: "my description", + SizeGigaBytes: 100, + CreatedAt: time.Date(2002, 10, 02, 15, 00, 00, 50000000, time.UTC), + } + jBlob := `{ + "snapshot":{ + "region": {"slug": "nyc3"}, + "id": "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + "volume_id": "98d414c6-295e-4e3a-ac58-eb9456c1e1d1", + "name": "my snapshot", + "description": "my description", + "size_gigabytes": 100, + "created_at": "2002-10-02T15:00:00.05Z" + }, + "links": { + "pages": { + "last": "https://api.digitalocean.com/v2/volumes/98d414c6-295e-4e3a-ac58-eb9456c1e1d1/snapshots?page=2", + "next": "https://api.digitalocean.com/v2/volumes/98d414c6-295e-4e3a-ac58-eb9456c1e1d1/snapshots?page=2" + } + }, + "meta": { + "total": 28 + } + }` + + mux.HandleFunc("/v2/snapshots/80d414c6-295e-4e3a-ac58-eb9456c1e1d1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, jBlob) + }) + + got, _, err := client.Storage.(BetaStorageService).GetSnapshot("80d414c6-295e-4e3a-ac58-eb9456c1e1d1") + if err != nil { + t.Errorf("Storage.GetSnapshot returned error: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Storage.GetSnapshot returned %+v, want %+v", got, want) + } +} + +func TestStorageSnapshots_Create(t *testing.T) { + setup() + defer teardown() + + createRequest := &SnapshotCreateRequest{ + VolumeID: "98d414c6-295e-4e3a-ac58-eb9456c1e1d1", + Name: "my snapshot", + Description: "my description", + } + + want := &Snapshot{ + Region: &Region{Slug: "nyc3"}, + ID: "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + VolumeID: "98d414c6-295e-4e3a-ac58-eb9456c1e1d1", + Name: "my snapshot", + Description: "my description", + SizeGigaBytes: 100, + CreatedAt: time.Date(2002, 10, 02, 15, 00, 00, 50000000, time.UTC), + } + jBlob := `{ + "snapshot":{ + "region": {"slug": "nyc3"}, + "id": "80d414c6-295e-4e3a-ac58-eb9456c1e1d1", + "volume_id": "98d414c6-295e-4e3a-ac58-eb9456c1e1d1", + "name": "my snapshot", + "description": "my description", + "size_gigabytes": 100, + "created_at": "2002-10-02T15:00:00.05Z" + }, + "links": { + "pages": { + "last": "https://api.digitalocean.com/v2/volumes/98d414c6-295e-4e3a-ac58-eb9456c1e1d1/snapshots?page=2", + "next": "https://api.digitalocean.com/v2/volumes/98d414c6-295e-4e3a-ac58-eb9456c1e1d1/snapshots?page=2" + } + }, + "meta": { + "total": 28 + } + }` + + mux.HandleFunc("/v2/volumes/98d414c6-295e-4e3a-ac58-eb9456c1e1d1/snapshots", func(w http.ResponseWriter, r *http.Request) { + v := new(SnapshotCreateRequest) + err := json.NewDecoder(r.Body).Decode(v) + if err != nil { + t.Fatal(err) + } + + testMethod(t, r, "POST") + if !reflect.DeepEqual(v, createRequest) { + t.Errorf("Request body = %+v, expected %+v", v, createRequest) + } + + fmt.Fprint(w, jBlob) + }) + + got, _, err := client.Storage.(BetaStorageService).CreateSnapshot(createRequest) + if err != nil { + t.Errorf("Storage.CreateSnapshot returned error: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Storage.CreateSnapshot returned %+v, want %+v", got, want) + } +} + +func TestStorageSnapshots_Destroy(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/snapshots/80d414c6-295e-4e3a-ac58-eb9456c1e1d1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + }) + + _, err := client.Storage.(BetaStorageService).DeleteSnapshot("80d414c6-295e-4e3a-ac58-eb9456c1e1d1") + if err != nil { + t.Errorf("Storage.DeleteSnapshot returned error: %v", err) + } +}