From ed23d080ce23b8f05432633dbf10ab70ff235799 Mon Sep 17 00:00:00 2001 From: David Stotijn Date: Sun, 4 Sep 2022 20:48:51 +0200 Subject: [PATCH] Add support for new Comments endpoints (#43) * Add `Client.CreateComment` method * Add `Client.FindCommentsByBlockID` method --- README.md | 7 +- client.go | 82 +++++++ client_test.go | 421 ++++++++++++++++++++++++++++++++ comment.go | 80 ++++++ examples/create-comment/main.go | 79 ++++++ examples/list-comments/main.go | 72 ++++++ 6 files changed, 740 insertions(+), 1 deletion(-) create mode 100644 comment.go create mode 100644 examples/create-comment/main.go create mode 100644 examples/list-comments/main.go diff --git a/README.md b/README.md index ef818cb..a57ae9a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## Features The client supports all (non-deprecated) endpoints available in the Notion API, -as of December 22, 2021: +as of September 4, 2022: ### Databases @@ -46,6 +46,11 @@ as of December 22, 2021: - [x] [Search](https://pkg.go.dev/github.com/dstotijn/go-notion#Client.Search) +### Comments + +- [x] [Retrieve comments](https://pkg.go.dev/github.com/dstotijn/go-notion#Client.FindCommentsByBlockID) +- [x] [Create a comment](https://pkg.go.dev/github.com/dstotijn/go-notion#Client.CreateComment) + ## Installation ```sh diff --git a/client.go b/client.go index 94ceb02..e44f533 100644 --- a/client.go +++ b/client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -626,3 +627,84 @@ func (c *Client) Search(ctx context.Context, opts *SearchOpts) (result SearchRes return result, nil } + +// CreateComment creates a comment in a page or existing discussion thread. +// See: https://developers.notion.com/reference/create-a-comment +func (c *Client) CreateComment(ctx context.Context, params CreateCommentParams) (comment Comment, err error) { + if err := params.Validate(); err != nil { + return Comment{}, fmt.Errorf("notion: invalid comment params: %w", err) + } + + body := &bytes.Buffer{} + + err = json.NewEncoder(body).Encode(params) + if err != nil { + return Comment{}, fmt.Errorf("notion: failed to encode body params to JSON: %w", err) + } + + req, err := c.newRequest(ctx, http.MethodPost, "/comments", body) + if err != nil { + return Comment{}, fmt.Errorf("notion: invalid request: %w", err) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return Comment{}, fmt.Errorf("notion: failed to make HTTP request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return Comment{}, fmt.Errorf("notion: failed to create comment: %w", parseErrorResponse(res)) + } + + err = json.NewDecoder(res.Body).Decode(&comment) + if err != nil { + return Comment{}, fmt.Errorf("notion: failed to parse HTTP response: %w", err) + } + + return comment, nil +} + +// FindCommentsByBlockID returns a list of unresolved comments by parent block +// ID, and pagination metadata. +// See: https://developers.notion.com/reference/retrieve-a-comment +func (c *Client) FindCommentsByBlockID( + ctx context.Context, + query FindCommentsByBlockIDQuery, +) (result FindCommentsResponse, err error) { + req, err := c.newRequest(ctx, http.MethodGet, "/comments", nil) + if err != nil { + return FindCommentsResponse{}, fmt.Errorf("notion: invalid request: %w", err) + } + + if query.BlockID == "" { + return FindCommentsResponse{}, errors.New("notion: block ID query field is required") + } + + q := url.Values{} + q.Set("block_id", query.BlockID) + if query.StartCursor != "" { + q.Set("start_cursor", query.StartCursor) + } + if query.PageSize != 0 { + q.Set("page_size", strconv.Itoa(query.PageSize)) + } + req.URL.RawQuery = q.Encode() + + res, err := c.httpClient.Do(req) + if err != nil { + return FindCommentsResponse{}, fmt.Errorf("notion: failed to make HTTP request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return FindCommentsResponse{}, fmt.Errorf("notion: failed to list comments: %w", parseErrorResponse(res)) + } + + err = json.NewDecoder(res.Body).Decode(&result) + if err != nil { + return FindCommentsResponse{}, fmt.Errorf("notion: failed to parse HTTP response: %w", err) + } + + return result, nil +} diff --git a/client_test.go b/client_test.go index cb408ac..e93b661 100644 --- a/client_test.go +++ b/client_test.go @@ -4231,3 +4231,424 @@ func TestDeleteBlock(t *testing.T) { }) } } + +func TestCreateComment(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params notion.CreateCommentParams + respBody func(r *http.Request) io.Reader + respStatusCode int + expPostBody map[string]interface{} + expResponse notion.Comment + expError error + }{ + { + name: "successful response", + params: notion.CreateCommentParams{ + ParentPageID: "8046f83a-09d3-4218-b308-2c0954a7f5d6", + RichText: []notion.RichText{ + { + Text: ¬ion.Text{ + Content: "This is an example comment.", + }, + }, + }, + }, + respBody: func(_ *http.Request) io.Reader { + return strings.NewReader( + `{ + "created_by": { + "id": "25c9cc08-1afd-4d22-b9e6-31b0f6e7b44f", + "object": "user" + }, + "created_time": "2022-09-04T14:15:00.000Z", + "discussion_id": "729d95d1-a804-4bc4-ab6a-adbb5de8c9b3", + "id": "ade11b15-10f1-474a-97dd-955073779f39", + "last_edited_time": "2022-09-04T14:15:00.000Z", + "object": "comment", + "parent": { + "page_id": "8046f83a-09d3-4218-b308-2c0954a7f5d6", + "type": "page_id" + }, + "rich_text": [ + { + "annotations": { + "bold": false, + "code": false, + "color": "default", + "italic": false, + "strikethrough": false, + "underline": false + }, + "href": null, + "plain_text": "This is an example comment.", + "text": { + "content": "This is an example comment.", + "link": null + }, + "type": "text" + } + ] + }`, + ) + }, + respStatusCode: http.StatusOK, + expPostBody: map[string]interface{}{ + "parent": map[string]interface{}{ + "type": "page_id", + "page_id": "8046f83a-09d3-4218-b308-2c0954a7f5d6", + }, + "rich_text": []interface{}{ + map[string]interface{}{ + "text": map[string]interface{}{ + "content": "This is an example comment.", + }, + }, + }, + }, + expResponse: notion.Comment{ + ID: "ade11b15-10f1-474a-97dd-955073779f39", + DiscussionID: "729d95d1-a804-4bc4-ab6a-adbb5de8c9b3", + CreatedTime: mustParseTime(time.RFC3339Nano, "2022-09-04T14:15:00.000Z"), + LastEditedTime: mustParseTime(time.RFC3339Nano, "2022-09-04T14:15:00.000Z"), + CreatedBy: notion.BaseUser{ + ID: "25c9cc08-1afd-4d22-b9e6-31b0f6e7b44f", + }, + Parent: notion.Parent{ + Type: notion.ParentTypePage, + PageID: "8046f83a-09d3-4218-b308-2c0954a7f5d6", + }, + RichText: []notion.RichText{ + { + Type: "text", + Annotations: ¬ion.Annotations{ + Color: "default", + }, + PlainText: "This is an example comment.", + HRef: nil, + Text: ¬ion.Text{ + Content: "This is an example comment.", + }, + }, + }, + }, + expError: nil, + }, + { + name: "error response", + params: notion.CreateCommentParams{ + ParentPageID: "8046f83a-09d3-4218-b308-2c0954a7f5d6", + RichText: []notion.RichText{ + { + Text: ¬ion.Text{ + Content: "This is an example comment.", + }, + }, + }, + }, + respBody: func(_ *http.Request) io.Reader { + return strings.NewReader( + `{ + "object": "error", + "status": 400, + "code": "validation_error", + "message": "foobar" + }`, + ) + }, + respStatusCode: http.StatusBadRequest, + expPostBody: map[string]interface{}{ + "parent": map[string]interface{}{ + "type": "page_id", + "page_id": "8046f83a-09d3-4218-b308-2c0954a7f5d6", + }, + "rich_text": []interface{}{ + map[string]interface{}{ + "text": map[string]interface{}{ + "content": "This is an example comment.", + }, + }, + }, + }, + expResponse: notion.Comment{}, + expError: errors.New("notion: failed to create comment: foobar (code: validation_error, status: 400)"), + }, + { + name: "parent ID and discussion ID both missing error", + params: notion.CreateCommentParams{ + RichText: []notion.RichText{ + { + Text: ¬ion.Text{ + Content: "This is an example comment.", + }, + }, + }, + }, + expResponse: notion.Comment{}, + expError: errors.New("notion: invalid comment params: either parent page ID or discussion ID is required"), + }, + { + name: "parent ID and discussion ID both non-empty error", + params: notion.CreateCommentParams{ + ParentPageID: "foo", + DiscussionID: "bar", + RichText: []notion.RichText{ + { + Text: ¬ion.Text{ + Content: "This is an example comment.", + }, + }, + }, + }, + expResponse: notion.Comment{}, + expError: errors.New("notion: invalid comment params: parent page ID and discussion ID cannot both be non-empty"), + }, + { + name: "rich text zero length error", + params: notion.CreateCommentParams{ + ParentPageID: "foo", + }, + expResponse: notion.Comment{}, + expError: errors.New("notion: invalid comment params: rich text is required"), + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + httpClient := &http.Client{ + Transport: &mockRoundtripper{fn: func(r *http.Request) (*http.Response, error) { + postBody := make(map[string]interface{}) + + err := json.NewDecoder(r.Body).Decode(&postBody) + if err != nil && err != io.EOF { + t.Fatal(err) + } + + if len(tt.expPostBody) == 0 && len(postBody) != 0 { + t.Errorf("unexpected post body: %#v", postBody) + } + + if len(tt.expPostBody) != 0 && len(postBody) == 0 { + t.Errorf("post body not equal (expected %+v, got: nil)", tt.expPostBody) + } + + if len(tt.expPostBody) != 0 && len(postBody) != 0 { + if diff := cmp.Diff(tt.expPostBody, postBody); diff != "" { + t.Errorf("post body not equal (-exp, +got):\n%v", diff) + } + } + + return &http.Response{ + StatusCode: tt.respStatusCode, + Status: http.StatusText(tt.respStatusCode), + Body: ioutil.NopCloser(tt.respBody(r)), + }, nil + }}, + } + client := notion.NewClient("secret-api-key", notion.WithHTTPClient(httpClient)) + page, err := client.CreateComment(context.Background(), tt.params) + + if tt.expError == nil && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.expError != nil && err == nil { + t.Fatalf("error not equal (expected: %v, got: nil)", tt.expError) + } + if tt.expError != nil && err != nil && tt.expError.Error() != err.Error() { + t.Fatalf("error not equal (expected: %v, got: %v)", tt.expError, err) + } + + if diff := cmp.Diff(tt.expResponse, page); diff != "" { + t.Fatalf("response not equal (-exp, +got):\n%v", diff) + } + }) + } +} + +func TestFindCommentsByBlockID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query notion.FindCommentsByBlockIDQuery + respBody func(r *http.Request) io.Reader + respStatusCode int + expQueryParams url.Values + expResponse notion.FindCommentsResponse + expError error + }{ + { + name: "successful response", + query: notion.FindCommentsByBlockIDQuery{ + BlockID: "8046f83a-09d3-4218-b308-2c0954a7f5d6", + StartCursor: "7c6b1c95-de50-45ca-94e6-af1d9fd295ab", + PageSize: 42, + }, + respBody: func(_ *http.Request) io.Reader { + return strings.NewReader( + `{ + "object": "list", + "results": [ + { + "created_by": { + "id": "25c9cc08-1afd-4d22-b9e6-31b0f6e7b44f", + "object": "user" + }, + "created_time": "2022-09-04T14:15:00.000Z", + "discussion_id": "729d95d1-a804-4bc4-ab6a-adbb5de8c9b3", + "id": "ade11b15-10f1-474a-97dd-955073779f39", + "last_edited_time": "2022-09-04T14:15:00.000Z", + "object": "comment", + "parent": { + "page_id": "8046f83a-09d3-4218-b308-2c0954a7f5d6", + "type": "page_id" + }, + "rich_text": [ + { + "annotations": { + "bold": false, + "code": false, + "color": "default", + "italic": false, + "strikethrough": false, + "underline": false + }, + "href": null, + "plain_text": "This is an example comment.", + "text": { + "content": "This is an example comment.", + "link": null + }, + "type": "text" + } + ] + } + ], + "next_cursor": "A^hd", + "has_more": true + }`, + ) + }, + respStatusCode: http.StatusOK, + expQueryParams: url.Values{ + "block_id": []string{"8046f83a-09d3-4218-b308-2c0954a7f5d6"}, + "start_cursor": []string{"7c6b1c95-de50-45ca-94e6-af1d9fd295ab"}, + "page_size": []string{"42"}, + }, + expResponse: notion.FindCommentsResponse{ + Results: []notion.Comment{ + { + ID: "ade11b15-10f1-474a-97dd-955073779f39", + Parent: notion.Parent{ + Type: "page_id", + PageID: "8046f83a-09d3-4218-b308-2c0954a7f5d6", + }, + DiscussionID: "729d95d1-a804-4bc4-ab6a-adbb5de8c9b3", + RichText: []notion.RichText{ + { + Type: "text", + Annotations: ¬ion.Annotations{ + Color: "default", + }, + PlainText: "This is an example comment.", + HRef: nil, + Text: ¬ion.Text{ + Content: "This is an example comment.", + }, + }, + }, + CreatedTime: mustParseTime(time.RFC3339, "2022-09-04T14:15:00.000Z"), + LastEditedTime: mustParseTime(time.RFC3339, "2022-09-04T14:15:00.000Z"), + CreatedBy: notion.BaseUser{ + ID: "25c9cc08-1afd-4d22-b9e6-31b0f6e7b44f", + }, + }, + }, + HasMore: true, + NextCursor: notion.StringPtr("A^hd"), + }, + expError: nil, + }, + { + name: "without block ID", + query: notion.FindCommentsByBlockIDQuery{}, + expError: errors.New("notion: block ID query field is required"), + }, + { + name: "error response", + query: notion.FindCommentsByBlockIDQuery{ + BlockID: "8046f83a-09d3-4218-b308-2c0954a7f5d6", + }, + respBody: func(_ *http.Request) io.Reader { + return strings.NewReader( + `{ + "object": "error", + "status": 400, + "code": "validation_error", + "message": "foobar" + }`, + ) + }, + respStatusCode: http.StatusBadRequest, + expQueryParams: url.Values{ + "block_id": []string{"8046f83a-09d3-4218-b308-2c0954a7f5d6"}, + }, + expResponse: notion.FindCommentsResponse{}, + expError: errors.New("notion: failed to list comments: foobar (code: validation_error, status: 400)"), + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + httpClient := &http.Client{ + Transport: &mockRoundtripper{fn: func(r *http.Request) (*http.Response, error) { + q := r.URL.Query() + + if len(tt.expQueryParams) == 0 && len(q) != 0 { + t.Errorf("unexpected query params: %+v", q) + } + + if len(tt.expQueryParams) != 0 && len(q) == 0 { + t.Errorf("query params not equal (expected %+v, got: nil)", tt.expQueryParams) + } + + if len(tt.expQueryParams) != 0 && len(q) != 0 { + if diff := cmp.Diff(tt.expQueryParams, q); diff != "" { + t.Errorf("query params not equal (-exp, +got):\n%v", diff) + } + } + + return &http.Response{ + StatusCode: tt.respStatusCode, + Status: http.StatusText(tt.respStatusCode), + Body: ioutil.NopCloser(tt.respBody(r)), + }, nil + }}, + } + client := notion.NewClient("secret-api-key", notion.WithHTTPClient(httpClient)) + resp, err := client.FindCommentsByBlockID(context.Background(), tt.query) + + if tt.expError == nil && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.expError != nil && err == nil { + t.Fatalf("error not equal (expected: %v, got: nil)", tt.expError) + } + if tt.expError != nil && err != nil && tt.expError.Error() != err.Error() { + t.Fatalf("error not equal (expected: %v, got: %v)", tt.expError, err) + } + + if diff := cmp.Diff(tt.expResponse, resp); diff != "" { + t.Fatalf("response not equal (-exp, +got):\n%v", diff) + } + }) + } +} diff --git a/comment.go b/comment.go new file mode 100644 index 0000000..6375394 --- /dev/null +++ b/comment.go @@ -0,0 +1,80 @@ +package notion + +import ( + "encoding/json" + "errors" + "time" +) + +// Comment represents a comment on a Notion page or block. +// See: https://developers.notion.com/reference/comment-object +type Comment struct { + ID string `json:"id"` + Parent Parent `json:"parent"` + DiscussionID string `json:"discussion_id"` + RichText []RichText `json:"rich_text"` + CreatedTime time.Time `json:"created_time"` + LastEditedTime time.Time `json:"last_edited_time"` + CreatedBy BaseUser `json:"created_by"` +} + +// CreateCommentParams are the params used for creating a comment. +type CreateCommentParams struct { + // Either ParentPageID or DiscussionID must be non-empty. Also cannot be set + // both at the same time. + ParentPageID string + DiscussionID string + + RichText []RichText +} + +func (p CreateCommentParams) Validate() error { + if p.ParentPageID == "" && p.DiscussionID == "" { + return errors.New("either parent page ID or discussion ID is required") + } + if p.ParentPageID != "" && p.DiscussionID != "" { + return errors.New("parent page ID and discussion ID cannot both be non-empty") + } + if len(p.RichText) == 0 { + return errors.New("rich text is required") + } + + return nil +} + +func (p CreateCommentParams) MarshalJSON() ([]byte, error) { + type CreateCommentParamsDTO struct { + Parent *Parent `json:"parent,omitempty"` + DiscussionID string `json:"discussion_id,omitempty"` + RichText []RichText `json:"rich_text"` + } + + dto := CreateCommentParamsDTO{ + RichText: p.RichText, + } + if p.ParentPageID != "" { + dto.Parent = &Parent{ + Type: ParentTypePage, + PageID: p.ParentPageID, + } + } else { + dto.DiscussionID = p.DiscussionID + } + + return json.Marshal(dto) +} + +// FindCommentsByBlockIDQuery is used when listing comments. +type FindCommentsByBlockIDQuery struct { + BlockID string + StartCursor string + PageSize int +} + +// FindCommentsResponse contains results (comments) and pagination data returned +// from a list request. +type FindCommentsResponse struct { + Results []Comment `json:"results"` + HasMore bool `json:"has_more"` + NextCursor *string `json:"next_cursor"` +} diff --git a/examples/create-comment/main.go b/examples/create-comment/main.go new file mode 100644 index 0000000..16e8efc --- /dev/null +++ b/examples/create-comment/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "io" + "log" + "net/http" + "os" + "time" + + "github.com/dstotijn/go-notion" + "github.com/sanity-io/litter" +) + +type httpTransport struct { + w io.Writer +} + +// RoundTrip implements http.RoundTripper. It multiplexes the read HTTP response +// data to an io.Writer for debugging. +func (t *httpTransport) RoundTrip(req *http.Request) (*http.Response, error) { + res, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + return nil, err + } + + res.Body = io.NopCloser(io.TeeReader(res.Body, t.w)) + + return res, nil +} + +func main() { + ctx := context.Background() + apiKey := os.Getenv("NOTION_API_KEY") + buf := &bytes.Buffer{} + httpClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &httpTransport{w: buf}, + } + client := notion.NewClient(apiKey, notion.WithHTTPClient(httpClient)) + + var parentPageID string + + flag.StringVar(&parentPageID, "parentPageId", "", "Parent page ID.") + flag.Parse() + + params := notion.CreateCommentParams{ + ParentPageID: parentPageID, + RichText: []notion.RichText{ + { + Text: ¬ion.Text{ + Content: "This is an example comment.", + }, + }, + }, + } + comment, err := client.CreateComment(ctx, params) + if err != nil { + log.Fatalf("Failed to create comment: %v", err) + } + + decoded := map[string]interface{}{} + if err := json.NewDecoder(buf).Decode(&decoded); err != nil { + log.Fatal(err) + } + + // Pretty print JSON reponse. + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(decoded); err != nil { + log.Fatal(err) + } + + // Pretty print parsed `notion.Comment` value. + litter.Dump(comment) +} diff --git a/examples/list-comments/main.go b/examples/list-comments/main.go new file mode 100644 index 0000000..240af97 --- /dev/null +++ b/examples/list-comments/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "io" + "log" + "net/http" + "os" + "time" + + "github.com/dstotijn/go-notion" + "github.com/sanity-io/litter" +) + +type httpTransport struct { + w io.Writer +} + +// RoundTrip implements http.RoundTripper. It multiplexes the read HTTP response +// data to an io.Writer for debugging. +func (t *httpTransport) RoundTrip(req *http.Request) (*http.Response, error) { + res, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + return nil, err + } + + res.Body = io.NopCloser(io.TeeReader(res.Body, t.w)) + + return res, nil +} + +func main() { + ctx := context.Background() + apiKey := os.Getenv("NOTION_API_KEY") + buf := &bytes.Buffer{} + httpClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &httpTransport{w: buf}, + } + client := notion.NewClient(apiKey, notion.WithHTTPClient(httpClient)) + + var blockID string + + flag.StringVar(&blockID, "blockId", "", "Block ID") + flag.Parse() + + query := notion.FindCommentsByBlockIDQuery{ + BlockID: blockID, + } + resp, err := client.FindCommentsByBlockID(ctx, query) + if err != nil { + log.Fatalf("Failed to list comments: %v", err) + } + + decoded := map[string]interface{}{} + if err := json.NewDecoder(buf).Decode(&decoded); err != nil { + log.Fatal(err) + } + + // Pretty print JSON reponse. + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(decoded); err != nil { + log.Fatal(err) + } + + // Pretty print parsed `notion.Comment` value. + litter.Dump(resp) +}