diff --git a/helper_test.go b/helper_test.go index 5251bcd..c081bc1 100644 --- a/helper_test.go +++ b/helper_test.go @@ -513,3 +513,33 @@ func createWebhookIntegration( } } } + +func createSlackIntegration( + t *testing.T, client *Client, slackConnection *SlackConnection, environment *Environment, +) (*SlackIntegration, func()) { + ctx := context.Background() + options := SlackIntegrationCreateOptions{ + Name: String("test-" + randomString(t)), + Events: []string{ + SlackIntegrationEventRunApprovalRequired, + SlackIntegrationEventRunSuccess, + SlackIntegrationEventRunErrored, + }, + ChannelId: String("C123"), + Account: &Account{ID: defaultAccountID}, + Connection: slackConnection, + Environments: []*Environment{environment}, + } + si, err := client.SlackIntegrations.Create(ctx, options) + if err != nil { + t.Fatal(err) + } + + return si, func() { + if err := client.SlackIntegrations.Delete(ctx, si.ID); err != nil { + t.Errorf("Error deleting slack integration! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "Webhook: %s\nError: %s", si.ID, err) + } + } +} diff --git a/integration.go b/integration.go new file mode 100644 index 0000000..4a4e3ee --- /dev/null +++ b/integration.go @@ -0,0 +1,9 @@ +package scalr + +type IntegrationStatus string + +const ( + IntegrationStatusActive IntegrationStatus = "active" + IntegrationStatusDisabled IntegrationStatus = "disabled" + IntegrationStatusFailed IntegrationStatus = "failed" +) diff --git a/scalr.go b/scalr.go index 48c6805..e02374d 100644 --- a/scalr.go +++ b/scalr.go @@ -139,6 +139,7 @@ type Client struct { Runs Runs ServiceAccountTokens ServiceAccountTokens ServiceAccounts ServiceAccounts + SlackIntegrations SlackIntegrations Tags Tags Teams Teams Users Users @@ -237,6 +238,7 @@ func NewClient(cfg *Config) (*Client, error) { client.Runs = &runs{client: client} client.ServiceAccountTokens = &serviceAccountTokens{client: client} client.ServiceAccounts = &serviceAccounts{client: client} + client.SlackIntegrations = &slackIntegrations{client: client} client.Tags = &tags{client: client} client.Teams = &teams{client: client} client.Users = &users{client: client} diff --git a/slack_integration.go b/slack_integration.go new file mode 100644 index 0000000..badc9cb --- /dev/null +++ b/slack_integration.go @@ -0,0 +1,217 @@ +package scalr + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" +) + +// Compile-time proof of interface implementation. +var _ SlackIntegrations = (*slackIntegrations)(nil) + +// SlackIntegrations describes all the SlackIntegration related methods that the Scalr +// IACP API supports. +// +// IACP API docs: https://www.scalr.com/docs/en/latest/api/index.html +type SlackIntegrations interface { + List(ctx context.Context, options SlackIntegrationListOptions) (*SlackIntegrationList, error) + Create(ctx context.Context, options SlackIntegrationCreateOptions) (*SlackIntegration, error) + Read(ctx context.Context, slackIntegration string) (*SlackIntegration, error) + Update(ctx context.Context, slackIntegration string, options SlackIntegrationUpdateOptions) (*SlackIntegration, error) + Delete(ctx context.Context, slackIntegration string) error + GetConnection(ctx context.Context, accID string) (*SlackConnection, error) +} + +// slackIntegrations implements SlackIntegrations. +type slackIntegrations struct { + client *Client +} + +const ( + SlackIntegrationEventRunApprovalRequired string = "run_approval_required" + SlackIntegrationEventRunSuccess string = "run_success" + SlackIntegrationEventRunErrored string = "run_errored" +) + +// SlackIntegration represents a Scalr IACP slack integration. +type SlackIntegration struct { + ID string `jsonapi:"primary,slack-integrations"` + Name string `jsonapi:"attr,name"` + Status IntegrationStatus `jsonapi:"attr,status"` + ChannelId string `jsonapi:"attr,channel-id"` + Events []string `jsonapi:"attr,events"` + + // Relations + Account *Account `jsonapi:"relation,account"` + Environments []*Environment `jsonapi:"relation,environments"` + Workspaces []*Workspace `jsonapi:"relation,workspaces"` +} + +type SlackIntegrationList struct { + *Pagination + Items []*SlackIntegration +} + +type SlackIntegrationListOptions struct { + ListOptions + + Filter *SlackIntegrationFilter `url:"filter,omitempty"` +} + +// SlackIntegrationFilter represents the options for filtering Slack integrations. +type SlackIntegrationFilter struct { + Account *string `url:"account,omitempty"` +} + +type SlackIntegrationCreateOptions struct { + ID string `jsonapi:"primary,slack-integrations"` + Name *string `jsonapi:"attr,name"` + ChannelId *string `jsonapi:"attr,channel-id"` + Events []string `jsonapi:"attr,events"` + + Account *Account `jsonapi:"relation,account"` + Connection *SlackConnection `jsonapi:"relation,connection"` + Environments []*Environment `jsonapi:"relation,environments"` + Workspaces []*Workspace `jsonapi:"relation,workspaces,omitempty"` +} + +type SlackIntegrationUpdateOptions struct { + ID string `jsonapi:"primary,slack-integrations"` + Name *string `jsonapi:"attr,name,omitempty"` + ChannelId *string `jsonapi:"attr,channel-id,omitempty"` + Status *IntegrationStatus `jsonapi:"attr,status,omitempty"` + Events []string `jsonapi:"attr,events,omitempty"` + + Environments []*Environment `jsonapi:"relation,environments,omitempty"` + Workspaces []*Workspace `jsonapi:"relation,workspaces"` +} + +type SlackConnection struct { + ID string `jsonapi:"primary,slack-connections"` + SlackWorkspaceName string `jsonapi:"attr,slack-workspace-name"` + + // Relations + Account *Account `jsonapi:"relation,account"` +} + +func (s *slackIntegrations) List( + ctx context.Context, options SlackIntegrationListOptions, +) (*SlackIntegrationList, error) { + req, err := s.client.newRequest("GET", "integrations/slack", &options) + if err != nil { + return nil, err + } + + wl := &SlackIntegrationList{} + err = s.client.do(ctx, req, wl) + if err != nil { + return nil, err + } + + return wl, nil +} + +func (s *slackIntegrations) Create( + ctx context.Context, options SlackIntegrationCreateOptions, +) (*SlackIntegration, error) { + // Make sure we don't send a user provided ID. + options.ID = "" + + req, err := s.client.newRequest("POST", "integrations/slack", &options) + if err != nil { + return nil, err + } + + w := &SlackIntegration{} + err = s.client.do(ctx, req, w) + if err != nil { + return nil, err + } + + return w, nil +} + +func (s *slackIntegrations) Read(ctx context.Context, si string) (*SlackIntegration, error) { + if !validStringID(&si) { + return nil, errors.New("invalid value for Slack integration ID") + } + + u := fmt.Sprintf("integrations/slack/%s", url.QueryEscape(si)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + w := &SlackIntegration{} + err = s.client.do(ctx, req, w) + if err != nil { + return nil, err + } + + return w, nil +} + +func (s *slackIntegrations) Update( + ctx context.Context, si string, options SlackIntegrationUpdateOptions, +) (*SlackIntegration, error) { + if !validStringID(&si) { + return nil, errors.New("invalid value for slack integration ID") + } + + // Make sure we don't send a user provided ID. + options.ID = "" + + u := fmt.Sprintf("integrations/slack/%s", url.QueryEscape(si)) + req, err := s.client.newRequest("PATCH", u, &options) + if err != nil { + return nil, err + } + + w := &SlackIntegration{} + err = s.client.do(ctx, req, w) + if err != nil { + return nil, err + } + + return w, nil +} + +func (s *slackIntegrations) Delete(ctx context.Context, si string) error { + if !validStringID(&si) { + return errors.New("invalid value for slack integration ID") + } + + u := fmt.Sprintf("integrations/slack/%s", url.QueryEscape(si)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} + +func (s *slackIntegrations) GetConnection(ctx context.Context, accID string) (*SlackConnection, error) { + if !validStringID(&accID) { + return nil, errors.New("invalid value for account ID") + } + + u := fmt.Sprintf("integrations/slack/%s/connection", url.QueryEscape(accID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + c := &SlackConnection{} + err = s.client.do(ctx, req, c) + if err != nil { + if strings.Contains(err.Error(), "data is not a jsonapi representation") { + // workaround for jsonapi serializer that can't handle nil 'data' structure we use for missing connection + return c, nil + } + return nil, err + } + + return c, nil +} diff --git a/slack_integration_test.go b/slack_integration_test.go new file mode 100644 index 0000000..5fc879e --- /dev/null +++ b/slack_integration_test.go @@ -0,0 +1,140 @@ +package scalr + +import ( + "context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestSlackIntegrationsCreate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + env1, deleteEnv1 := createEnvironment(t, client) + defer deleteEnv1() + + slackConnection, err := client.SlackIntegrations.GetConnection(ctx, defaultAccountID) + require.NoError(t, err) + + if slackConnection.ID == "" { + t.Skip("Scalr instance doesn't have working slack connection.") + } + + t.Run("with valid options", func(t *testing.T) { + + options := SlackIntegrationCreateOptions{ + Name: String("test-" + randomString(t)), + Events: []string{ + SlackIntegrationEventRunApprovalRequired, + SlackIntegrationEventRunSuccess, + SlackIntegrationEventRunErrored, + }, + ChannelId: String("C123"), + Account: &Account{ID: defaultAccountID}, + Connection: slackConnection, + Environments: []*Environment{env1}, + } + + si, err := client.SlackIntegrations.Create(ctx, options) + require.NoError(t, err) + + refreshed, err := client.SlackIntegrations.Read(ctx, si.ID) + require.NoError(t, err) + + for _, item := range []*SlackIntegration{ + si, + refreshed, + } { + assert.NotEmpty(t, item.ID) + assert.Equal(t, *options.Name, item.Name) + assert.Equal(t, options.Account, item.Account) + assert.Equal(t, *options.ChannelId, item.ChannelId) + assert.Equal(t, options.Events, item.Events) + } + + err = client.SlackIntegrations.Delete(ctx, si.ID) + require.NoError(t, err) + }) +} + +func TestSlackIntegrationsUpdate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + env1, deleteEnv1 := createEnvironment(t, client) + defer deleteEnv1() + env2, deleteEnv2 := createEnvironment(t, client) + defer deleteEnv2() + + slackConnection, err := client.SlackIntegrations.GetConnection(ctx, defaultAccountID) + require.NoError(t, err) + + if slackConnection.ID == "" { + t.Skip("Scalr instance doesn't have working slack connection.") + } + + si, deleteSlack := createSlackIntegration(t, client, slackConnection, env1) + defer deleteSlack() + + t.Run("with valid options", func(t *testing.T) { + + options := SlackIntegrationUpdateOptions{ + Name: String("test-" + randomString(t)), + Events: []string{SlackIntegrationEventRunApprovalRequired, SlackIntegrationEventRunErrored}, + Environments: []*Environment{env2}, + } + + si, err := client.SlackIntegrations.Update(ctx, si.ID, options) + require.NoError(t, err) + + refreshed, err := client.SlackIntegrations.Read(ctx, si.ID) + require.NoError(t, err) + + for _, item := range []*SlackIntegration{ + si, + refreshed, + } { + assert.NotEmpty(t, item.ID) + assert.Equal(t, *options.Name, item.Name) + assert.Equal(t, options.Events, item.Events) + } + }) +} + +func TestSlackIntegrationsList(t *testing.T) { + client := testClient(t) + ctx := context.Background() + env1, deleteEnv1 := createEnvironment(t, client) + defer deleteEnv1() + env2, deleteEnv2 := createEnvironment(t, client) + defer deleteEnv2() + + slackConnection, err := client.SlackIntegrations.GetConnection(ctx, defaultAccountID) + require.NoError(t, err) + + if slackConnection.ID == "" { + t.Skip("Scalr instance doesn't have working slack connection.") + } + + si, deleteSlack := createSlackIntegration(t, client, slackConnection, env1) + defer deleteSlack() + si2, deleteSlack2 := createSlackIntegration(t, client, slackConnection, env2) + defer deleteSlack2() + + t.Run("with valid options", func(t *testing.T) { + + options := SlackIntegrationListOptions{ + Filter: &SlackIntegrationFilter{Account: String(defaultAccountID)}, + } + + sil, err := client.SlackIntegrations.List(ctx, options) + require.NoError(t, err) + + assert.Equal(t, 2, sil.TotalCount) + expectedIDs := []string{si.ID, si2.ID} + actualIDs := make([]string, len(sil.Items)) + for i, s := range sil.Items { + actualIDs[i] = s.ID + } + assert.ElementsMatch(t, expectedIDs, actualIDs) + }) +}