From d9894822c412cf269942a730bc5ef94a0b70f9bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Mon, 21 Aug 2023 12:59:25 +0200 Subject: [PATCH] feat: support resource-specific Action endpoints --- hcloud/action.go | 136 ++++++++++++++++++++++------------- hcloud/action_test.go | 154 ++++++++++++++++++++++++++++++++++++++++ hcloud/certificate.go | 1 + hcloud/client.go | 20 +++--- hcloud/firewall.go | 1 + hcloud/floating_ip.go | 1 + hcloud/image.go | 1 + hcloud/load_balancer.go | 1 + hcloud/network.go | 1 + hcloud/primary_ip.go | 1 + hcloud/server.go | 1 + hcloud/volume.go | 1 + 12 files changed, 259 insertions(+), 60 deletions(-) diff --git a/hcloud/action.go b/hcloud/action.go index 1375e0cc..b37f4a32 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++ } @@ -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++ } @@ -327,3 +284,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/action_test.go b/hcloud/action_test.go index b813bb7e..add34a1f 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() diff --git a/hcloud/certificate.go b/hcloud/certificate.go index 3d9dbddd..1c1d9c7a 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 0f6e2c86..53e094f4 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 509b4b60..512c32ff 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 7abba08a..0e6962ab 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 ac844b5f..185c38d4 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 96d2c81f..fb40057b 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 ba0aef47..2688c93c 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 631e44a6..5d241e6d 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 2b32cdf4..d85699c9 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 b955384e..3f23c6da 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.