Repository: slack-go/slack
Pull request #1044 (2022-03-16 19:49:32)

Add `bookmarks.<add|edit|remove|list>` support
### Summary

Add support for the `bookmarks.<add|edit|remove|list>` endpoints https://api.slack.com/apis/bookmarks-api

### Motivation

Implements bookmarks per request in #1036

##### PR preparation
Run `make pr-prep` from the root of the repository to run formatting, linting and tests.
- [x] Done!

##### API changes

Since API changes have to be maintained they undergo a more detailed review and are more likely to require changes.

This PR does include API changes since it exposes new endpoints for accessing
- `bookmarks.add` https://api.slack.com/methods/bookmarks.add
- `bookmarks.edit` https://api.slack.com/methods/bookmarks.edit
- `bookmarks.remove` https://api.slack.com/methods/bookmarks.remove
- `bookmarks.list` https://api.slack.com/methods/bookmarks.list

All implemented methods include tests


Patch:
diff --git a/bookmarks.go b/bookmarks.go
new file mode 100644
index 0000000..7875350
--- /dev/null
+++ b/bookmarks.go
@@ -0,0 +1,159 @@
+package slack
+
+import (
+	"context"
+	"net/url"
+)
+
+type Bookmark struct {
+	ID        string   `json:"id"`
+	ChannelID string   `json:"channel_id"`
+	Title     string   `json:"title"`
+	Link      string   `json:"link"`
+	Emoji     string   `json:"emoji"`
+	IconURL   string   `json:"icon_url"`
+	Type      string   `json:"type"`
+	Created   JSONTime `json:"date_created"`
+	Updated   JSONTime `json:"date_updated"`
+	Rank      string   `json:"rank"`
+
+	LastUpdatedByUserID string `json:"last_updated_by_user_id"`
+	LastUpdatedByTeamID string `json:"last_updated_by_team_id"`
+
+	ShortcutID string `json:"shortcut_id"`
+	EntityID   string `json:"entity_id"`
+	AppID      string `json:"app_id"`
+}
+
+type AddBookmarkParameters struct {
+	Title    string // A required title for the bookmark
+	Type     string // A required type for the bookmark
+	Link     string // URL required for type:link
+	Emoji    string // An optional emoji
+	EntityID string
+	ParentID string
+}
+
+type EditBookmarkParameters struct {
+	Title *string // Change the title. Set to "" to clear
+	Emoji *string // Change the emoji. Set to "" to clear
+	Link  string  // Change the link
+}
+
+type addBookmarkResponse struct {
+	Bookmark Bookmark `json:"bookmark"`
+	SlackResponse
+}
+
+type editBookmarkResponse struct {
+	Bookmark Bookmark `json:"bookmark"`
+	SlackResponse
+}
+
+type listBookmarksResponse struct {
+	Bookmarks []Bookmark `json:"bookmarks"`
+	SlackResponse
+}
+
+// AddBookmark adds a bookmark in a channel
+func (api *Client) AddBookmark(channelID string, params AddBookmarkParameters) (Bookmark, error) {
+	return api.AddBookmarkContext(context.Background(), channelID, params)
+}
+
+// AddBookmarkContext adds a bookmark in a channel with a custom context
+func (api *Client) AddBookmarkContext(ctx context.Context, channelID string, params AddBookmarkParameters) (Bookmark, error) {
+	values := url.Values{
+		"channel_id": {channelID},
+		"token":      {api.token},
+		"title":      {params.Title},
+		"type":       {params.Type},
+	}
+	if params.Link != "" {
+		values.Set("link", params.Link)
+	}
+	if params.Emoji != "" {
+		values.Set("emoji", params.Emoji)
+	}
+	if params.EntityID != "" {
+		values.Set("entity_id", params.EntityID)
+	}
+	if params.ParentID != "" {
+		values.Set("parent_id", params.ParentID)
+	}
+
+	response := &addBookmarkResponse{}
+	if err := api.postMethod(ctx, "bookmarks.add", values, response); err != nil {
+		return Bookmark{}, err
+	}
+
+	return response.Bookmark, response.Err()
+}
+
+// RemoveBookmark removes a bookmark from a channel
+func (api *Client) RemoveBookmark(channelID, bookmarkID string) error {
+	return api.RemoveBookmarkContext(context.Background(), channelID, bookmarkID)
+}
+
+// RemoveBookmarkContext removes a bookmark from a channel with a custom context
+func (api *Client) RemoveBookmarkContext(ctx context.Context, channelID, bookmarkID string) error {
+	values := url.Values{
+		"channel_id":  {channelID},
+		"token":       {api.token},
+		"bookmark_id": {bookmarkID},
+	}
+
+	response := &SlackResponse{}
+	if err := api.postMethod(ctx, "bookmarks.remove", values, response); err != nil {
+		return err
+	}
+
+	return response.Err()
+}
+
+// ListBookmarks returns all bookmarks for a channel.
+func (api *Client) ListBookmarks(channelID string) ([]Bookmark, error) {
+	return api.ListBookmarksContext(context.Background(), channelID)
+}
+
+// ListBookmarksContext returns all bookmarks for a channel with a custom context.
+func (api *Client) ListBookmarksContext(ctx context.Context, channelID string) ([]Bookmark, error) {
+	values := url.Values{
+		"channel_id": {channelID},
+		"token":      {api.token},
+	}
+
+	response := &listBookmarksResponse{}
+	err := api.postMethod(ctx, "bookmarks.list", values, response)
+	if err != nil {
+		return nil, err
+	}
+	return response.Bookmarks, response.Err()
+}
+
+func (api *Client) EditBookmark(channelID, bookmarkID string, params EditBookmarkParameters) (Bookmark, error) {
+	return api.EditBookmarkContext(context.Background(), channelID, bookmarkID, params)
+}
+
+func (api *Client) EditBookmarkContext(ctx context.Context, channelID, bookmarkID string, params EditBookmarkParameters) (Bookmark, error) {
+	values := url.Values{
+		"channel_id":  {channelID},
+		"token":       {api.token},
+		"bookmark_id": {bookmarkID},
+	}
+	if params.Link != "" {
+		values.Set("link", params.Link)
+	}
+	if params.Emoji != nil {
+		values.Set("emoji", *params.Emoji)
+	}
+	if params.Title != nil {
+		values.Set("title", *params.Title)
+	}
+
+	response := &editBookmarkResponse{}
+	if err := api.postMethod(ctx, "bookmarks.edit", values, response); err != nil {
+		return Bookmark{}, err
+	}
+
+	return response.Bookmark, response.Err()
+}


