Skip to content

Commit

Permalink
feat: support resource-specific Action endpoints (#295) (#309)
Browse files Browse the repository at this point in the history
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.

(cherry picked from commit ddc2ac4)
  • Loading branch information
apricote authored Aug 24, 2023
1 parent 38121f6 commit b492d68
Show file tree
Hide file tree
Showing 12 changed files with 262 additions and 62 deletions.
141 changes: 89 additions & 52 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 int) (*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 @@ -204,7 +161,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 @@ -241,9 +198,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
}

Expand Down Expand Up @@ -289,7 +246,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 @@ -320,6 +277,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:
Expand All @@ -328,3 +286,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 int) (*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
Loading

0 comments on commit b492d68

Please sign in to comment.