Repository: hetznercloud/hcloud-go
Pull request #15 (2017-12-11 17:08:50)

Add image client


Patch:
diff --git a/hcloud/client.go b/hcloud/client.go
index dfb65df..5cc8023 100644
--- a/hcloud/client.go
+++ b/hcloud/client.go
@@ -53,6 +53,7 @@ type Client struct {
 
 	Action     ActionClient
 	FloatingIP FloatingIPClient
+	Image      ImageClient
 	Server     ServerClient
 	ServerType ServerTypeClient
 	SSHKey     SSHKeyClient
@@ -96,9 +97,10 @@ func NewClient(options ...ClientOption) *Client {
 
 	client.Action = ActionClient{client: client}
 	client.FloatingIP = FloatingIPClient{client: client}
+	client.Image = ImageClient{client: client}
 	client.Server = ServerClient{client: client}
-	client.SSHKey = SSHKeyClient{client: client}
 	client.ServerType = ServerTypeClient{client: client}
+	client.SSHKey = SSHKeyClient{client: client}
 
 	return client
 }
diff --git a/hcloud/image.go b/hcloud/image.go
index 8a2c770..5af6bec 100644
--- a/hcloud/image.go
+++ b/hcloud/image.go
@@ -1,7 +1,124 @@
 package hcloud
 
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/hetznercloud/hcloud-go/hcloud/schema"
+)
+
 // Image represents an Image in the Hetzner Cloud.
 type Image struct {
-	ID   int
-	Name string
+	ID          int
+	Name        string
+	Type        ImageType
+	Status      ImageStatus
+	Description string
+	ImageSize   float32
+	DiskSize    float32
+	Created     time.Time
+	RapidDeploy bool
+
+	OSFlavor  string
+	OSVersion string
+}
+
+// ImageType specifies the type of an image.
+type ImageType string
+
+const (
+	// ImageTypeSnapshot represents a snapshot image.
+	ImageTypeSnapshot = "snapshot"
+	// ImageTypeBackup represents a backup image.
+	ImageTypeBackup = "backup"
+	// ImageTypeSystem represents a system image.
+	ImageTypeSystem = "system"
+)
+
+// ImageStatus specifies the status of an image.
+type ImageStatus string
+
+const (
+	// ImageStatusCreating is the status when an image is being created.
+	ImageStatusCreating = "creating"
+	// ImageStatusAvailable is the stats when an image is available.
+	ImageStatusAvailable = "available"
+)
+
+// ImageClient is a client for the image API.
+type ImageClient struct {
+	client *Client
+}
+
+// Get retrieves an image.
+func (c *ImageClient) Get(ctx context.Context, id int) (*Image, *Response, error) {
+	req, err := c.client.NewRequest(ctx, "GET", fmt.Sprintf("/images/%d", id), nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var body schema.ImageGetResponse
+	resp, err := c.client.Do(req, &body)
+	if err != nil {
+		return nil, nil, err
+	}
+	return ImageFromSchema(body.Image), resp, nil
+}
+
+// ImageListOpts specifies options for listing images.
+type ImageListOpts struct {
+	ListOpts
+}
+
+// List returns a list of images for a specific page.
+func (c *ImageClient) List(ctx context.Context, opts ImageListOpts) ([]*Image, *Response, error) {
+	path := "/images?" + valuesForListOpts(opts.ListOpts).Encode()
+	req, err := c.client.NewRequest(ctx, "GET", path, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var body schema.ImageListResponse
+	resp, err := c.client.Do(req, &body)
+	if err != nil {
+		return nil, nil, err
+	}
+	images := make([]*Image, 0, len(body.Images))
+	for _, i := range body.Images {
+		images = append(images, ImageFromSchema(i))
+	}
+	return images, resp, nil
+}
+
+// All returns all images.
+func (c *ImageClient) All(ctx context.Context) ([]*Image, error) {
+	allImages := []*Image{}
+
+	opts := ImageListOpts{}
+	opts.PerPage = 50
+
+	_, err := c.client.all(func(page int) (*Response, error) {
+		opts.Page = page
+		images, resp, err := c.List(ctx, opts)
+		if err != nil {
+			return resp, err
+		}
+		allImages = append(allImages, images...)
+		return resp, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return allImages, nil
+}
+
+// Delete deletes an image.
+func (c *ImageClient) Delete(ctx context.Context, id int) (*Response, error) {
+	req, err := c.client.NewRequest(ctx, "DELETE", fmt.Sprintf("/images/%d", id), nil)
+	if err != nil {
+		return nil, err
+	}
+	return c.client.Do(req, nil)
 }
diff --git a/hcloud/schema.go b/hcloud/schema.go
index 9f0206c..8f50105 100644
--- a/hcloud/schema.go
+++ b/hcloud/schema.go
@@ -162,6 +162,24 @@ func SSHKeyFromSchema(s schema.SSHKey) *SSHKey {
 	}
 }
 
+// ImageFromSchema converts a schema.Image to an Image.
+func ImageFromSchema(s schema.Image) *Image {
+	return &Image{
+		ID:          s.ID,
+		Name:        s.Name,
+		Type:        ImageType(s.Type),
+		Status:      ImageStatus(s.Status),
+		Description: s.Description,
+		ImageSize:   s.ImageSize,
+		DiskSize:    s.DiskSize,
+		Created:     s.Created,
+		RapidDeploy: s.RapidDeploy,
+
+		OSFlavor:  s.OSFlavor,
+		OSVersion: s.OSVersion,
+	}
+}
+
 // PaginationFromSchema converts a schema.MetaPagination to a Pagination.
 func PaginationFromSchema(s schema.MetaPagination) Pagination {
 	return Pagination{
diff --git a/hcloud/schema/image.go b/hcloud/schema/image.go
new file mode 100644
index 0000000..cf2b42c
--- /dev/null
+++ b/hcloud/schema/image.go
@@ -0,0 +1,30 @@
+package schema
+
+import "time"
+
+// Image defines the schema of an image.
+type Image struct {
+	ID          int       `json:"id"`
+	Status      string    `json:"status"`
+	Type        string    `json:"type"`
+	Name        string    `json:"name"`
+	Description string    `json:"description"`
+	ImageSize   float32   `json:"image_size"`
+	DiskSize    float32   `json:"disk_size"`
+	Created     time.Time `json:"created"`
+	OSFlavor    string    `json:"os_flavor"`
+	OSVersion   string    `json:"os_version"`
+	RapidDeploy bool      `json:"rapid_deploy"`
+}
+
+// ImageGetResponse defines the schema of the response when
+// retrieving a single image.
+type ImageGetResponse struct {
+	Image Image `json:"image"`
+}
+
+// ImageListResponse defines the schema of the response when
+// listing images.
+type ImageListResponse struct {
+	Images []Image `json:"images"`
+}


Test patch:
diff --git a/hcloud/image_test.go b/hcloud/image_test.go
new file mode 100644
index 0000000..3d1241d
--- /dev/null
+++ b/hcloud/image_test.go
@@ -0,0 +1,178 @@
+package hcloud
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/hetznercloud/hcloud-go/hcloud/schema"
+)
+
+func TestImageClient(t *testing.T) {
+	t.Run("Get", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/images/1", func(w http.ResponseWriter, r *http.Request) {
+			json.NewEncoder(w).Encode(schema.ImageGetResponse{
+				Image: schema.Image{
+					ID: 1,
+				},
+			})
+		})
+
+		ctx := context.Background()
+		image, _, err := env.Client.Image.Get(ctx, 1)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if image == nil {
+			t.Fatal("no image")
+		}
+		if image.ID != 1 {
+			t.Errorf("unexpected image ID: %v", image.ID)
+		}
+	})
+
+	t.Run("List", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/images", func(w http.ResponseWriter, r *http.Request) {
+			if page := r.URL.Query().Get("page"); page != "2" {
+				t.Errorf("expected page 2; got %q", page)
+			}
+			if perPage := r.URL.Query().Get("per_page"); perPage != "50" {
+				t.Errorf("expected per_page 50; got %q", perPage)
+			}
+			json.NewEncoder(w).Encode(schema.ImageListResponse{
+				Images: []schema.Image{
+					{ID: 1},
+					{ID: 2},
+				},
+			})
+		})
+
+		opts := ImageListOpts{}
+		opts.Page = 2
+		opts.PerPage = 50
+
+		ctx := context.Background()
+		images, _, err := env.Client.Image.List(ctx, opts)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(images) != 2 {
+			t.Fatal("expected 2 images")
+		}
+	})
+
+	t.Run("All", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		firstRequest := true
+		env.Mux.HandleFunc("/images", func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "application/json")
+			if firstRequest {
+				firstRequest = false
+				w.WriteHeader(http.StatusTooManyRequests)
+				json.NewEncoder(w).Encode(schema.ErrorResponse{
+					Error: schema.Error{
+						Code:    "limit_reached",
+						Message: "ratelimited",
+					},
+				})
+				return
+			}
+
+			switch page := r.URL.Query().Get("page"); page {
+			case "", "1":
+				fmt.Fprint(w, `{
+					"images": [
+						{
+							"id": 1
+						}
+					],
+					"meta": {
+						"pagination": {
+							"page": 1,
+							"per_page": 1,
+							"previous_page": null,
+							"next_page": 2,
+							"last_page": 3,
+							"total_entries": 3
+						}
+					}
+				}`)
+			case "2":
+				fmt.Fprint(w, `{
+					"images": [
+						{
+							"id": 2
+						}
+					],
+					"meta": {
+						"pagination": {
+							"page": 2,
+							"per_page": 1,
+							"previous_page": 1,
+							"next_page": 3,
+							"last_page": 3,
+							"total_entries": 3
+						}
+					}
+				}`)
+			case "3":
+				fmt.Fprint(w, `{
+					"images": [
+						{
+							"id": 3
+						}
+					],
+					"meta": {
+						"pagination": {
+							"page": 3,
+							"per_page": 1,
+							"previous_page": 2,
+							"next_page": null,
+							"last_page": 3,
+							"total_entries": 3
+						}
+					}
+				}`)
+			default:
+				panic("bad page")
+			}
+		})
+
+		ctx := context.Background()
+		images, err := env.Client.Image.All(ctx)
+		if err != nil {
+			t.Fatalf("Image.List failed: %s", err)
+		}
+		if len(images) != 3 {
+			t.Fatalf("expected 3 images; got %d", len(images))
+		}
+		if images[0].ID != 1 {
+			t.Errorf("Expected first image to have an id of 1; got %d", images[0].ID)
+		}
+	})
+
+	t.Run("Delete", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/images/1", func(w http.ResponseWriter, r *http.Request) {
+			return
+		})
+
+		ctx := context.Background()
+		_, err := env.Client.Image.Delete(ctx, 1)
+		if err != nil {
+			t.Fatalf("Image.Delete failed: %s", err)
+		}
+	})
+}
diff --git a/hcloud/schema_test.go b/hcloud/schema_test.go
index 2184439..fa6b712 100644
--- a/hcloud/schema_test.go
+++ b/hcloud/schema_test.go
@@ -467,3 +467,62 @@ func TestPaginationFromSchema(t *testing.T) {
 		t.Errorf("unexpected total entries: %d", p.TotalEntries)
 	}
 }
