Repository: hetznercloud/hcloud-go (go)

Issue:
Implement changing a server’s type
https://docs.hetzner.cloud/#resources-server-actions-post-10

Pull request description:
Implement changing a server's type
Closes #30.

Patch:
diff --git a/hcloud/image.go b/hcloud/image.go
index 7eddc87..d414795 100644
--- a/hcloud/image.go
+++ b/hcloud/image.go
@@ -1,7 +1,9 @@
 package hcloud
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
 	"fmt"
 	"net/url"
 	"strconv"
@@ -157,3 +159,36 @@ func (c *ImageClient) Delete(ctx context.Context, image *Image) (*Response, erro
 	}
 	return c.client.Do(req, nil)
 }
+
+// ImageUpdateOpts specifies options for updating an image.
+type ImageUpdateOpts struct {
+	Description *string
+	Type        ImageType
+}
+
+// Update updates an image.
+func (c *ImageClient) Update(ctx context.Context, image *Image, opts ImageUpdateOpts) (*Image, *Response, error) {
+	reqBody := schema.ImageUpdateRequest{
+		Description: opts.Description,
+	}
+	if opts.Type != "" {
+		reqBody.Type = String(string(opts.Type))
+	}
+	reqBodyData, err := json.Marshal(reqBody)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	path := fmt.Sprintf("/images/%d", image.ID)
+	req, err := c.client.NewRequest(ctx, "PUT", path, bytes.NewReader(reqBodyData))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	respBody := schema.ImageUpdateResponse{}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+	return ImageFromSchema(respBody.Image), resp, nil
+}
diff --git a/hcloud/schema/image.go b/hcloud/schema/image.go
index b58e486..1d8c69c 100644
--- a/hcloud/schema/image.go
+++ b/hcloud/schema/image.go
@@ -36,3 +36,14 @@ type ImageGetResponse struct {
 type ImageListResponse struct {
 	Images []Image `json:"images"`
 }
+
+// ImageUpdateRequest defines the schema of the request to update an image.
+type ImageUpdateRequest struct {
+	Description *string `json:"description,omitempty"`
+	Type        *string `json:"type,omitempty"`
+}
+
+// ImageUpdateResponse defines the schema of the response when updating an image.
+type ImageUpdateResponse struct {
+	Image Image `json:"image"`
+}
diff --git a/hcloud/schema/server.go b/hcloud/schema/server.go
index 3a5f695..2ee1f4b 100644
--- a/hcloud/schema/server.go
+++ b/hcloud/schema/server.go
@@ -82,6 +82,16 @@ type ServerCreateResponse struct {
 	RootPassword *string `json:"root_password"`
 }
 
+// ServerUpdateRequest defines the schema of the request to update a server.
+type ServerUpdateRequest struct {
+	Name string `json:"name,omitempty"`
+}
+
+// ServerUpdateResponse defines the schema of the response when updating a server.
+type ServerUpdateResponse struct {
+	Server Server `json:"server"`
+}
+
 // ServerActionPoweronRequest defines the schema for the request to
 // create a poweron server action.
 type ServerActionPoweronRequest struct{}
@@ -236,3 +246,16 @@ type ServerActionDisableBackupRequest struct{}
 type ServerActionDisableBackupResponse struct {
 	Action Action `json:"action"`
 }
+
+// ServerActionChangeTypeRequest defines the schema for the request to
+// change a server's type.
+type ServerActionChangeTypeRequest struct {
+	ServerType  interface{} `json:"server_type"` // int or string
+	UpgradeDisk bool        `json:"upgrade_disk"`
+}
+
+// ServerActionChangeTypeResponse defines the schema of the response when
+// creating a change_type server action.
+type ServerActionChangeTypeResponse struct {
+	Action Action `json:"action"`
+}
diff --git a/hcloud/server.go b/hcloud/server.go
index d3733ed..31c68f8 100644
--- a/hcloud/server.go
+++ b/hcloud/server.go
@@ -285,6 +285,35 @@ func (c *ServerClient) Delete(ctx context.Context, server *Server) (*Response, e
 	return c.client.Do(req, nil)
 }
 
+// ServerUpdateOpts specifies options for updating a server.
+type ServerUpdateOpts struct {
+	Name string
+}
+
+// Update updates a server.
+func (c *ServerClient) Update(ctx context.Context, server *Server, opts ServerUpdateOpts) (*Server, *Response, error) {
+	reqBody := schema.ServerUpdateRequest{
+		Name: opts.Name,
+	}
+	reqBodyData, err := json.Marshal(reqBody)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	path := fmt.Sprintf("/servers/%d", server.ID)
+	req, err := c.client.NewRequest(ctx, "PUT", path, bytes.NewReader(reqBodyData))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	respBody := schema.ServerUpdateResponse{}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+	return ServerFromSchema(respBody.Server), resp, nil
+}
+
 // Poweron starts a server.
 func (c *ServerClient) Poweron(ctx context.Context, server *Server) (*Action, *Response, error) {
 	path := fmt.Sprintf("/servers/%d/actions/poweron", server.ID)
@@ -628,3 +657,38 @@ func (c *ServerClient) DisableBackup(ctx context.Context, server *Server) (*Acti
 	}
 	return ActionFromSchema(respBody.Action), resp, nil
 }
+
+// ServerChangeTypeOpts specifies options for changing a server's type.
+type ServerChangeTypeOpts struct {
+	ServerType  *ServerType // new server type
+	UpgradeDisk bool        // whether disk should be upgraded
+}
+
+// ChangeType changes a server's type.
+func (c *ServerClient) ChangeType(ctx context.Context, server *Server, opts ServerChangeTypeOpts) (*Action, *Response, error) {
+	reqBody := schema.ServerActionChangeTypeRequest{
+		UpgradeDisk: opts.UpgradeDisk,
+	}
+	if opts.ServerType.ID != 0 {
+		reqBody.ServerType = opts.ServerType.ID
+	} else {
+		reqBody.ServerType = opts.ServerType.Name
+	}
+	reqBodyData, err := json.Marshal(reqBody)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	path := fmt.Sprintf("/servers/%d/actions/change_type", server.ID)
+	req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	respBody := schema.ServerActionChangeTypeResponse{}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+	return ActionFromSchema(respBody.Action), resp, nil
+}


Test patch:
diff --git a/hcloud/image_test.go b/hcloud/image_test.go
index 9b040c0..6725354 100644
--- a/hcloud/image_test.go
+++ b/hcloud/image_test.go
@@ -228,3 +228,85 @@ func TestImageClient(t *testing.T) {
 		}
 	})
 }
