diff --git a/.gitignore b/.gitignore index 93c700e..69bcb49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .vscode/ .idea/ +covprofile diff --git a/helper_test.go b/helper_test.go index b1fca36..5251bcd 100644 --- a/helper_test.go +++ b/helper_test.go @@ -486,3 +486,30 @@ func assignTagsToEnvironment(t *testing.T, client *Client, environment *Environm t.Fatal(err) } } + +func createWebhookIntegration( + t *testing.T, client *Client, isShared bool, envs []*Environment, +) (*WebhookIntegration, func()) { + ctx := context.Background() + opts := WebhookIntegrationCreateOptions{ + Name: String("tst-" + randomString(t)), + Enabled: Bool(true), + IsShared: Bool(isShared), + Url: String("https://example.com"), + Account: &Account{ID: defaultAccountID}, + Events: []*EventDefinition{{ID: "run:completed"}}, + Environments: envs, + } + w, err := client.WebhookIntegrations.Create(ctx, opts) + if err != nil { + t.Fatal(err) + } + + return w, func() { + if err := client.WebhookIntegrations.Delete(ctx, w.ID); err != nil { + t.Errorf("Error destroying webhook integration! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "Webhook: %s\nError: %s", w.ID, err) + } + } +} diff --git a/module_version.go b/module_version.go index 97faf89..f1cb136 100644 --- a/module_version.go +++ b/module_version.go @@ -16,8 +16,6 @@ type ModuleVersions interface { List(ctx context.Context, options ModuleVersionListOptions) (*ModuleVersionList, error) // Read a module version by its ID. Read(ctx context.Context, moduleVersionID string) (*ModuleVersion, error) - // ReadBySemanticVersion read module version by module and semantic version - ReadBySemanticVersion(ctx context.Context, moduleId string, version string) (*ModuleVersion, error) } // moduleVersions implements ModuleVersions. @@ -105,32 +103,3 @@ func (s *moduleVersions) List(ctx context.Context, options ModuleVersionListOpti return mv, nil } - -func (s *moduleVersions) ReadBySemanticVersion(ctx context.Context, moduleID string, version string) (*ModuleVersion, error) { - if !validStringID(&moduleID) { - return nil, errors.New("invalid value for module id") - } - - v := &version - if !validString(v) { - return nil, errors.New("invalid value for version") - } - - req, err := s.client.newRequest("GET", "module-versions", &ModuleVersionListOptions{Module: moduleID, Version: v}) - if err != nil { - return nil, err - } - - mvl := &ModuleVersionList{} - err = s.client.do(ctx, req, mvl) - if err != nil { - return nil, err - } - if len(mvl.Items) != 1 { - return nil, ResourceNotFoundError{ - Message: fmt.Sprintf("ModuleVersion with Module ID '%v' and version '%v' not found.", moduleID, version), - } - } - - return mvl.Items[0], nil -} diff --git a/scalr.go b/scalr.go index 901a61e..2724133 100644 --- a/scalr.go +++ b/scalr.go @@ -145,6 +145,7 @@ type Client struct { VcsProviders VcsProviders VcsRevisions VcsRevisions Webhooks Webhooks + WebhookIntegrations WebhookIntegrations WorkspaceTags WorkspaceTags Workspaces Workspaces } @@ -242,6 +243,7 @@ func NewClient(cfg *Config) (*Client, error) { client.VcsProviders = &vcsProviders{client: client} client.VcsRevisions = &vcsRevisions{client: client} client.Webhooks = &webhooks{client: client} + client.WebhookIntegrations = &webhookIntegrations{client: client} client.WorkspaceTags = &workspaceTag{client: client} client.Workspaces = &workspaces{client: client} return client, nil diff --git a/webhook_integration.go b/webhook_integration.go new file mode 100644 index 0000000..6004b49 --- /dev/null +++ b/webhook_integration.go @@ -0,0 +1,196 @@ +package scalr + +import ( + "context" + "errors" + "fmt" + "net/url" + "time" +) + +// Compile-time proof of interface implementation. +var _ WebhookIntegrations = (*webhookIntegrations)(nil) + +type WebhookIntegrations interface { + List(ctx context.Context, options WebhookIntegrationListOptions) (*WebhookIntegrationList, error) + Create(ctx context.Context, options WebhookIntegrationCreateOptions) (*WebhookIntegration, error) + Read(ctx context.Context, wi string) (*WebhookIntegration, error) + Update(ctx context.Context, wi string, options WebhookIntegrationUpdateOptions) (*WebhookIntegration, error) + Delete(ctx context.Context, wi string) error +} + +// webhookIntegrations implements WebhookIntegrations. +type webhookIntegrations struct { + client *Client +} + +type WebhookIntegrationList struct { + *Pagination + Items []*WebhookIntegration +} + +// WebhookIntegration represents a Scalr IACP webhook integration. +type WebhookIntegration struct { + ID string `jsonapi:"primary,webhook-integrations"` + Name string `jsonapi:"attr,name"` + Enabled bool `jsonapi:"attr,enabled"` + IsShared bool `jsonapi:"attr,is-shared"` + LastTriggeredAt *time.Time `jsonapi:"attr,last-triggered-at,iso8601"` + Url string `jsonapi:"attr,url"` + SecretKey string `jsonapi:"attr,secret-key"` + Timeout int `jsonapi:"attr,timeout"` + MaxAttempts int `jsonapi:"attr,max-attempts"` + HttpMethod string `jsonapi:"attr,http-method"` + Headers []*WebhookHeader `jsonapi:"attr,headers"` + + // Relations + Environments []*Environment `jsonapi:"relation,environments"` + Account *Account `jsonapi:"relation,account"` + Events []*EventDefinition `jsonapi:"relation,events"` +} + +type WebhookHeader struct { + Name string `json:"name"` + Value string `json:"value"` + Sensitive bool `json:"sensitive"` +} + +type WebhookIntegrationListOptions struct { + ListOptions + + Query *string `url:"query,omitempty"` + Sort *string `url:"sort,omitempty"` + Enabled *bool `url:"filter[enabled],omitempty"` + Event *string `url:"filter[event],omitempty"` + Environment *string `url:"filter[environment],omitempty"` + Account *string `url:"filter[account],omitempty"` +} + +type WebhookIntegrationCreateOptions struct { + ID string `jsonapi:"primary,webhook-integrations"` + Name *string `jsonapi:"attr,name"` + Enabled *bool `jsonapi:"attr,enabled,omitempty"` + IsShared *bool `jsonapi:"attr,is-shared,omitempty"` + + Url *string `jsonapi:"attr,url"` + SecretKey *string `jsonapi:"attr,secret-key,omitempty"` + Timeout *int `jsonapi:"attr,timeout,omitempty"` + MaxAttempts *int `jsonapi:"attr,max-attempts,omitempty"` + Headers []*WebhookHeader `jsonapi:"attr,headers,omitempty"` + + Environments []*Environment `jsonapi:"relation,environments,omitempty"` + Account *Account `jsonapi:"relation,account"` + Events []*EventDefinition `jsonapi:"relation,events,omitempty"` +} + +type WebhookIntegrationUpdateOptions struct { + ID string `jsonapi:"primary,webhook-integrations"` + Name *string `jsonapi:"attr,name,omitempty"` + Enabled *bool `jsonapi:"attr,enabled,omitempty"` + IsShared *bool `jsonapi:"attr,is-shared,omitempty"` + + Url *string `jsonapi:"attr,url,omitempty"` + SecretKey *string `jsonapi:"attr,secret-key,omitempty"` + Timeout *int `jsonapi:"attr,timeout,omitempty"` + MaxAttempts *int `jsonapi:"attr,max-attempts,omitempty"` + Headers []*WebhookHeader `jsonapi:"attr,headers,omitempty"` + + Environments []*Environment `jsonapi:"relation,environments"` + Events []*EventDefinition `jsonapi:"relation,events"` +} + +func (s *webhookIntegrations) List( + ctx context.Context, options WebhookIntegrationListOptions, +) (*WebhookIntegrationList, error) { + req, err := s.client.newRequest("GET", "integrations/webhooks", &options) + if err != nil { + return nil, err + } + + wl := &WebhookIntegrationList{} + err = s.client.do(ctx, req, wl) + if err != nil { + return nil, err + } + + return wl, nil +} + +func (s *webhookIntegrations) Create( + ctx context.Context, options WebhookIntegrationCreateOptions, +) (*WebhookIntegration, error) { + // Make sure we don't send a user provided ID. + options.ID = "" + + req, err := s.client.newRequest("POST", "integrations/webhooks", &options) + if err != nil { + return nil, err + } + + w := &WebhookIntegration{} + err = s.client.do(ctx, req, w) + if err != nil { + return nil, err + } + + return w, nil +} + +func (s *webhookIntegrations) Read(ctx context.Context, wi string) (*WebhookIntegration, error) { + if !validStringID(&wi) { + return nil, errors.New("invalid value for webhook ID") + } + + u := fmt.Sprintf("integrations/webhooks/%s", url.QueryEscape(wi)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + w := &WebhookIntegration{} + err = s.client.do(ctx, req, w) + if err != nil { + return nil, err + } + + return w, nil +} + +func (s *webhookIntegrations) Update( + ctx context.Context, wi string, options WebhookIntegrationUpdateOptions, +) (*WebhookIntegration, error) { + if !validStringID(&wi) { + return nil, errors.New("invalid value for webhook ID") + } + + // Make sure we don't send a user provided ID. + options.ID = "" + + u := fmt.Sprintf("integrations/webhooks/%s", url.QueryEscape(wi)) + req, err := s.client.newRequest("PATCH", u, &options) + if err != nil { + return nil, err + } + + w := &WebhookIntegration{} + err = s.client.do(ctx, req, w) + if err != nil { + return nil, err + } + + return w, nil +} + +func (s *webhookIntegrations) Delete(ctx context.Context, wi string) error { + if !validStringID(&wi) { + return errors.New("invalid value for webhook ID") + } + + u := fmt.Sprintf("integrations/webhooks/%s", url.QueryEscape(wi)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} diff --git a/webhook_integration_test.go b/webhook_integration_test.go new file mode 100644 index 0000000..b1196dd --- /dev/null +++ b/webhook_integration_test.go @@ -0,0 +1,163 @@ +package scalr + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWebhookIntegrationsList(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + env1, deleteEnv1 := createEnvironment(t, client) + defer deleteEnv1() + env2, deleteEnv2 := createEnvironment(t, client) + defer deleteEnv2() + + whTest1, whTest1Cleanup := createWebhookIntegration(t, client, true, nil) + defer whTest1Cleanup() + _, whTest2Cleanup := createWebhookIntegration(t, client, false, []*Environment{env1}) + defer whTest2Cleanup() + whTest3, whTest3Cleanup := createWebhookIntegration(t, client, false, []*Environment{env2}) + defer whTest3Cleanup() + + t.Run("with options", func(t *testing.T) { + whl, err := client.WebhookIntegrations.List( + ctx, WebhookIntegrationListOptions{ + Account: String(defaultAccountID), + Environment: &env2.ID, + }, + ) + require.NoError(t, err) + assert.Equal(t, 2, whl.TotalCount) + + expectedIDs := []string{whTest1.ID, whTest3.ID} + actualIDs := make([]string, len(whl.Items)) + for i, wh := range whl.Items { + actualIDs[i] = wh.ID + } + assert.ElementsMatch(t, expectedIDs, actualIDs) + }) +} + +func TestWebhookIntegrationsRead(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + whTest, whTestCleanup := createWebhookIntegration(t, client, true, nil) + defer whTestCleanup() + + t.Run("by ID when the webhook exists", func(t *testing.T) { + wh, err := client.WebhookIntegrations.Read(ctx, whTest.ID) + require.NoError(t, err) + assert.Equal(t, whTest.ID, wh.ID) + }) + + t.Run("by ID when the webhook does not exist", func(t *testing.T) { + wh, err := client.WebhookIntegrations.Read(ctx, "wh-nonexisting") + assert.Nil(t, wh) + assert.Error(t, err) + }) + + t.Run("by ID without a valid webhook ID", func(t *testing.T) { + wh, err := client.WebhookIntegrations.Read(ctx, badIdentifier) + assert.Nil(t, wh) + assert.EqualError(t, err, "invalid value for webhook ID") + }) +} + +func TestWebhookIntegrationsCreate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + t.Run("with valid options", func(t *testing.T) { + options := WebhookIntegrationCreateOptions{ + Name: String("tst-" + randomString(t)), + Account: &Account{ID: defaultAccountID}, + Url: String("https://example.com"), + Events: []*EventDefinition{{ID: "run:completed"}}, + } + + wh, err := client.WebhookIntegrations.Create(ctx, options) + require.NoError(t, err) + + // Get a refreshed view from the API. + refreshed, err := client.WebhookIntegrations.Read(ctx, wh.ID) + require.NoError(t, err) + + for _, item := range []*WebhookIntegration{ + wh, + refreshed, + } { + assert.NotEmpty(t, item.ID) + assert.Equal(t, *options.Name, item.Name) + assert.Equal(t, options.Account, item.Account) + assert.Equal(t, *options.Url, item.Url) + assert.Equal(t, options.Events, item.Events) + } + err = client.WebhookIntegrations.Delete(ctx, wh.ID) + require.NoError(t, err) + }) +} + +func TestWebhookIntegrationsUpdate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + env1, deleteEnv1 := createEnvironment(t, client) + defer deleteEnv1() + env2, deleteEnv2 := createEnvironment(t, client) + defer deleteEnv2() + + whTest, whTestCleanup := createWebhookIntegration(t, client, true, nil) + defer whTestCleanup() + + t.Run("with valid options", func(t *testing.T) { + options := WebhookIntegrationUpdateOptions{ + Name: String(randomString(t)), + IsShared: Bool(false), + Environments: []*Environment{env1, env2}, + } + + wh, err := client.WebhookIntegrations.Update(ctx, whTest.ID, options) + require.NoError(t, err) + + // Get a refreshed view from the API. + refreshed, err := client.WebhookIntegrations.Read(ctx, whTest.ID) + require.NoError(t, err) + + for _, item := range []*WebhookIntegration{ + wh, + refreshed, + } { + assert.Equal(t, *options.Name, item.Name) + assert.Equal(t, *options.IsShared, item.IsShared) + assert.Len(t, item.Environments, 2) + } + }) +} + +func TestWebhookIntegrationsDelete(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + whTest, _ := createWebhookIntegration(t, client, true, nil) + + t.Run("with valid options", func(t *testing.T) { + err := client.WebhookIntegrations.Delete(ctx, whTest.ID) + require.NoError(t, err) + + _, err = client.WebhookIntegrations.Read(ctx, whTest.ID) + assert.Equal( + t, + ResourceNotFoundError{ + Message: fmt.Sprintf("Webhook with ID '%s' not found or user unauthorized", whTest.ID), + }.Error(), + err.Error(), + ) + }) +}