diff --git a/helper_test.go b/helper_test.go index 438a061..3aec72e 100644 --- a/helper_test.go +++ b/helper_test.go @@ -261,7 +261,7 @@ func createVcsProvider(t *testing.T, client *Client, envs []*Environment) (*VcsP func createTag(t *testing.T, client *Client) (*Tag, func()) { ctx := context.Background() tag, err := client.Tags.Create(ctx, TagCreateOptions{ - Name: String("test-role-" + randomString(t)), + Name: String("tst-" + randomString(t)), Account: &Account{ID: defaultAccountID}, }) if err != nil { @@ -424,3 +424,16 @@ func createProviderConfigurationScalr(t *testing.T, client *Client, providerName } } } + +func assignTagsToWorkspace(t *testing.T, client *Client, workspace *Workspace, tags []*Tag) { + ctx := context.Background() + tagRels := make([]*TagRelation, len(tags)) + for i, tag := range tags { + tagRels[i] = &TagRelation{ID: tag.ID} + } + err := client.WorkspaceTags.Add(ctx, workspace.ID, tagRels) + + if err != nil { + t.Fatal(err) + } +} diff --git a/tag.go b/tag.go index bab28a2..77f1a01 100644 --- a/tag.go +++ b/tag.go @@ -16,10 +16,8 @@ type Tags interface { List(ctx context.Context, options TagListOptions) (*TagList, error) // Create is used to create a new tag. Create(ctx context.Context, options TagCreateOptions) (*Tag, error) - // Read a tag by its account ID and name. - Read(ctx context.Context, accountID, tagName string) (*Tag, error) - // ReadByID reads a tag by its ID. - ReadByID(ctx context.Context, tagID string) (*Tag, error) + // Read reads a tag by its ID. + Read(ctx context.Context, tagID string) (*Tag, error) // Update existing tag by its ID. Update(ctx context.Context, tagID string, options TagUpdateOptions) (*Tag, error) // Delete deletes a tag by its ID. @@ -45,12 +43,17 @@ type Tag struct { Account *Account `jsonapi:"relation,account"` } +type TagRelation struct { + ID string `jsonapi:"primary,tags"` +} + // TagListOptions represents the options for listing tags. type TagListOptions struct { ListOptions Account *string `url:"filter[account],omitempty"` - Name *string `url:"query,omitempty"` + Name *string `url:"filter[name],omitempty"` + Query *string `url:"query,omitempty"` } // TagCreateOptions represents the options for creating a new tag. @@ -87,36 +90,8 @@ func (s *tags) List(ctx context.Context, options TagListOptions) (*TagList, erro return tl, nil } -// Read tag by account ID and name. -func (s *tags) Read(ctx context.Context, accountID, tagName string) (*Tag, error) { - if !validStringID(&accountID) { - return nil, errors.New("invalid value for account") - } - if !validStringID(&tagName) { - return nil, errors.New("invalid value for tag") - } - - options := TagListOptions{Account: &accountID, Name: &tagName} - - req, err := s.client.newRequest("GET", "tags", &options) - if err != nil { - return nil, err - } - - tl := &TagList{} - err = s.client.do(ctx, req, tl) - if err != nil { - return nil, err - } - if len(tl.Items) != 1 { - return nil, errors.New("invalid filters") - } - - return tl.Items[0], nil -} - -// ReadByID reads a tag by its ID. -func (s *tags) ReadByID(ctx context.Context, tagID string) (*Tag, error) { +// Read reads a tag by its ID. +func (s *tags) Read(ctx context.Context, tagID string) (*Tag, error) { if !validStringID(&tagID) { return nil, errors.New("invalid value for tag ID") } diff --git a/tag_test.go b/tag_test.go index fe653f0..9301e81 100644 --- a/tag_test.go +++ b/tag_test.go @@ -21,23 +21,23 @@ func TestTagsList(t *testing.T) { t.Run("without options", func(t *testing.T) { tagl, err := client.Tags.List(ctx, TagListOptions{}) require.NoError(t, err) - taglIDs := make([]string, len(tagl.Items)) - for _, tag := range tagl.Items { - taglIDs = append(taglIDs, tag.ID) + assert.Equal(t, 2, tagl.TotalCount) + + tagIDs := make([]string, len(tagl.Items)) + for i, tag := range tagl.Items { + tagIDs[i] = tag.ID } - assert.Contains(t, taglIDs, tagTest1.ID) - assert.Contains(t, taglIDs, tagTest2.ID) + assert.Contains(t, tagIDs, tagTest1.ID) + assert.Contains(t, tagIDs, tagTest2.ID) }) t.Run("with options", func(t *testing.T) { - tagl, err := client.Tags.List(ctx, TagListOptions{Account: String(defaultAccountID)}) + tagl, err := client.Tags.List(ctx, + TagListOptions{Account: String(defaultAccountID), Name: String(tagTest1.Name)}, + ) require.NoError(t, err) - taglIDs := make([]string, len(tagl.Items)) - for _, tag := range tagl.Items { - taglIDs = append(taglIDs, tag.ID) - } - assert.Contains(t, taglIDs, tagTest1.ID) - assert.Contains(t, taglIDs, tagTest2.ID) + assert.Equal(t, 1, tagl.TotalCount) + assert.Equal(t, tagTest1.ID, tagl.Items[0].ID) }) } @@ -47,7 +47,7 @@ func TestTagsCreate(t *testing.T) { t.Run("with valid options", func(t *testing.T) { options := TagCreateOptions{ - Name: String("test-role-" + randomString(t)), + Name: String("tst-" + randomString(t)), Account: &Account{ID: defaultAccountID}, } @@ -55,7 +55,7 @@ func TestTagsCreate(t *testing.T) { require.NoError(t, err) // Get a refreshed view from the API. - refreshed, err := client.Tags.ReadByID(ctx, tag.ID) + refreshed, err := client.Tags.Read(ctx, tag.ID) require.NoError(t, err) for _, item := range []*Tag{ @@ -111,40 +111,22 @@ func TestTagsRead(t *testing.T) { defer tagTestCleanup() t.Run("by ID when the tag exists", func(t *testing.T) { - tag, err := client.Tags.ReadByID(ctx, tagTest.ID) + tag, err := client.Tags.Read(ctx, tagTest.ID) require.NoError(t, err) assert.Equal(t, tagTest.ID, tag.ID) }) t.Run("by ID when the tag does not exist", func(t *testing.T) { - tag, err := client.Tags.ReadByID(ctx, "tag-nonexisting") + tag, err := client.Tags.Read(ctx, "tag-nonexisting") assert.Nil(t, tag) assert.Error(t, err) }) t.Run("by ID without a valid tag ID", func(t *testing.T) { - tag, err := client.Tags.ReadByID(ctx, badIdentifier) + tag, err := client.Tags.Read(ctx, badIdentifier) assert.Nil(t, tag) assert.EqualError(t, err, "invalid value for tag ID") }) - - t.Run("by name when the tag exists", func(t *testing.T) { - tag, err := client.Tags.Read(ctx, defaultAccountID, tagTest.Name) - require.NoError(t, err) - assert.Equal(t, tagTest.ID, tag.ID) - }) - - t.Run("by name when the tag does not exist", func(t *testing.T) { - tag, err := client.Tags.Read(ctx, defaultAccountID, "tag-nonexisting") - assert.Nil(t, tag) - assert.Error(t, err) - }) - - t.Run("by name without a valid account ID", func(t *testing.T) { - tag, err := client.Tags.Read(ctx, "acc-nonexisting", tagTest.Name) - assert.Nil(t, tag) - assert.Error(t, err) - }) } func TestTagsUpdate(t *testing.T) { @@ -163,7 +145,7 @@ func TestTagsUpdate(t *testing.T) { require.NoError(t, err) // Get a refreshed view from the API. - refreshed, err := client.Tags.ReadByID(ctx, tagTest.ID) + refreshed, err := client.Tags.Read(ctx, tagTest.ID) require.NoError(t, err) for _, item := range []*Tag{ @@ -193,7 +175,7 @@ func TestTagsDelete(t *testing.T) { err := client.Tags.Delete(ctx, tagTest.ID) require.NoError(t, err) - _, err = client.Tags.ReadByID(ctx, tagTest.ID) + _, err = client.Tags.Read(ctx, tagTest.ID) assert.Equal( t, ResourceNotFoundError{ diff --git a/workspace.go b/workspace.go index a397d04..e211946 100644 --- a/workspace.go +++ b/workspace.go @@ -86,7 +86,7 @@ type Workspace struct { VcsProvider *VcsProvider `jsonapi:"relation,vcs-provider"` AgentPool *AgentPool `jsonapi:"relation,agent-pool"` ModuleVersion *ModuleVersion `jsonapi:"relation,module-version,omitempty"` - Tags []*Tag `jsonapi:"relation,tags,omitempty"` + Tags []*Tag `jsonapi:"relation,tags"` } // Hooks contains the custom hooks field. @@ -211,6 +211,9 @@ type WorkspaceCreateOptions struct { // Specifies the number of minutes run operation can be executed before termination. RunOperationTimeout *int `jsonapi:"attr,run-operation-timeout"` + + // Specifies tags assigned to the workspace + Tags []*Tag `jsonapi:"relation,tags,omitempty"` } // WorkspaceVCSRepoOptions represents the configuration options of a VCS integration. diff --git a/workspace_tags.go b/workspace_tags.go index b45e357..f2fbd99 100644 --- a/workspace_tags.go +++ b/workspace_tags.go @@ -2,7 +2,6 @@ package scalr import ( "context" - "errors" "fmt" "net/url" ) @@ -13,8 +12,9 @@ var _ WorkspaceTags = (*workspaceTag)(nil) // WorkspaceTags describes all the workspace tags related methods that the // Scalr API supports. type WorkspaceTags interface { - Create(ctx context.Context, options WorkspaceTagsCreateOptions) error - Update(ctx context.Context, options WorkspaceTagsUpdateOptions) error + Add(ctx context.Context, wsID string, tags []*TagRelation) error + Replace(ctx context.Context, wsID string, tags []*TagRelation) error + Delete(ctx context.Context, wsID string, tags []*TagRelation) error } // workspaceTag implements WorkspaceTags. @@ -22,47 +22,21 @@ type workspaceTag struct { client *Client } -// WorkspaceTag represents a single workspace tag relation. -type WorkspaceTag struct { - ID string `jsonapi:"primary,tags"` -} - -// WorkspaceTagsCreateOptions represents options for adding tags to a workspace. -type WorkspaceTagsCreateOptions struct { - WorkspaceID string - WorkspaceTags []*WorkspaceTag -} - -// WorkspaceTagsUpdateOptions represents options for updating tags in a workspace. -type WorkspaceTagsUpdateOptions struct { - WorkspaceID string - WorkspaceTags []*WorkspaceTag -} - -func (o WorkspaceTagsCreateOptions) valid() error { - if !validStringID(&o.WorkspaceID) { - return errors.New("invalid value for workspace ID") - } - if o.WorkspaceTags == nil || len(o.WorkspaceTags) < 1 { - return errors.New("list of tags is required") +// Add tags to the workspace +func (s *workspaceTag) Add(ctx context.Context, wsID string, trs []*TagRelation) error { + u := fmt.Sprintf("workspaces/%s/relationships/tags", url.QueryEscape(wsID)) + req, err := s.client.newRequest("POST", u, trs) + if err != nil { + return err } - return nil -} -func (o WorkspaceTagsUpdateOptions) valid() error { - if !validStringID(&o.WorkspaceID) { - return errors.New("invalid value for workspace ID") - } - return nil + return s.client.do(ctx, req, nil) } -// Create is used for adding tags to the workspace. -func (s *workspaceTag) Create(ctx context.Context, options WorkspaceTagsCreateOptions) error { - if err := options.valid(); err != nil { - return err - } - u := fmt.Sprintf("workspaces/%s/relationships/tags", url.QueryEscape(options.WorkspaceID)) - req, err := s.client.newRequest("POST", u, options.WorkspaceTags) +// Replace workspace's tags +func (s *workspaceTag) Replace(ctx context.Context, wsID string, trs []*TagRelation) error { + u := fmt.Sprintf("workspaces/%s/relationships/tags", url.QueryEscape(wsID)) + req, err := s.client.newRequest("PATCH", u, trs) if err != nil { return err } @@ -70,14 +44,10 @@ func (s *workspaceTag) Create(ctx context.Context, options WorkspaceTagsCreateOp return s.client.do(ctx, req, nil) } -// Update is used for tags replacement in the workspace. -func (s *workspaceTag) Update(ctx context.Context, options WorkspaceTagsUpdateOptions) error { - if err := options.valid(); err != nil { - return err - } - - u := fmt.Sprintf("workspaces/%s/relationships/tags", url.QueryEscape(options.WorkspaceID)) - req, err := s.client.newRequest("PATCH", u, options.WorkspaceTags) +// Delete workspace's tags +func (s *workspaceTag) Delete(ctx context.Context, wsID string, trs []*TagRelation) error { + u := fmt.Sprintf("workspaces/%s/relationships/tags", url.QueryEscape(wsID)) + req, err := s.client.newRequest("DELETE", u, trs) if err != nil { return err } diff --git a/workspace_tags_test.go b/workspace_tags_test.go index 6a2f788..31ea87e 100644 --- a/workspace_tags_test.go +++ b/workspace_tags_test.go @@ -8,112 +8,113 @@ import ( "testing" ) -func TestWorkspaceTagsCreate(t *testing.T) { +func TestWorkspaceTagsAdd(t *testing.T) { client := testClient(t) ctx := context.Background() - environment, deleteEnvironment := createEnvironment(t, client) - defer deleteEnvironment() - - workspace, deleteWorkspace := createWorkspace(t, client, environment) + workspace, deleteWorkspace := createWorkspace(t, client, nil) defer deleteWorkspace() - tag, deleteTag := createTag(t, client) - defer deleteTag() + tag1, deleteTag1 := createTag(t, client) + defer deleteTag1() + tag2, deleteTag2 := createTag(t, client) + defer deleteTag2() + tag3, deleteTag3 := createTag(t, client) + defer deleteTag3() t.Run("with valid options", func(t *testing.T) { - options := WorkspaceTagsCreateOptions{ - WorkspaceID: workspace.ID, - WorkspaceTags: []*WorkspaceTag{{ID: tag.ID}}, - } - - err := client.WorkspaceTags.Create(ctx, options) + err := client.WorkspaceTags.Add(ctx, workspace.ID, + []*TagRelation{ + {ID: tag1.ID}, + {ID: tag2.ID}, + }, + ) require.NoError(t, err) // Get a refreshed view from the API. refreshed, err := client.Workspaces.ReadByID(ctx, workspace.ID) require.NoError(t, err) + assert.Len(t, refreshed.Tags, 2) - for _, item := range refreshed.Tags { - assert.Equal(t, tag.ID, item.ID) + tagIDs := make([]string, len(refreshed.Tags)) + for _, tag := range refreshed.Tags { + tagIDs = append(tagIDs, tag.ID) } + assert.Contains(t, tagIDs, tag1.ID) + assert.Contains(t, tagIDs, tag2.ID) }) - t.Run("without valid workspace ID", func(t *testing.T) { - err := client.WorkspaceTags.Create(ctx, WorkspaceTagsCreateOptions{ - WorkspaceTags: []*WorkspaceTag{{ID: tag.ID}}, - }) - assert.EqualError(t, err, "invalid value for workspace ID") - }) + t.Run("add another one", func(t *testing.T) { + err := client.WorkspaceTags.Add(ctx, workspace.ID, []*TagRelation{{ID: tag3.ID}}) + require.NoError(t, err) - t.Run("without valid workspace tags", func(t *testing.T) { - err := client.WorkspaceTags.Create(ctx, WorkspaceTagsCreateOptions{ - WorkspaceID: workspace.ID, - }) - assert.EqualError(t, err, "list of tags is required") + // Get a refreshed view from the API. + refreshed, err := client.Workspaces.ReadByID(ctx, workspace.ID) + require.NoError(t, err) + assert.Len(t, refreshed.Tags, 3) + + tagIDs := make([]string, len(refreshed.Tags)) + for _, tag := range refreshed.Tags { + tagIDs = append(tagIDs, tag.ID) + } + assert.Contains(t, tagIDs, tag1.ID) + assert.Contains(t, tagIDs, tag2.ID) + assert.Contains(t, tagIDs, tag3.ID) }) - t.Run("when options have an invalid tag", func(t *testing.T) { + t.Run("with invalid tag", func(t *testing.T) { tagID := "tag-invalid-id" - err := client.WorkspaceTags.Create(ctx, WorkspaceTagsCreateOptions{ - WorkspaceID: workspace.ID, - WorkspaceTags: []*WorkspaceTag{{ID: tagID}}, - }) + err := client.WorkspaceTags.Add(ctx, workspace.ID, []*TagRelation{{ID: tagID}}) assert.EqualError(t, err, fmt.Sprintf("Not Found\n\nTag with ID '%s' not found or user unauthorized.", tagID)) }) } -func TestWorkspaceTagsUpdate(t *testing.T) { +func TestWorkspaceTagsReplace(t *testing.T) { client := testClient(t) ctx := context.Background() - environment, deleteEnvironment := createEnvironment(t, client) - defer deleteEnvironment() - - workspace, deleteWorkspace := createWorkspace(t, client, environment) + workspace, deleteWorkspace := createWorkspace(t, client, nil) defer deleteWorkspace() - tag, deleteTag := createTag(t, client) - defer deleteTag() + tag1, deleteTag1 := createTag(t, client) + defer deleteTag1() + tag2, deleteTag2 := createTag(t, client) + defer deleteTag2() + tag3, deleteTag3 := createTag(t, client) + defer deleteTag3() - t.Run("with valid options", func(t *testing.T) { - options := WorkspaceTagsUpdateOptions{ - WorkspaceID: workspace.ID, - WorkspaceTags: []*WorkspaceTag{{ID: tag.ID}}, - } + assignTagsToWorkspace(t, client, workspace, []*Tag{tag1}) - err := client.WorkspaceTags.Update(ctx, options) + t.Run("with valid options", func(t *testing.T) { + err := client.WorkspaceTags.Replace(ctx, workspace.ID, + []*TagRelation{ + {ID: tag2.ID}, + {ID: tag3.ID}, + }, + ) require.NoError(t, err) // Get a refreshed view from the API. refreshed, err := client.Workspaces.ReadByID(ctx, workspace.ID) require.NoError(t, err) + assert.Len(t, refreshed.Tags, 2) - for _, item := range refreshed.Tags { - assert.Equal(t, tag.ID, item.ID) + tagIDs := make([]string, len(refreshed.Tags)) + for _, tag := range refreshed.Tags { + tagIDs = append(tagIDs, tag.ID) } + assert.Contains(t, tagIDs, tag2.ID) + assert.Contains(t, tagIDs, tag3.ID) }) - t.Run("without valid workspace ID", func(t *testing.T) { - err := client.WorkspaceTags.Update(ctx, WorkspaceTagsUpdateOptions{}) - assert.EqualError(t, err, "invalid value for workspace ID") - }) - - t.Run("with invalid workspace tag", func(t *testing.T) { + t.Run("with invalid tag", func(t *testing.T) { tagID := "tag-invalid-id" - options := WorkspaceTagsUpdateOptions{ - WorkspaceID: workspace.ID, - WorkspaceTags: []*WorkspaceTag{{ID: tagID}}, - } - - err := client.WorkspaceTags.Update(ctx, options) + err := client.WorkspaceTags.Replace(ctx, workspace.ID, []*TagRelation{{ID: tagID}}) assert.EqualError(t, err, fmt.Sprintf("Not Found\n\nTag with ID '%s' not found or user unauthorized.", tagID)) }) t.Run("when all tags should be removed", func(t *testing.T) { - err := client.WorkspaceTags.Update(ctx, WorkspaceTagsUpdateOptions{ - WorkspaceID: workspace.ID, - }) + err := client.WorkspaceTags.Replace(ctx, workspace.ID, make([]*TagRelation, 0)) require.NoError(t, err) // Get a refreshed view from the API. @@ -122,3 +123,42 @@ func TestWorkspaceTagsUpdate(t *testing.T) { assert.Empty(t, refreshed.Tags) }) } + +func TestWorkspaceTagsDelete(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + workspace, deleteWorkspace := createWorkspace(t, client, nil) + defer deleteWorkspace() + + tag1, deleteTag1 := createTag(t, client) + defer deleteTag1() + tag2, deleteTag2 := createTag(t, client) + defer deleteTag2() + tag3, deleteTag3 := createTag(t, client) + defer deleteTag3() + + assignTagsToWorkspace(t, client, workspace, []*Tag{tag1, tag2, tag3}) + + t.Run("with valid options", func(t *testing.T) { + err := client.WorkspaceTags.Delete(ctx, workspace.ID, + []*TagRelation{ + {ID: tag1.ID}, + {ID: tag2.ID}, + }, + ) + require.NoError(t, err) + + // Get a refreshed view from the API. + refreshed, err := client.Workspaces.ReadByID(ctx, workspace.ID) + require.NoError(t, err) + assert.Len(t, refreshed.Tags, 1) + assert.Equal(t, tag3.ID, refreshed.Tags[0].ID) + }) + + t.Run("with invalid tag", func(t *testing.T) { + tagID := "tag-invalid-id" + err := client.WorkspaceTags.Replace(ctx, workspace.ID, []*TagRelation{{ID: tagID}}) + assert.EqualError(t, err, fmt.Sprintf("Not Found\n\nTag with ID '%s' not found or user unauthorized.", tagID)) + }) +}