+
+func TestImageClientUpdate(t *testing.T) {
+	var (
+		ctx   = context.Background()
+		image = &Image{ID: 1}
+	)
+
+	t.Run("description and type", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/images/1", func(w http.ResponseWriter, r *http.Request) {
+			if r.Method != "PUT" {
+				t.Error("expected PUT")
+			}
+			var reqBody schema.ImageUpdateRequest
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if reqBody.Description == nil || *reqBody.Description != "test" {
+				t.Errorf("unexpected description: %v", reqBody.Description)
+			}
+			if reqBody.Type == nil || *reqBody.Type != "snapshot" {
+				t.Errorf("unexpected type: %v", reqBody.Type)
+			}
+			json.NewEncoder(w).Encode(schema.ImageUpdateResponse{
+				Image: schema.Image{
+					ID: 1,
+				},
+			})
+		})
+
+		opts := ImageUpdateOpts{
+			Description: String("test"),
+			Type:        ImageTypeSnapshot,
+		}
+		updatedImage, _, err := env.Client.Image.Update(ctx, image, opts)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if updatedImage.ID != 1 {
+			t.Errorf("unexpected image ID: %v", updatedImage.ID)
+		}
+	})
+
+	t.Run("no updates", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/images/1", func(w http.ResponseWriter, r *http.Request) {
+			if r.Method != "PUT" {
+				t.Error("expected PUT")
+			}
+			var reqBody schema.ImageUpdateRequest
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if reqBody.Description != nil {
+				t.Errorf("unexpected no description, but got: %v", reqBody.Description)
+			}
+			if reqBody.Type != nil {
+				t.Errorf("unexpected no type, but got: %v", reqBody.Type)
+			}
+			json.NewEncoder(w).Encode(schema.ImageUpdateResponse{
+				Image: schema.Image{
+					ID: 1,
+				},
+			})
+		})
+
+		opts := ImageUpdateOpts{}
+		updatedImage, _, err := env.Client.Image.Update(ctx, image, opts)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if updatedImage.ID != 1 {
+			t.Errorf("unexpected image ID: %v", updatedImage.ID)
+		}
+	})
+}
diff --git a/hcloud/server_test.go b/hcloud/server_test.go
index 31a5bd8..c0a17e6 100644
--- a/hcloud/server_test.go
+++ b/hcloud/server_test.go
@@ -447,6 +447,81 @@ func TestServersDelete(t *testing.T) {
 	}
 }
 
