Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support resource-specific Action endpoints (#295) #309

Merged
merged 1 commit into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
jooola marked this conversation as resolved.
Show resolved Hide resolved
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
Loading