Test patch:
diff --git a/bookmarks_test.go b/bookmarks_test.go
new file mode 100644
index 0000000..43e5bb9
--- /dev/null
+++ b/bookmarks_test.go
@@ -0,0 +1,237 @@
+package slack
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+)
+
+func getTestBookmark(channelID, bookmarkID string) Bookmark {
+	return Bookmark{
+		ID:        bookmarkID,
+		ChannelID: channelID,
+		Title:     "bookmark",
+		Type:      "link",
+		Link:      "https://example.com",
+		IconURL:   "https://example.com/icon.png",
+	}
+}
+
+func addBookmarkLinkHandler(rw http.ResponseWriter, r *http.Request) {
+	channelID := r.FormValue("channel_id")
+	title := r.FormValue("title")
+	bookmarkType := r.FormValue("type")
+	link := r.FormValue("link")
+
+	rw.Header().Set("Content-Type", "application/json")
+
+	if bookmarkType == "link" && link != "" && channelID != "" && title != "" {
+		bookmark := getTestBookmark(channelID, "Bk123RBZG8GZ")
+		bookmark.Title = title
+		bookmark.Type = bookmarkType
+		bookmark.Link = link
+
+		resp, _ := json.Marshal(&addBookmarkResponse{
+			SlackResponse: SlackResponse{Ok: true},
+			Bookmark:      bookmark})
+		rw.Write(resp)
+	} else {
+		rw.Write([]byte(`{ "ok": false, "error": "errored" }`))
+	}
+}
+
+func TestAddBookmarkLink(t *testing.T) {
+	http.HandleFunc("/bookmarks.add", addBookmarkLinkHandler)
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	params := AddBookmarkParameters{
+		Title: "test",
+		Type:  "link",
+		Link:  "https://example.com",
+	}
+	_, err := api.AddBookmark("CXXXXXXXX", params)
+	if err != nil {
+		t.Fatalf("Unexpected error: %s", err)
+	}
+}
+
+func listBookmarksHandler(rw http.ResponseWriter, r *http.Request) {
+	channelID := r.FormValue("channel_id")
+
+	rw.Header().Set("Content-Type", "application/json")
+
+	if channelID != "" {
+		bookmarks := []Bookmark{
+			getTestBookmark(channelID, "Bk001"),
+			getTestBookmark(channelID, "Bk002"),
+			getTestBookmark(channelID, "Bk003"),
+			getTestBookmark(channelID, "Bk004"),
+		}
+
+		resp, _ := json.Marshal(&listBookmarksResponse{
+			SlackResponse: SlackResponse{Ok: true},
+			Bookmarks:     bookmarks})
+		rw.Write(resp)
+	} else {
+		rw.Write([]byte(`{ "ok": false, "error": "errored" }`))
+	}
+}
+
+func TestListBookmarks(t *testing.T) {
+	http.HandleFunc("/bookmarks.list", listBookmarksHandler)
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	channel := "CXXXXXXXX"
+	bookmarks, err := api.ListBookmarks(channel)
+	if err != nil {
+		t.Fatalf("Unexpected error: %s", err)
+	}
+
+	if !reflect.DeepEqual([]Bookmark{
+		getTestBookmark(channel, "Bk001"),
+		getTestBookmark(channel, "Bk002"),
+		getTestBookmark(channel, "Bk003"),
+		getTestBookmark(channel, "Bk004"),
+	}, bookmarks) {
+		t.Fatal(ErrIncorrectResponse)
+	}
+}
+
+func removeBookmarkHandler(bookmark *Bookmark) func(rw http.ResponseWriter, r *http.Request) {
+	return func(rw http.ResponseWriter, r *http.Request) {
+		channelID := r.FormValue("channel_id")
+		bookmarkID := r.FormValue("bookmark_id")
+
+		rw.Header().Set("Content-Type", "application/json")
+
+		if channelID == bookmark.ChannelID && bookmarkID == bookmark.ID {
+			rw.Write([]byte(`{ "ok": true }`))
+		} else {
+			rw.Write([]byte(`{ "ok": false, "error": "errored" }`))
+		}
+	}
+}
+
+func TestRemoveBookmark(t *testing.T) {
+	channel := "CXXXXXXXX"
+	bookmark := getTestBookmark(channel, "BkXXXXX")
+	http.HandleFunc("/bookmarks.remove", removeBookmarkHandler(&bookmark))
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+
+	err := api.RemoveBookmark(channel, bookmark.ID)
+	if err != nil {
+		t.Fatalf("Unexpected error: %s", err)
+	}
+}
+
+func editBookmarkHandler(bookmarks []Bookmark) func(rw http.ResponseWriter, r *http.Request) {
+	return func(rw http.ResponseWriter, r *http.Request) {
+		channelID := r.FormValue("channel_id")
+		bookmarkID := r.FormValue("bookmark_id")
+
+		rw.Header().Set("Content-Type", "application/json")
+		if err := r.ParseForm(); err != nil {
+			httpTestErrReply(rw, true, fmt.Sprintf("err parsing form: %s", err.Error()))
+			return
+		}
+
+		for _, bookmark := range bookmarks {
+			if bookmark.ID == bookmarkID && bookmark.ChannelID == channelID {
+				if v := r.Form.Get("link"); v != "" {
+					bookmark.Link = v
+				}
+				// Emoji and title require special handling since empty string sets to null
+				if _, ok := r.Form["emoji"]; ok {
+					bookmark.Emoji = r.Form.Get("emoji")
+				}
+				if _, ok := r.Form["title"]; ok {
+					bookmark.Title = r.Form.Get("title")
+				}
+				resp, _ := json.Marshal(&editBookmarkResponse{
+					SlackResponse: SlackResponse{Ok: true},
+					Bookmark:      bookmark})
+				rw.Write(resp)
+				return
+			}
+		}
+		// Fail if the bookmark doesn't exist
+		rw.Write([]byte(`{ "ok": false, "error": "not_found" }`))
+	}
+}
+
+func TestEditBookmark(t *testing.T) {
+	channel := "CXXXXXXXX"
+	bookmarks := []Bookmark{
+		getTestBookmark(channel, "Bk001"),
+		getTestBookmark(channel, "Bk002"),
+		getTestBookmark(channel, "Bk003"),
+		getTestBookmark(channel, "Bk004"),
+	}
+	http.HandleFunc("/bookmarks.edit", editBookmarkHandler(bookmarks))
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+
+	smileEmoji := ":smile:"
+	empty := ""
+	title := "hello, world!"
+	changes := []struct {
+		ID     string
+		Params EditBookmarkParameters
+	}{
+		{ // add emoji
+			ID:     "Bk001",
+			Params: EditBookmarkParameters{Emoji: &smileEmoji},
+		},
+		{ // delete emoji
+			ID:     "Bk001",
+			Params: EditBookmarkParameters{Emoji: &empty},
+		},
+		{ // add title
+			ID:     "Bk002",
+			Params: EditBookmarkParameters{Title: &title},
+		},
+		{ // delete title
+			ID:     "Bk002",
+			Params: EditBookmarkParameters{Title: &empty},
+		},
+		{ // Change multiple fields at once
+			ID: "Bk003",
+			Params: EditBookmarkParameters{
+				Title: &title,
+				Emoji: &empty,
+				Link:  "https://example.com/changed",
+			},
+		},
+		{ // noop
+			ID: "Bk004",
+		},
+	}
+
+	for _, change := range changes {
+		bookmark, err := api.EditBookmark(channel, change.ID, change.Params)
+		if err != nil {
+			t.Fatalf("Unexpected error: %s", err)
+		}
+		if change.ID != bookmark.ID {
+			t.Fatalf("expected to modify bookmark with ID = %s, got %s", change.ID, bookmark.ID)
+		}
+		if change.Params.Emoji != nil && bookmark.Emoji != *change.Params.Emoji {
+			t.Fatalf("expected bookmark.Emoji = %s, got %s", *change.Params.Emoji, bookmark.Emoji)
+		}
+		if change.Params.Title != nil && bookmark.Title != *change.Params.Title {
+			t.Fatalf("expected bookmark.Title = %s, got %s", *change.Params.Title, bookmark.Emoji)
+		}
+		if change.Params.Link != "" && change.Params.Link != bookmark.Link {
+			t.Fatalf("expected bookmark.Link = %s, got %s", change.Params.Link, bookmark.Link)
+		}
+	}
+
+	// Cover the final case of trying to edit a bookmark which doesn't exist
+	bookmark, err := api.EditBookmark(channel, "BkMissing", EditBookmarkParameters{})
+	if err == nil {
+		t.Fatalf("Expected not found error, but got bookmark %s", bookmark.ID)
+	}
+}