+func TestServerClientUpdate(t *testing.T) {
+	var (
+		ctx    = context.Background()
+		server = &Server{ID: 1}
+	)
+
+	t.Run("update name", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
+			if r.Method != "PUT" {
+				t.Error("expected PUT")
+			}
+			var reqBody schema.ServerUpdateRequest
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if reqBody.Name != "test" {
+				t.Errorf("unexpected name: %v", reqBody.Name)
+			}
+			json.NewEncoder(w).Encode(schema.ServerUpdateResponse{
+				Server: schema.Server{
+					ID: 1,
+				},
+			})
+		})
+
+		opts := ServerUpdateOpts{
+			Name: "test",
+		}
+		updatedServer, _, err := env.Client.Server.Update(ctx, server, opts)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if updatedServer.ID != 1 {
+			t.Errorf("unexpected server ID: %v", updatedServer.ID)
+		}
+	})
+
+	t.Run("no updates", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/servers/1", func(w http.ResponseWriter, r *http.Request) {
+			if r.Method != "PUT" {
+				t.Error("expected PUT")
+			}
+			var reqBody schema.ServerUpdateRequest
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if reqBody.Name != "" {
+				t.Errorf("unexpected no name, but got: %v", reqBody.Name)
+			}
+			json.NewEncoder(w).Encode(schema.ServerUpdateResponse{
+				Server: schema.Server{
+					ID: 1,
+				},
+			})
+		})
+
+		opts := ServerUpdateOpts{}
+		updatedServer, _, err := env.Client.Server.Update(ctx, server, opts)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if updatedServer.ID != 1 {
+			t.Errorf("unexpected server ID: %v", updatedServer.ID)
+		}
+	})
+}
+
 func TestServerClientPoweron(t *testing.T) {
 	env := newTestEnv()
 	defer env.Teardown()
@@ -966,3 +1041,80 @@ func TestServerClientDisableBackup(t *testing.T) {
 		t.Errorf("unexpected action ID: %d", action.ID)
 	}
 }
+
+func TestServerClientChangeType(t *testing.T) {
+	var (
+		ctx    = context.Background()
+		server = &Server{ID: 1}
+	)
+
+	t.Run("with server type ID", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/servers/1/actions/change_type", func(w http.ResponseWriter, r *http.Request) {
+			var reqBody schema.ServerActionChangeTypeRequest
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if id, ok := reqBody.ServerType.(float64); !ok || id != 1 {
+				t.Errorf("unexpected server type ID: %v", reqBody.ServerType)
+			}
+			if !reqBody.UpgradeDisk {
+				t.Error("expected to upgrade disk")
+			}
+			json.NewEncoder(w).Encode(schema.ServerActionChangeTypeResponse{
+				Action: schema.Action{
+					ID: 1,
+				},
+			})
+		})
+
+		opts := ServerChangeTypeOpts{
+			ServerType:  &ServerType{ID: 1},
+			UpgradeDisk: true,
+		}
+		action, _, err := env.Client.Server.ChangeType(ctx, server, opts)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if action.ID != 1 {
+			t.Errorf("unexpected action ID: %d", action.ID)
+		}
+	})
+
+	t.Run("with server type name", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/servers/1/actions/change_type", func(w http.ResponseWriter, r *http.Request) {
+			var reqBody schema.ServerActionChangeTypeRequest
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if name, ok := reqBody.ServerType.(string); !ok || name != "type" {
+				t.Errorf("unexpected server type name: %v", reqBody.ServerType)
+			}
+			if !reqBody.UpgradeDisk {
+				t.Error("expected to upgrade disk")
+			}
+			json.NewEncoder(w).Encode(schema.ServerActionChangeTypeResponse{
+				Action: schema.Action{
+					ID: 1,
+				},
+			})
+		})
+
+		opts := ServerChangeTypeOpts{
+			ServerType:  &ServerType{Name: "type"},
+			UpgradeDisk: true,
+		}
+		action, _, err := env.Client.Server.ChangeType(ctx, server, opts)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if action.ID != 1 {
+			t.Errorf("unexpected action ID: %d", action.ID)
+		}
+	})
+}