Skip to content

Commit

Permalink
feat: support resource-specific Action endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
apricote committed Aug 21, 2023
1 parent a317348 commit d989482
Show file tree
Hide file tree
Showing 12 changed files with 259 additions and 60 deletions.
136 changes: 86 additions & 50 deletions hcloud/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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++
}

Expand Down Expand Up @@ -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++
}

Expand Down Expand Up @@ -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
}
154 changes: 154 additions & 0 deletions hcloud/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions hcloud/certificate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 10 additions & 10 deletions hcloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions hcloud/firewall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading

0 comments on commit d989482

Please sign in to comment.