Repository: hetznercloud/hcloud-go
Pull request #295 (2023-07-21 13:51:11)

feat: support resource-specific Action endpoints
Related to https://docs.hetzner.cloud/changelog#2023-06-29-resource-action-endpoints

The existing `ActionClient` has two purposes:
- Expose the `GET /v1/actions` & `GET /v1/actions/:id` endpoints
- Provider helper methods for waiting on running actions

The new resource-specific endpoints do not need the helper methods, so I have refactored the code for the endpoints into the `ResourceActionClient`. This client is exposed by all resources that have actions as `client.Server.Action`. In addition, I have replaced the implementation of the two endpoints in `ActionClient` by forwarding them to an instance of `ResourceActionClient`.


Patch:
diff --git a/hcloud/action.go b/hcloud/action.go
index 1375e0c..4d38218 100644
--- a/hcloud/action.go
+++ b/hcloud/action.go
@@ -72,25 +72,12 @@ func (a *Action) Error() error {
 
 // ActionClient is a client for the actions API.
 type ActionClient struct {
-	client *Client
+	action *ResourceActionClient
 }
 
 // GetByID retrieves an action by its ID. If the action does not exist, nil is returned.
 func (c *ActionClient) GetByID(ctx context.Context, id int64) (*Action, *Response, error) {
-	req, err := c.client.NewRequest(ctx, "GET", fmt.Sprintf("/actions/%d", id), nil)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	var body schema.ActionGetResponse
-	resp, err := c.client.Do(req, &body)
-	if err != nil {
-		if IsError(err, ErrorCodeNotFound) {
-			return nil, resp, nil
-		}
-		return nil, nil, err
-	}
-	return ActionFromSchema(body.Action), resp, nil
+	return c.action.GetByID(ctx, id)
 }
 
 // ActionListOpts specifies options for listing actions.
@@ -120,47 +107,17 @@ func (l ActionListOpts) values() url.Values {
 // Please note that filters specified in opts are not taken into account
 // when their value corresponds to their zero value or when they are empty.
 func (c *ActionClient) List(ctx context.Context, opts ActionListOpts) ([]*Action, *Response, error) {
-	path := "/actions?" + opts.values().Encode()
-	req, err := c.client.NewRequest(ctx, "GET", path, nil)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	var body schema.ActionListResponse
-	resp, err := c.client.Do(req, &body)
-	if err != nil {
-		return nil, nil, err
-	}
-	actions := make([]*Action, 0, len(body.Actions))
-	for _, i := range body.Actions {
-		actions = append(actions, ActionFromSchema(i))
-	}
-	return actions, resp, nil
+	return c.action.List(ctx, opts)
 }
 
 // All returns all actions.
 func (c *ActionClient) All(ctx context.Context) ([]*Action, error) {
-	return c.AllWithOpts(ctx, ActionListOpts{ListOpts: ListOpts{PerPage: 50}})
+	return c.action.All(ctx, ActionListOpts{ListOpts: ListOpts{PerPage: 50}})
 }
 
 // AllWithOpts returns all actions for the given options.
 func (c *ActionClient) AllWithOpts(ctx context.Context, opts ActionListOpts) ([]*Action, error) {
-	allActions := []*Action{}
-
-	err := c.client.all(func(page int) (*Response, error) {
-		opts.Page = page
-		actions, resp, err := c.List(ctx, opts)
-		if err != nil {
-			return resp, err
-		}
-		allActions = append(allActions, actions...)
-		return resp, nil
-	})
-	if err != nil {
-		return nil, err
-	}
-
-	return allActions, nil
+	return c.action.All(ctx, opts)
 }
 
 // WatchOverallProgress watches several actions' progress until they complete
@@ -203,7 +160,7 @@ func (c *ActionClient) WatchOverallProgress(ctx context.Context, actions []*Acti
 			case <-ctx.Done():
 				errCh <- ctx.Err()
 				return
-			case <-time.After(c.client.pollBackoffFunc(retries)):
+			case <-time.After(c.action.client.pollBackoffFunc(retries)):
 				retries++
 			}
 
@@ -240,9 +197,9 @@ func (c *ActionClient) WatchOverallProgress(ctx context.Context, actions []*Acti
 				}
 			}
 
-			progress += (len(completedIDs) * 100)
+			progress += len(completedIDs) * 100
 			if progress != 0 && progress != previousProgress {
-				sendProgress(progressCh, int(progress/len(actions)))
+				sendProgress(progressCh, progress/len(actions))
 				previousProgress = progress
 			}
 
@@ -288,7 +245,7 @@ func (c *ActionClient) WatchProgress(ctx context.Context, action *Action) (<-cha
 			case <-ctx.Done():
 				errCh <- ctx.Err()
 				return
-			case <-time.After(c.client.pollBackoffFunc(retries)):
+			case <-time.After(c.action.client.pollBackoffFunc(retries)):
 				retries++
 			}
 
@@ -319,6 +276,7 @@ func (c *ActionClient) WatchProgress(ctx context.Context, action *Action) (<-cha
 	return progressCh, errCh
 }
 
+// sendProgress allows the user to only read from the error channel and ignore any progress updates.
 func sendProgress(progressCh chan int, p int) {
 	select {
 	case progressCh <- p:
@@ -327,3 +285,82 @@ func sendProgress(progressCh chan int, p int) {
 		break
 	}
 }
+
+// ResourceActionClient is a client for the actions API exposed by the resource.
+type ResourceActionClient struct {
+	resource string
+	client   *Client
+}
+
+func (c *ResourceActionClient) getBaseURL() string {
+	if c.resource == "" {
+		return ""
+	}
+
+	return "/" + c.resource
+}
+
+// GetByID retrieves an action by its ID. If the action does not exist, nil is returned.
+func (c *ResourceActionClient) GetByID(ctx context.Context, id int64) (*Action, *Response, error) {
+	req, err := c.client.NewRequest(ctx, "GET", fmt.Sprintf("%s/actions/%d", c.getBaseURL(), id), nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var body schema.ActionGetResponse
+	resp, err := c.client.Do(req, &body)
+	if err != nil {
+		if IsError(err, ErrorCodeNotFound) {
+			return nil, resp, nil
+		}
+		return nil, nil, err
+	}
+	return ActionFromSchema(body.Action), resp, nil
+}
+
+// List returns a list of actions for a specific page.
+//
+// Please note that filters specified in opts are not taken into account
+// when their value corresponds to their zero value or when they are empty.
+func (c *ResourceActionClient) List(ctx context.Context, opts ActionListOpts) ([]*Action, *Response, error) {
+	req, err := c.client.NewRequest(
+		ctx,
+		"GET",
+		fmt.Sprintf("%s/actions?%s", c.getBaseURL(), opts.values().Encode()),
+		nil,
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var body schema.ActionListResponse
+	resp, err := c.client.Do(req, &body)
+	if err != nil {
+		return nil, nil, err
+	}
+	actions := make([]*Action, 0, len(body.Actions))
+	for _, i := range body.Actions {
+		actions = append(actions, ActionFromSchema(i))
+	}
+	return actions, resp, nil
+}
+
+// All returns all actions for the given options.
+func (c *ResourceActionClient) All(ctx context.Context, opts ActionListOpts) ([]*Action, error) {
+	allActions := []*Action{}
+
+	err := c.client.all(func(page int) (*Response, error) {
+		opts.Page = page
+		actions, resp, err := c.List(ctx, opts)
+		if err != nil {
+			return resp, err
+		}
+		allActions = append(allActions, actions...)
+		return resp, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return allActions, nil
+}
diff --git a/hcloud/certificate.go b/hcloud/certificate.go
index 3d9dbdd..1c1d9c7 100644
--- a/hcloud/certificate.go
+++ b/hcloud/certificate.go
@@ -93,6 +93,7 @@ type CertificateCreateResult struct {
 // CertificateClient is a client for the Certificates API.
 type CertificateClient struct {
 	client *Client
+	Action *ResourceActionClient
 }
 
 // GetByID retrieves a Certificate by its ID. If the Certificate does not exist, nil is returned.
diff --git a/hcloud/client.go b/hcloud/client.go
index 0f6e2c8..53e094f 100644
--- a/hcloud/client.go
+++ b/hcloud/client.go
@@ -189,25 +189,25 @@ func NewClient(options ...ClientOption) *Client {
 		client.httpClient.Transport = i.InstrumentedRoundTripper()
 	}
 
-	client.Action = ActionClient{client: client}
+	client.Action = ActionClient{action: &ResourceActionClient{client: client}}
 	client.Datacenter = DatacenterClient{client: client}
-	client.FloatingIP = FloatingIPClient{client: client}
-	client.Image = ImageClient{client: client}
+	client.FloatingIP = FloatingIPClient{client: client, Action: &ResourceActionClient{client: client, resource: "floating_ips"}}
+	client.Image = ImageClient{client: client, Action: &ResourceActionClient{client: client, resource: "images"}}
 	client.ISO = ISOClient{client: client}
 	client.Location = LocationClient{client: client}
-	client.Network = NetworkClient{client: client}
+	client.Network = NetworkClient{client: client, Action: &ResourceActionClient{client: client, resource: "networks"}}
 	client.Pricing = PricingClient{client: client}
-	client.Server = ServerClient{client: client}
+	client.Server = ServerClient{client: client, Action: &ResourceActionClient{client: client, resource: "servers"}}
 	client.ServerType = ServerTypeClient{client: client}
 	client.SSHKey = SSHKeyClient{client: client}
-	client.Volume = VolumeClient{client: client}
-	client.LoadBalancer = LoadBalancerClient{client: client}
+	client.Volume = VolumeClient{client: client, Action: &ResourceActionClient{client: client, resource: "volumes"}}
+	client.LoadBalancer = LoadBalancerClient{client: client, Action: &ResourceActionClient{client: client, resource: "load_balancers"}}
 	client.LoadBalancerType = LoadBalancerTypeClient{client: client}
-	client.Certificate = CertificateClient{client: client}
-	client.Firewall = FirewallClient{client: client}
+	client.Certificate = CertificateClient{client: client, Action: &ResourceActionClient{client: client, resource: "certificates"}}
+	client.Firewall = FirewallClient{client: client, Action: &ResourceActionClient{client: client, resource: "firewalls"}}
 	client.PlacementGroup = PlacementGroupClient{client: client}
 	client.RDNS = RDNSClient{client: client}
-	client.PrimaryIP = PrimaryIPClient{client: client}
+	client.PrimaryIP = PrimaryIPClient{client: client, Action: &ResourceActionClient{client: client, resource: "primary_ips"}}
 
 	return client
 }
diff --git a/hcloud/firewall.go b/hcloud/firewall.go
index 509b4b6..512c32f 100644
--- a/hcloud/firewall.go
+++ b/hcloud/firewall.go
@@ -91,6 +91,7 @@ type FirewallResourceLabelSelector struct {
 // FirewallClient is a client for the Firewalls API.
 type FirewallClient struct {
 	client *Client
+	Action *ResourceActionClient
 }
 
 // GetByID retrieves a Firewall by its ID. If the Firewall does not exist, nil is returned.
diff --git a/hcloud/floating_ip.go b/hcloud/floating_ip.go
index 7abba08..0e6962a 100644
--- a/hcloud/floating_ip.go
+++ b/hcloud/floating_ip.go
@@ -91,6 +91,7 @@ func (f *FloatingIP) GetDNSPtrForIP(ip net.IP) (string, error) {
 // FloatingIPClient is a client for the Floating IP API.
 type FloatingIPClient struct {
 	client *Client
+	Action *ResourceActionClient
 }
 
 // GetByID retrieves a Floating IP by its ID. If the Floating IP does not exist,
diff --git a/hcloud/image.go b/hcloud/image.go
index ac844b5..185c38d 100644
--- a/hcloud/image.go
+++ b/hcloud/image.go
@@ -78,6 +78,7 @@ const (
 // ImageClient is a client for the image API.
 type ImageClient struct {
 	client *Client
+	Action *ResourceActionClient
 }
 
 // GetByID retrieves an image by its ID. If the image does not exist, nil is returned.
diff --git a/hcloud/load_balancer.go b/hcloud/load_balancer.go
index 96d2c81..fb40057 100644
--- a/hcloud/load_balancer.go
+++ b/hcloud/load_balancer.go
@@ -238,6 +238,7 @@ func (lb *LoadBalancer) GetDNSPtrForIP(ip net.IP) (string, error) {
 // LoadBalancerClient is a client for the Load Balancers API.
 type LoadBalancerClient struct {
 	client *Client
+	Action *ResourceActionClient
 }
 
 // GetByID retrieves a Load Balancer by its ID. If the Load Balancer does not exist, nil is returned.
diff --git a/hcloud/network.go b/hcloud/network.go
index ba0aef4..2688c93 100644
--- a/hcloud/network.go
+++ b/hcloud/network.go
@@ -73,6 +73,7 @@ type NetworkProtection struct {
 // NetworkClient is a client for the network API.
 type NetworkClient struct {
 	client *Client
+	Action *ResourceActionClient
 }
 
 // GetByID retrieves a network by its ID. If the network does not exist, nil is returned.
diff --git a/hcloud/primary_ip.go b/hcloud/primary_ip.go
index 631e44a..5d241e6 100644
--- a/hcloud/primary_ip.go
+++ b/hcloud/primary_ip.go
@@ -160,6 +160,7 @@ type PrimaryIPChangeProtectionResult struct {
 // PrimaryIPClient is a client for the Primary IP API.
 type PrimaryIPClient struct {
 	client *Client
+	Action *ResourceActionClient
 }
 
 // GetByID retrieves a Primary IP by its ID. If the Primary IP does not exist, nil is returned.
diff --git a/hcloud/server.go b/hcloud/server.go
index 2b32cdf..d85699c 100644
--- a/hcloud/server.go
+++ b/hcloud/server.go
@@ -191,6 +191,7 @@ func (s *Server) GetDNSPtrForIP(ip net.IP) (string, error) {
 // ServerClient is a client for the servers API.
 type ServerClient struct {
 	client *Client
+	Action *ResourceActionClient
 }
 
 // GetByID retrieves a server by its ID. If the server does not exist, nil is returned.
diff --git a/hcloud/volume.go b/hcloud/volume.go
index b955384..3f23c6d 100644
--- a/hcloud/volume.go
+++ b/hcloud/volume.go
@@ -35,6 +35,7 @@ type VolumeProtection struct {
 // VolumeClient is a client for the volume API.
 type VolumeClient struct {
 	client *Client
+	Action *ResourceActionClient
 }
 
 // VolumeStatus specifies a volume's status.


Test patch:
diff --git a/hcloud/action_test.go b/hcloud/action_test.go
index b813bb7..add34a1 100644
--- a/hcloud/action_test.go
+++ b/hcloud/action_test.go
@@ -167,6 +167,160 @@ func TestActionClientAll(t *testing.T) {
 	}
 }
 
+func TestResourceActionClientGetByID(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/primary_ips/actions/1", func(w http.ResponseWriter, r *http.Request) {
+		_ = json.NewEncoder(w).Encode(schema.ActionGetResponse{
+			Action: schema.Action{
+				ID:       1,
+				Status:   "running",
+				Command:  "create_primary_ip",
+				Progress: 50,
+				Started:  time.Date(2017, 12, 4, 14, 31, 1, 0, time.UTC),
+			},
+		})
+	})
+
+	ctx := context.Background()
+	action, _, err := env.Client.PrimaryIP.Action.GetByID(ctx, 1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if action == nil {
+		t.Fatal("no action")
+	}
+	if action.ID != 1 {
+		t.Errorf("unexpected action ID: %v", action.ID)
+	}
+}
+
+func TestResourceActionClientGetByIDNotFound(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/primary_ips/actions/1", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusNotFound)
+		_ = json.NewEncoder(w).Encode(schema.ErrorResponse{
+			Error: schema.Error{
+				Code: string(ErrorCodeNotFound),
+			},
+		})
+	})
+
+	ctx := context.Background()
+	action, _, err := env.Client.PrimaryIP.Action.GetByID(ctx, 1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if action != nil {
+		t.Fatal("expected no action")
+	}
+}
+
+func TestResourceActionClientList(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/primary_ips/actions", 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)
+		}
+
+		status := r.URL.Query()["status"]
+		if len(status) != 2 {
+			t.Errorf("expected status to contain 2 elements; got %q", status)
+		} else {
+			if status[0] != "running" {
+				t.Errorf("expected status[0] to be running; got %q", status[0])
+			}
+			if status[1] != "error" {
+				t.Errorf("expected status[1] to be error; got %q", status[1])
+			}
+		}
+
+		sort := r.URL.Query()["sort"]
+		if len(sort) != 3 {
+			t.Errorf("expected sort to contain 3 elements; got %q", sort)
+		} else {
+			if sort[0] != "status" {
+				t.Errorf("expected sort[0] to be status; got %q", sort[0])
+			}
+			if sort[1] != "progress:desc" {
+				t.Errorf("expected sort[1] to be progress:desc; got %q", sort[1])
+			}
+			if sort[2] != "command:asc" {
+				t.Errorf("expected sort[2] to be command:asc; got %q", sort[2])
+			}
+		}
+		_ = json.NewEncoder(w).Encode(schema.ActionListResponse{
+			Actions: []schema.Action{
+				{ID: 1},
+				{ID: 2},
+			},
+		})
+	})
+
+	opts := ActionListOpts{}
+	opts.Page = 2
+	opts.PerPage = 50
+	opts.Status = []ActionStatus{ActionStatusRunning, ActionStatusError}
+	opts.Sort = []string{"status", "progress:desc", "command:asc"}
+
+	ctx := context.Background()
+	actions, _, err := env.Client.PrimaryIP.Action.List(ctx, opts)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(actions) != 2 {
+		t.Fatal("expected 2 actions")
+	}
+}
+
+func TestResourceActionClientAll(t *testing.T) {
+	env := newTestEnv()
+	defer env.Teardown()
+
+	env.Mux.HandleFunc("/primary_ips/actions", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(struct {
+			Actions []schema.Action `json:"actions"`
+			Meta    schema.Meta     `json:"meta"`
+		}{
+			Actions: []schema.Action{
+				{ID: 1},
+				{ID: 2},
+				{ID: 3},
+			},
+			Meta: schema.Meta{
+				Pagination: &schema.MetaPagination{
+					Page:         1,
+					LastPage:     1,
+					PerPage:      3,
+					TotalEntries: 3,
+				},
+			},
+		})
+	})
+
+	ctx := context.Background()
+	actions, err := env.Client.PrimaryIP.Action.All(ctx, ActionListOpts{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(actions) != 3 {
+		t.Fatalf("expected 3 actions; got %d", len(actions))
+	}
+	if actions[0].ID != 1 || actions[1].ID != 2 || actions[2].ID != 3 {
+		t.Errorf("unexpected actions")
+	}
+}
+
 func TestActionClientWatchOverallProgress(t *testing.T) {
 	env := newTestEnv()
 	defer env.Teardown()