+
+func TestImageFromSchema(t *testing.T) {
+	data := []byte(`{
+		"id": 4711,
+		"type": "system",
+		"status": "available",
+		"name": "ubuntu16.04-standard-x64",
+		"description": "Ubuntu 16.04 Standard 64 bit",
+		"image_size": 2.3,
+		"disk_size": 10,
+		"created": "2016-01-30T23:55:01Z",
+		"created_from": "my_server1",
+		"created_from_id": 1,
+		"bound_to": 1,
+		"os_flavor": "ubuntu",
+		"os_version": "16.04",
+		"rapid_deploy": false
+	}`)
+
+	var s schema.Image
+	if err := json.Unmarshal(data, &s); err != nil {
+		t.Fatal(err)
+	}
+	image := ImageFromSchema(s)
+
+	if image.ID != 4711 {
+		t.Errorf("unexpected ID: %v", image.ID)
+	}
+	if image.Type != ImageTypeSystem {
+		t.Errorf("unexpected Type: %v", image.Type)
+	}
+	if image.Status != ImageStatusAvailable {
+		t.Errorf("unexpected Status: %v", image.Status)
+	}
+	if image.Name != "ubuntu16.04-standard-x64" {
+		t.Errorf("unexpected Name: %v", image.Name)
+	}
+	if image.Description != "Ubuntu 16.04 Standard 64 bit" {
+		t.Errorf("unexpected Description: %v", image.Description)
+	}
+	if image.ImageSize != 2.3 {
+		t.Errorf("unexpected ImageSize: %v", image.ImageSize)
+	}
+	if image.DiskSize != 10 {
+		t.Errorf("unexpected DiskSize: %v", image.DiskSize)
+	}
+	if !image.Created.Equal(time.Date(2016, 1, 30, 23, 55, 1, 0, time.UTC)) {
+		t.Errorf("unexpected Created: %v", image.Created)
+	}
+	if image.OSVersion != "16.04" {
+		t.Errorf("unexpected OSVersion: %v", image.OSVersion)
+	}
+	if image.OSFlavor != "ubuntu" {
+		t.Errorf("unexpected OSFlavor: %v", image.OSFlavor)
+	}
+	if image.RapidDeploy {
+		t.Errorf("unexpected RapidDeploy: %v", image.RapidDeploy)
+	}
+}