diff --git a/comment.go b/comment.go new file mode 100644 index 0000000..31bc03a --- /dev/null +++ b/comment.go @@ -0,0 +1,223 @@ +package geddit + +import ( + "context" + "fmt" + "net/url" + "strings" +) + +// CommentService handles communication with the comment +// related methods of the Reddit API +type CommentService interface { + Submit(ctx context.Context, id string, text string) (*Comment, *Response, error) + Edit(ctx context.Context, id string, text string) (*Comment, *Response, error) + Delete(ctx context.Context, id string) (*Response, error) + + Save(ctx context.Context, id string) (*Response, error) + Unsave(ctx context.Context, id string) (*Response, error) +} + +// CommentServiceOp implements the CommentService interface +type CommentServiceOp struct { + client *Client +} + +var _ CommentService = &CommentServiceOp{} + +type commentRoot struct { + Kind *string `json:"kind,omitempty"` + Data *Comment `json:"data,omitempty"` +} + +type commentRootListing struct { + Kind *string `json:"kind,omitempty"` + Data *struct { + Dist int `json:"dist"` + Roots []commentRoot `json:"children,omitempty"` + After string `json:"after,omitempty"` + Before string `json:"before,omitempty"` + } `json:"data,omitempty"` +} + +// Comment is a comment posted by a user +type Comment struct { + ID string `json:"id,omitempty"` + FullID string `json:"name,omitempty"` + ParentID string `json:"parent_id,omitempty"` + Permalink string `json:"permalink,omitempty"` + + Body string `json:"body,omitempty"` + BodyHTML string `json:"body_html,omitempty"` + Author string `json:"author,omitempty"` + AuthorID string `json:"author_fullname,omitempty"` + AuthorFlairText string `json:"author_flair_text,omitempty"` + + Subreddit string `json:"subreddit,omitempty"` + SubredditNamePrefixed string `json:"subreddit_name_prefixed,omitempty"` + SubredditID string `json:"subreddit_id,omitempty"` + + Score int `json:"score"` + Controversiality int `json:"controversiality"` + + Created float64 `json:"created"` + CreatedUTC float64 `json:"created_utc"` + + LinkID string `json:"link_id,omitempty"` + + // These don't appear when submitting a comment + LinkTitle string `json:"link_title,omitempty"` + LinkPermalink string `json:"link_permalink,omitempty"` + LinkAuthor string `json:"link_author,omitempty"` + LinkNumComments int `json:"num_comments"` + + IsSubmitter bool `json:"is_submitter"` + ScoreHidden bool `json:"score_hidden"` + Saved bool `json:"saved"` + Stickied bool `json:"stickied"` + Locked bool `json:"locked"` + CanGild bool `json:"can_gild"` + NSFW bool `json:"over_18"` +} + +// CommentList holds information about a list of comments +// The after and before fields help decide the anchor point for a subsequent +// call that returns a list +type CommentList struct { + Comments []Comment `json:"comments,omitempty"` + After string `json:"after,omitempty"` + Before string `json:"before,omitempty"` +} + +func (s *CommentServiceOp) isCommentID(id string) bool { + return strings.HasPrefix(id, kindComment+"_") +} + +// Submit submits a comment as a reply to a link or to another comment +func (s *CommentServiceOp) Submit(ctx context.Context, id string, text string) (*Comment, *Response, error) { + path := "api/comment" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("return_rtjson", "true") + form.Set("parent", id) + form.Set("text", text) + + req, err := s.client.NewPostForm(path, form) + if err != nil { + return nil, nil, err + } + + root := new(Comment) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Edit edits the comment with the id provided +// todo: don't forget to do this for links (i.e. posts) +func (s *CommentServiceOp) Edit(ctx context.Context, id string, text string) (*Comment, *Response, error) { + if !s.isCommentID(id) { + return nil, nil, fmt.Errorf("must provide comment id (starting with t1_); id provided: %q", id) + } + + path := "api/editusertext" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("return_rtjson", "true") + form.Set("thing_id", id) + form.Set("text", text) + + req, err := s.client.NewPostForm(path, form) + if err != nil { + return nil, nil, err + } + + root := new(Comment) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Delete deletes a comment via the id +// todo: don't forget to do this for links (i.e. posts) +// Seems like this always returns {} as a response, no matter if an id is even provided +func (s *CommentServiceOp) Delete(ctx context.Context, id string) (*Response, error) { + if !s.isCommentID(id) { + return nil, fmt.Errorf("must provide comment id (starting with t1_); id provided: %q", id) + } + + path := "api/del" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewPostForm(path, form) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +// Save saves a comment +// Seems like this just returns {} on success +func (s *CommentServiceOp) Save(ctx context.Context, id string) (*Response, error) { + if !s.isCommentID(id) { + return nil, fmt.Errorf("must provide comment id (starting with t1_); id provided: %q", id) + } + + path := "api/save" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewPostForm(path, form) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +// Unsave unsaves a comment +// Seems like this just returns {} on success +func (s *CommentServiceOp) Unsave(ctx context.Context, id string) (*Response, error) { + if !s.isCommentID(id) { + return nil, fmt.Errorf("must provide comment id (starting with t1_); id provided: %q", id) + } + + path := "api/unsave" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewPostForm(path, form) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} diff --git a/comment_test.go b/comment_test.go new file mode 100644 index 0000000..1a20770 --- /dev/null +++ b/comment_test.go @@ -0,0 +1,216 @@ +package geddit + +import ( + "fmt" + "net/http" + "net/url" + "reflect" + "testing" +) + +var expectedCommentSubmitOrEdit = &Comment{ + ID: "test2", + FullID: "t1_test2", + ParentID: "t1_test", + Permalink: "/r/subreddit/comments/test1/some_thread/test2/", + + Body: "test comment", + BodyHTML: "

test comment

\n
", + Author: "reddit_username", + AuthorID: "t2_user1", + AuthorFlairText: "Flair", + + Subreddit: "subreddit", + SubredditNamePrefixed: "r/subreddit", + SubredditID: "t5_test", + + Score: 1, + Controversiality: 0, + + Created: 1588147787, + CreatedUTC: 1588118987, + + LinkID: "t3_link1", +} + +func TestCommentServiceOp_Submit(t *testing.T) { + setup() + defer teardown() + + commentBlob := readFileContents(t, "testdata/comment-submit-edit.json") + + mux.HandleFunc("/api/comment", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("return_rtjson", "true") + form.Set("parent", "t1_test") + form.Set("text", "test comment") + + _ = r.ParseForm() + if expect, actual := form, r.PostForm; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + fmt.Fprint(w, commentBlob) + }) + + comment, _, err := client.Comment.Submit(ctx, "t1_test", "test comment") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := expectedCommentSubmitOrEdit, comment; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} + +func TestCommentServiceOp_Edit(t *testing.T) { + setup() + defer teardown() + + commentBlob := readFileContents(t, "testdata/comment-submit-edit.json") + + mux.HandleFunc("/api/editusertext", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("return_rtjson", "true") + form.Set("thing_id", "t1_test") + form.Set("text", "test comment") + + _ = r.ParseForm() + if expect, actual := form, r.PostForm; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + fmt.Fprint(w, commentBlob) + }) + + _, _, err := client.Comment.Edit(ctx, "t3_test", "test comment") + if err == nil { + t.Fatal("expected error, got nothing instead") + } + if expect, actual := `must provide comment id (starting with t1_); id provided: "t3_test"`, err.Error(); expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + comment, _, err := client.Comment.Edit(ctx, "t1_test", "test comment") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := expectedCommentSubmitOrEdit, comment; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} + +func TestCommentServiceOp_Delete(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/api/del", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + + form := url.Values{} + form.Set("id", "t1_test") + + _ = r.ParseForm() + if expect, actual := form, r.PostForm; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + fmt.Fprint(w, `{}`) + }) + + _, err := client.Comment.Delete(ctx, "t3_test") + if err == nil { + t.Fatal("expected error, got nothing instead") + } + if expect, actual := `must provide comment id (starting with t1_); id provided: "t3_test"`, err.Error(); expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + res, err := client.Comment.Delete(ctx, "t1_test") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := http.StatusOK, res.StatusCode; expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} + +func TestCommentServiceOp_Save(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/api/save", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + + form := url.Values{} + form.Set("id", "t1_test") + + _ = r.ParseForm() + if expect, actual := form, r.PostForm; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + fmt.Fprint(w, `{}`) + }) + + _, err := client.Comment.Save(ctx, "t3_test") + if err == nil { + t.Fatal("expected error, got nothing instead") + } + if expect, actual := `must provide comment id (starting with t1_); id provided: "t3_test"`, err.Error(); expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + res, err := client.Comment.Save(ctx, "t1_test") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := http.StatusOK, res.StatusCode; expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} + +func TestCommentServiceOp_Unsave(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/api/unsave", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + + form := url.Values{} + form.Set("id", "t1_test") + + _ = r.ParseForm() + if expect, actual := form, r.PostForm; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + fmt.Fprint(w, `{}`) + }) + + _, err := client.Comment.Unsave(ctx, "t3_test") + if err == nil { + t.Fatal("expected error, got nothing instead") + } + if expect, actual := `must provide comment id (starting with t1_); id provided: "t3_test"`, err.Error(); expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + res, err := client.Comment.Unsave(ctx, "t1_test") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := http.StatusOK, res.StatusCode; expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} diff --git a/flair.go b/flair.go new file mode 100644 index 0000000..86fdac2 --- /dev/null +++ b/flair.go @@ -0,0 +1,74 @@ +package geddit + +import ( + "context" + "fmt" + "net/http" +) + +// FlairService handles communication with the user +// related methods of the Reddit API +type FlairService interface { + GetFromSubreddit(ctx context.Context, name string) ([]Flair, *Response, error) + GetFromSubredditV2(ctx context.Context, name string) ([]FlairV2, *Response, error) +} + +// FlairServiceOp implements the FlairService interface +type FlairServiceOp struct { + client *Client +} + +var _ FlairService = &FlairServiceOp{} + +// Flair is a flair on Reddit +type Flair struct { + ID string `json:"id,omitempty"` + Text string `json:"text,omitempty"` + Type string `json:"type,omitempty"` + CSS string `json:"css_class,omitempty"` +} + +// FlairV2 is a flair on Reddit +type FlairV2 struct { + ID string `json:"id,omitempty"` + Text string `json:"text,omitempty"` + Type string `json:"type,omitempty"` + CSS string `json:"css_class,omitempty"` + ModOnly bool `json:"mod_only"` +} + +// GetFromSubreddit returns the flairs from the subreddit +func (s *FlairServiceOp) GetFromSubreddit(ctx context.Context, name string) ([]Flair, *Response, error) { + path := fmt.Sprintf("r/%s/api/user_flair", name) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + var flairs []Flair + resp, err := s.client.Do(ctx, req, &flairs) + if err != nil { + return nil, resp, err + } + + return flairs, resp, nil +} + +// GetFromSubredditV2 returns the flairs from the subreddit +func (s *FlairServiceOp) GetFromSubredditV2(ctx context.Context, name string) ([]FlairV2, *Response, error) { + path := fmt.Sprintf("r/%s/api/user_flair_v2", name) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + var flairs []FlairV2 + resp, err := s.client.Do(ctx, req, &flairs) + if err != nil { + return nil, resp, err + } + + return flairs, resp, nil +} diff --git a/flair_test.go b/flair_test.go new file mode 100644 index 0000000..70f1716 --- /dev/null +++ b/flair_test.go @@ -0,0 +1,82 @@ +package geddit + +import ( + "fmt" + "net/http" + "reflect" + "testing" +) + +var expectedFlairs = []Flair{ + { + ID: "b8a1c822-3feb-11e8-88e1-0e5f55d58ce0", + Text: "Beginner", + Type: "text", + CSS: "Beginner1", + }, + { + ID: "b8ea0fce-3feb-11e8-af7a-0e263a127cf8", + Text: "Beginner", + Type: "text", + CSS: "Beginner2", + }, +} + +var expectedFlairsV2 = []FlairV2{ + { + ID: "b8a1c822-3feb-11e8-88e1-0e5f55d58ce0", + Text: "Beginner", + Type: "text", + CSS: "Beginner1", + ModOnly: false, + }, + { + ID: "b8ea0fce-3feb-11e8-af7a-0e263a127cf8", + Text: "Moderator", + Type: "text", + CSS: "Moderator1", + ModOnly: true, + }, +} + +func TestFlairServiceOp_GetFlairs(t *testing.T) { + setup() + defer teardown() + + flairsBlob := readFileContents(t, "testdata/flairs.json") + + mux.HandleFunc("/r/subreddit/api/user_flair", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + fmt.Fprint(w, flairsBlob) + }) + + flairs, _, err := client.Flair.GetFromSubreddit(ctx, "subreddit") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := expectedFlairs, flairs; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} + +func TestFlairServiceOp_GetFlairsV2(t *testing.T) { + setup() + defer teardown() + + flairsV2Blob := readFileContents(t, "testdata/flairs-v2.json") + + mux.HandleFunc("/r/subreddit/api/user_flair_v2", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + fmt.Fprint(w, flairsV2Blob) + }) + + flairs, _, err := client.Flair.GetFromSubredditV2(ctx, "subreddit") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := expectedFlairsV2, flairs; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} diff --git a/geddit.go b/geddit.go index e42efdb..37a9fc6 100644 --- a/geddit.go +++ b/geddit.go @@ -9,20 +9,67 @@ import ( "io/ioutil" "net/http" "net/url" + "reflect" + "strings" + + "github.com/google/go-querystring/query" + "golang.org/x/oauth2" ) const ( - libraryVersion = "0.0.1" - defaultBaseURL = "https://reddit.com" - defaultBaseURLOauth = "https://oauth.reddit.com" - userAgent = "geddit/" + libraryVersion - mediaType = "application/json" + libraryName = "github.com/vartanbeno/geddit" + libraryVersion = "0.0.1" + + defaultBaseURL = "https://oauth.reddit.com" + defaultTokenURL = "https://www.reddit.com/api/v1/access_token" + + mediaTypeJSON = "application/json" + mediaTypeForm = "application/x-www-form-urlencoded" headerContentType = "Content-Type" headerAccept = "Accept" headerUserAgent = "User-Agent" ) +// cloneRequest returns a clone of the provided *http.Request. +// The clone is a shallow copy of the struct and its Header map, +// since we'll only be modify the headers. +// Per the specification of http.RoundTripper, we should not directly modify a request. +func cloneRequest(r *http.Request) *http.Request { + r2 := new(http.Request) + *r2 = *r + // deep copy of the Header + r2.Header = make(http.Header, len(r.Header)) + for k, s := range r.Header { + r2.Header[k] = append([]string(nil), s...) + } + return r2 +} + +// sets the User-Agent header for requests +type userAgentTransport struct { + ua string + Base http.RoundTripper +} + +func (t *userAgentTransport) setUserAgent(req *http.Request) *http.Request { + req2 := cloneRequest(req) + req2.Header.Set(headerUserAgent, t.ua) + return req2 +} + +func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := t.setUserAgent(req) + return t.base().RoundTrip(req2) +} + +func (t *userAgentTransport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + // RequestCompletionCallback defines the type of the request callback function type RequestCompletionCallback func(*http.Request, *http.Response) @@ -31,12 +78,25 @@ type Client struct { // HTTP client used to communicate with the Reddit API client *http.Client - // Base URL for HTTP requests - BaseURL *url.URL + BaseURL *url.URL + TokenURL *url.URL - UserAgent string + userAgent string + ID string + Secret string + Username string + Password string + + Comment CommentService + Flair FlairService + Link LinkService + Listings ListingsService Subreddit SubredditService + User UserService + Vote VoteService + + oauth2Transport *oauth2.Transport onRequestCompleted RequestCompletionCallback } @@ -52,21 +112,47 @@ func newClient(httpClient *http.Client) *Client { } baseURL, _ := url.Parse(defaultBaseURL) + tokenURL, _ := url.Parse(defaultTokenURL) - c := &Client{client: httpClient, BaseURL: baseURL, UserAgent: userAgent} + c := &Client{client: httpClient, BaseURL: baseURL, TokenURL: tokenURL} + + c.Comment = &CommentServiceOp{client: c} + c.Flair = &FlairServiceOp{client: c} + c.Link = &LinkServiceOp{client: c} + c.Listings = &ListingsServiceOp{client: c} c.Subreddit = &SubredditServiceOp{client: c} + c.User = &UserServiceOp{client: c} + c.Vote = &VoteServiceOp{client: c} return c } -// New returns a client that can make requests to the Reddit API -func New(httpClient *http.Client, opts ...Opt) (c *Client, err error) { +// NewClient returns a client that can make requests to the Reddit API +func NewClient(httpClient *http.Client, opts ...Opt) (c *Client, err error) { c = newClient(httpClient) + for _, opt := range opts { if err = opt(c); err != nil { return } } + + c.userAgent = fmt.Sprintf("golang:%s:v%s (by /u/%s)", libraryName, libraryVersion, c.Username) + userAgentTransport := &userAgentTransport{ + ua: c.userAgent, + Base: c.client.Transport, + } + + oauth2Config := oauth2Config{ + id: c.ID, + secret: c.Secret, + username: c.Username, + password: c.Password, + tokenURL: c.TokenURL.String(), + userAgentTransport: userAgentTransport, + } + c.client.Transport = oauth2Transport(oauth2Config) + return } @@ -93,9 +179,28 @@ func (c *Client) NewRequest(method, path string, body interface{}) (*http.Reques return nil, err } - req.Header.Add(headerContentType, mediaType) - req.Header.Add(headerAccept, mediaType) - req.Header.Add(headerUserAgent, c.UserAgent) + req.Header.Add(headerContentType, mediaTypeJSON) + req.Header.Add(headerAccept, mediaTypeJSON) + + return req, nil +} + +// NewPostForm creates an API request with a POST form +// The path is the relative URL which will be resolves to the BaseURL of the Client +// It should always be specified without a preceding slash +func (c *Client) NewPostForm(path string, form url.Values) (*http.Request, error) { + u, err := c.BaseURL.Parse(path) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + + req.Header.Add(headerContentType, mediaTypeForm) + req.Header.Add(headerAccept, mediaTypeJSON) return req, nil } @@ -164,6 +269,29 @@ func DoRequestWithClient(ctx context.Context, client *http.Client, req *http.Req return client.Do(req) } +// JSONErrorResponse is an error response that sometimes gets returned with a 200 code +type JSONErrorResponse struct { + // HTTP response that caused this error + Response *http.Response + + JSON *struct { + Errors [][]string `json:"errors,omitempty"` + } `json:"json,omitempty"` +} + +func (r *JSONErrorResponse) Error() string { + var message string + if r.JSON != nil && len(r.JSON.Errors) > 0 { + for _, errList := range r.JSON.Errors { + message += strings.Join(errList, ", ") + } + } + return fmt.Sprintf( + "%v %v: %d %v", + r.Response.Request.Method, r.Response.Request.URL, r.Response.StatusCode, message, + ) +} + // An ErrorResponse reports the error caused by an API request type ErrorResponse struct { // HTTP response that caused this error @@ -182,13 +310,27 @@ func (r *ErrorResponse) Error() string { // CheckResponse checks the API response for errors, and returns them if present. // A response is considered an error if it has a status code outside the 200 range. +// Reddit also sometimes sends errors with 200 codes; we check for those too. func CheckResponse(r *http.Response) error { + jsonErrorResponse := &JSONErrorResponse{Response: r} + + data, err := ioutil.ReadAll(r.Body) + if err == nil && len(data) > 0 { + json.Unmarshal(data, jsonErrorResponse) + if jsonErrorResponse.JSON != nil { + return jsonErrorResponse + } + } + + // reset response body + r.Body = ioutil.NopCloser(bytes.NewBuffer(data)) + if c := r.StatusCode; c >= 200 && c <= 299 { return nil } errorResponse := &ErrorResponse{Response: r} - data, err := ioutil.ReadAll(r.Body) + data, err = ioutil.ReadAll(r.Body) if err == nil && len(data) > 0 { err := json.Unmarshal(data, errorResponse) if err != nil { @@ -198,3 +340,60 @@ func CheckResponse(r *http.Response) error { return errorResponse } + +// ListOptions are the optional parameters to the various endpoints that return lists +type ListOptions struct { + Sort string `url:"sort,omitempty"` + Type string `url:"type,omitempty"` // links or comments + + // For getting submissions + // all, year, month, week, day, hour + Timespan string `url:"t,omitempty"` + + // Common for all listing endpoints + After string `url:"after,omitempty"` + Before string `url:"before,omitempty"` + Limit int `url:"limit,omitempty"` // default: 25 + Count int `url:"count,omitempty"` // default: 0 +} + +func addOptions(s string, opt interface{}) (string, error) { + v := reflect.ValueOf(opt) + if v.Kind() == reflect.Ptr && v.IsNil() { + return s, nil + } + + origURL, err := url.Parse(s) + if err != nil { + return s, err + } + + origValues := origURL.Query() + + newValues, err := query.Values(opt) + if err != nil { + return s, err + } + + for k, v := range newValues { + origValues[k] = v + } + + origURL.RawQuery = origValues.Encode() + return origURL.String(), nil +} + +type root struct { + Kind string `json:"kind,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +const ( + kindListing string = "Listing" + kindComment string = "t1" + kindAccount string = "t2" + kindLink string = "t3" + kindMessage string = "t4" + kindSubreddit string = "t5" + kindAward string = "t6" +) diff --git a/geddit_oauth.go b/geddit_oauth.go new file mode 100644 index 0000000..d7ae5aa --- /dev/null +++ b/geddit_oauth.go @@ -0,0 +1,99 @@ +/* +Docs: +- https://www.reddit.com/dev/api/ +- https://github.com/reddit-archive/reddit/wiki/api +- https://github.com/reddit-archive/reddit/wiki/OAuth2 +- https://github.com/reddit-archive/reddit/wiki/OAuth2-Quick-Start-Example + +1. Go to https://www.reddit.com/prefs/apps and create an app. There are 3 types of apps: + - Web app. Service is available over http or https, preferably the latter. + - Installed app, such as a mobile app on a user's device which you can't control. + Redirect the user to a URI after they grant your app permissions. + - Script (the simplest type of app). Select this if you are the only person who will + use the app. Only has access to your account. + +Best option for a client like this is to use the script option. + +2. After creating the app, you will get a client id and client secret. + +3. Send a POST request (with the Content-Type header set to "application/x-www-form-urlencoded") +to https://www.reddit.com/api/v1/access_token with the following form values: + - grant_type=password + - username={your Reddit username} + - password={your Reddit password} + +4. You should receive a response body like the following: +{ + "access_token": "70743860-DRhHVNSEOMu1ldlI", + "token_type": "bearer", + "expires_in": 3600, + "scope": "*" +} +*/ + +package geddit + +import ( + "context" + "net/http" + "net/url" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +var endpoint = oauth2.Endpoint{ + TokenURL: "https://www.reddit.com/api/v1/access_token", + AuthStyle: oauth2.AuthStyleInHeader, +} + +type oauth2Config struct { + id string + secret string + username string + password string + tokenURL string + + // We need to set a custom user agent, because using the one set by default by the + // stdlib gives us 429 Too Many Request responses from the Reddit API + userAgentTransport *userAgentTransport +} + +func oauth2Transport(c oauth2Config) *oauth2.Transport { + params := url.Values{ + "grant_type": {"password"}, + "username": {c.username}, + "password": {c.password}, + } + + cfg := clientcredentials.Config{ + ClientID: c.id, + ClientSecret: c.secret, + TokenURL: c.tokenURL, + AuthStyle: oauth2.AuthStyleInHeader, + EndpointParams: params, + } + + // We need to set a custom user agent, because using the one set by default by the + // stdlib gives us 429 Too Many Request responses from the Reddit API + httpClient := &http.Client{Transport: c.userAgentTransport} + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + + src := cfg.TokenSource(ctx) + tr := &oauth2.Transport{ + Source: src, + Base: c.userAgentTransport, + } + return tr +} + +// WithOAuth2 sets the necessary values for the client to authenticate via OAuth2 +func WithOAuth2(id, secret, username, password string) Opt { + return func(c *Client) error { + c.ID = id + c.Secret = secret + c.Username = username + c.Password = password + return nil + } +} diff --git a/geddit_options.go b/geddit_options.go new file mode 100644 index 0000000..78a748f --- /dev/null +++ b/geddit_options.go @@ -0,0 +1,62 @@ +package geddit + +import ( + "net/url" + "os" +) + +// Opt is a configuration option to initialize a client +type Opt func(*Client) error + +// FromEnv configures the client with values from environment variables. +// +// Supported environment variables: +// GEDDIT_CLIENT_ID to set the client's id. +// GEDDIT_CLIENT_SECRET to set the client's secret. +// GEDDIT_CLIENT_USERNAME to set the client's username. +// GEDDIT_CLIENT_PASSWORD to set the client's password. +func FromEnv(c *Client) error { + if v, ok := os.LookupEnv("GEDDIT_CLIENT_ID"); ok { + c.ID = v + } + + if v, ok := os.LookupEnv("GEDDIT_CLIENT_SECRET"); ok { + c.Secret = v + } + + if v, ok := os.LookupEnv("GEDDIT_CLIENT_USERNAME"); ok { + c.Username = v + } + + if v, ok := os.LookupEnv("GEDDIT_CLIENT_PASSWORD"); ok { + c.Password = v + } + + return nil +} + +// WithBaseURL sets the base URL for the client to make requests to +func WithBaseURL(u string) Opt { + return func(c *Client) error { + url, err := url.Parse(u) + if err != nil { + return err + } + + c.BaseURL = url + return nil + } +} + +// WithTokenURL sets the url used to get access tokens +func WithTokenURL(u string) Opt { + return func(c *Client) error { + url, err := url.Parse(u) + if err != nil { + return err + } + + c.TokenURL = url + return nil + } +} diff --git a/geddit_options_test.go b/geddit_options_test.go new file mode 100644 index 0000000..6b70660 --- /dev/null +++ b/geddit_options_test.go @@ -0,0 +1,61 @@ +package geddit + +import ( + "os" + "reflect" + "testing" +) + +func TestFromEnv(t *testing.T) { + os.Setenv("GEDDIT_CLIENT_ID", "id1") + defer os.Unsetenv("GEDDIT_CLIENT_ID") + + os.Setenv("GEDDIT_CLIENT_SECRET", "secret1") + defer os.Unsetenv("GEDDIT_CLIENT_SECRET") + + os.Setenv("GEDDIT_CLIENT_USERNAME", "username1") + defer os.Unsetenv("GEDDIT_CLIENT_USERNAME") + + os.Setenv("GEDDIT_CLIENT_PASSWORD", "password1") + defer os.Unsetenv("GEDDIT_CLIENT_PASSWORD") + + c, err := NewClient(nil, FromEnv) + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + type values struct { + id, secret, username, password string + } + + expect := values{"id1", "secret1", "username1", "password1"} + actual := values{c.ID, c.Secret, c.Username, c.Password} + + if !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} + +func TestWithBaseURL(t *testing.T) { + baseURL := "http://localhost:8080" + c, err := NewClient(nil, WithBaseURL(baseURL)) + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := baseURL, c.BaseURL.String(); expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} + +func TestWithTokenURL(t *testing.T) { + tokenURL := "http://localhost:8080/api/v1/access_token" + c, err := NewClient(nil, WithTokenURL(tokenURL)) + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := tokenURL, c.TokenURL.String(); expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} diff --git a/geddit_opts.go b/geddit_opts.go deleted file mode 100644 index 71f6689..0000000 --- a/geddit_opts.go +++ /dev/null @@ -1,14 +0,0 @@ -package geddit - -import "fmt" - -// Opt is a configuration option to initialize a client -type Opt func(*Client) error - -// WithUserAgent sets the user agent for the client -func WithUserAgent(ua string) Opt { - return func(c *Client) error { - c.UserAgent = fmt.Sprintf("%s %s", ua, c.UserAgent) - return nil - } -} diff --git a/geddit_test.go b/geddit_test.go new file mode 100644 index 0000000..9a1fd2f --- /dev/null +++ b/geddit_test.go @@ -0,0 +1,124 @@ +package geddit + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "reflect" + "testing" +) + +var ( + mux *http.ServeMux + ctx = context.Background() + client *Client + server *httptest.Server +) + +func setup() { + mux = http.NewServeMux() + server = httptest.NewServer(mux) + + mux.HandleFunc("/api/v1/access_token", func(w http.ResponseWriter, r *http.Request) { + response := ` + { + "access_token": "token1", + "token_type": "bearer", + "expires_in": 3600, + "scope": "*" + } + ` + w.Header().Add(headerContentType, mediaTypeJSON) + fmt.Fprint(w, response) + }) + + client, _ = NewClient(nil, + WithOAuth2("id1", "secret1", "user1", "password1"), + WithBaseURL(server.URL), + WithTokenURL(server.URL+"/api/v1/access_token"), + ) +} + +func teardown() { + server.Close() +} + +func testMethod(t *testing.T, r *http.Request, expected string) { + if expected != r.Method { + t.Fatalf("Request method = %v, expected %v", r.Method, expected) + } +} + +func readFileContents(t *testing.T, filepath string) string { + file, err := os.Open(filepath) + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + defer file.Close() + + bytes, err := ioutil.ReadAll(file) + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + return string(bytes) +} + +func testClientServices(t *testing.T, c *Client) { + services := []string{ + "Comment", + "Flair", + "Link", + "Listings", + "Subreddit", + "User", + "Vote", + } + + cp := reflect.ValueOf(c) + cv := reflect.Indirect(cp) + + for _, s := range services { + if cv.FieldByName(s).IsNil() { + t.Fatalf("c.%s shouldn't be nil", s) + } + } +} + +func testClientDefaultUserAgent(t *testing.T, c *Client) { + if expect, actual := fmt.Sprintf("golang:%s:v%s (by /u/)", libraryName, libraryVersion), c.userAgent; expect != actual { + t.Fatalf("got unexpected value\nexpect: %+v\nactual: %+v", Stringify(expect), Stringify(actual)) + } +} + +func testClientDefaults(t *testing.T, c *Client) { + testClientDefaultUserAgent(t, c) + testClientServices(t, c) +} + +func TestNewClient(t *testing.T) { + c, err := NewClient(nil) + if err != nil { + t.Fatalf("got unexpected error: %+v", err) + } + testClientDefaults(t, c) +} + +func TestNewClient_Error(t *testing.T) { + errorOpt := func(c *Client) error { + return errors.New("foo") + } + + _, err := NewClient(nil, errorOpt) + if err == nil { + t.Fatal("expected error, got nothing instead") + } + + if expect, actual := "foo", err.Error(); expect != actual { + t.Fatalf("got unexpected error\nexpect: %+v\nactual: %+v", Stringify(expect), Stringify(actual)) + } +} diff --git a/link.go b/link.go new file mode 100644 index 0000000..5de5fba --- /dev/null +++ b/link.go @@ -0,0 +1,72 @@ +package geddit + +import ( + "context" + "errors" + "net/url" + "strings" +) + +// LinkService handles communication with the link (post) +// related methods of the Reddit API +type LinkService interface { + Hide(ctx context.Context, ids ...string) (*Response, error) + Unhide(ctx context.Context, ids ...string) (*Response, error) +} + +// LinkServiceOp implements the Vote interface +type LinkServiceOp struct { + client *Client +} + +var _ LinkService = &LinkServiceOp{} + +// Hide hides links with the specified ids +// On successful calls, it just returns {} +func (s *LinkServiceOp) Hide(ctx context.Context, ids ...string) (*Response, error) { + if len(ids) == 0 { + return nil, errors.New("must provide at least 1 id") + } + + path := "api/hide" + + form := url.Values{} + form.Set("id", strings.Join(ids, ",")) + + req, err := s.client.NewPostForm(path, form) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +// Unhide unhides links with the specified ids +// On successful calls, it just returns {} +func (s *LinkServiceOp) Unhide(ctx context.Context, ids ...string) (*Response, error) { + if len(ids) == 0 { + return nil, errors.New("must provide at least 1 id") + } + + path := "api/unhide" + + form := url.Values{} + form.Set("id", strings.Join(ids, ",")) + + req, err := s.client.NewPostForm(path, form) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} diff --git a/link_test.go b/link_test.go new file mode 100644 index 0000000..ecff639 --- /dev/null +++ b/link_test.go @@ -0,0 +1,81 @@ +package geddit + +import ( + "fmt" + "net/http" + "net/url" + "reflect" + "testing" +) + +func TestLinkServiceOp_Hide(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/api/hide", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + + form := url.Values{} + form.Set("id", "1,2,3") + + _ = r.ParseForm() + if expect, actual := form, r.PostForm; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + fmt.Fprint(w, `{}`) + }) + + _, err := client.Link.Hide(ctx) + if err == nil { + t.Fatal("expected error, got nothing instead") + } + if expect, actual := `must provide at least 1 id`, err.Error(); expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + res, err := client.Link.Hide(ctx, "1", "2", "3") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := http.StatusOK, res.StatusCode; expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} + +func TestLinkServiceOp_Unhide(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/api/unhide", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + + form := url.Values{} + form.Set("id", "1,2,3") + + _ = r.ParseForm() + if expect, actual := form, r.PostForm; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + fmt.Fprint(w, `{}`) + }) + + _, err := client.Link.Unhide(ctx) + if err == nil { + t.Fatal("expected error, got nothing instead") + } + if expect, actual := `must provide at least 1 id`, err.Error(); expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + res, err := client.Link.Unhide(ctx, "1", "2", "3") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + + if expect, actual := http.StatusOK, res.StatusCode; expect != actual { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } +} diff --git a/listings.go b/listings.go new file mode 100644 index 0000000..76c6ccb --- /dev/null +++ b/listings.go @@ -0,0 +1,104 @@ +package geddit + +import ( + "context" + "encoding/json" + "net/http" +) + +// ListingsService handles communication with the link (post) +// related methods of the Reddit API +type ListingsService interface { + Get(ctx context.Context, ids ...string) (*Listing, *Response, error) +} + +// ListingsServiceOp implements the Vote interface +type ListingsServiceOp struct { + client *Client +} + +var _ ListingsService = &ListingsServiceOp{} + +type listingRoot struct { + Kind string `json:"kind,omitempty"` + Data *struct { + Dist int `json:"dist"` + Children []map[string]interface{} `json:"children,omitempty"` + After string `json:"after,omitempty"` + Before string `json:"before,omitempty"` + } `json:"data,omitempty"` +} + +// Listing holds various types of things that all come from the Reddit API +type Listing struct { + Links []*Submission `json:"links,omitempty"` + Comments []*Comment `json:"comments,omitempty"` + Subreddits []*Subreddit `json:"subreddits,omitempty"` +} + +// Get gets a list of things based on their IDs +// Only links, comments, and subreddits are allowed +func (s *ListingsServiceOp) Get(ctx context.Context, ids ...string) (*Listing, *Response, error) { + type query struct { + IDs []string `url:"id,comma"` + } + + path := "api/info" + path, err := addOptions(path, query{ids}) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(listingRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + if root.Data == nil { + return nil, resp, nil + } + + l := new(Listing) + + for _, result := range root.Data.Children { + kind, ok1 := result["kind"].(string) + data, ok2 := result["data"] + + if ok1 && ok2 { + byteValue, err := json.Marshal(data) + if err != nil { + return nil, resp, err + } + + var v interface{} + switch kind { + case kindComment: + v = new(Comment) + l.Comments = append(l.Comments, v.(*Comment)) + case kindLink: + v = new(Submission) + l.Links = append(l.Links, v.(*Submission)) + case kindSubreddit: + v = new(Subreddit) + l.Subreddits = append(l.Subreddits, v.(*Subreddit)) + default: + continue + } + + err = json.Unmarshal(byteValue, v) + if err != nil { + return nil, resp, err + } + } + } + + return l, resp, nil +} + +// todo: do by_id next diff --git a/subreddit.go b/subreddit.go index de90a3d..0d1b286 100644 --- a/subreddit.go +++ b/subreddit.go @@ -5,12 +5,32 @@ import ( "errors" "fmt" "net/http" + "strings" ) // SubredditService handles communication with the subreddit // related methods of the Reddit API type SubredditService interface { GetByName(ctx context.Context, name string) (*Subreddit, *Response, error) + + GetPopular(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) + GetNew(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) + GetGold(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) + GetDefault(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) + + GetMineWhereSubscriber(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) + GetMineWhereContributor(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) + GetMineWhereModerator(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) + GetMineWhereStreams(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) + + GetHotPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) + GetNewPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) + GetRisingPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) + GetControversialPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) + GetTopPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) + + GetSticky1(ctx context.Context, name string) (interface{}, *Response, error) + GetSticky2(ctx context.Context, name string) (interface{}, *Response, error) } // SubredditServiceOp implements the SubredditService interface @@ -25,20 +45,98 @@ type subredditRoot struct { Data *Subreddit `json:"data,omitempty"` } +type subredditRootListing struct { + Kind *string `json:"kind,omitempty"` + Data *struct { + Dist int `json:"dist"` + Roots []subredditRoot `json:"children,omitempty"` + After string `json:"after,omitempty"` + Before string `json:"before,omitempty"` + } `json:"data,omitempty"` +} + // Subreddit holds information about a subreddit type Subreddit struct { - ID *string `json:"id,omitempty"` - FullID *string `json:"name,omitempty"` - Created *float64 `json:"created_utc,omitempty"` + ID string `json:"id,omitempty"` + FullID string `json:"name,omitempty"` + Created float64 `json:"created"` + CreatedUTC float64 `json:"created_utc"` - URL *string `json:"url,omitempty"` - DisplayName *string `json:"display_name,omitempty"` - DisplayNamePrefixed *string `json:"display_name_prefixed,omitempty"` - Title *string `json:"title,omitempty"` - PublicDescription *string `json:"public_description,omitempty"` + URL string `json:"url,omitempty"` + DisplayName string `json:"display_name,omitempty"` + DisplayNamePrefixed string `json:"display_name_prefixed,omitempty"` + Title string `json:"title,omitempty"` + PublicDescription string `json:"public_description,omitempty"` + Type string `json:"subreddit_type,omitempty"` + SuggestedCommentSort string `json:"suggested_comment_sort,omitempty"` - Subscribers *int `json:"subscribers,omitempty"` + Subscribers int `json:"subscribers"` ActiveUserCount *int `json:"active_user_count,omitempty"` + NSFW bool `json:"over18"` + UserIsMod bool `json:"user_is_moderator"` +} + +// SubredditList holds information about a list of subreddits +// The after and before fields help decide the anchor point for a subsequent +// call that returns a list +type SubredditList struct { + Subreddits []Subreddit `json:"subreddits,omitempty"` + After string `json:"after,omitempty"` + Before string `json:"before,omitempty"` +} + +type submissionRoot struct { + Kind *string `json:"kind,omitempty"` + Data *Submission `json:"data,omitempty"` +} + +type submissionRootListing struct { + Kind *string `json:"kind,omitempty"` + Data *struct { + Dist int `json:"dist"` + Roots []submissionRoot `json:"children,omitempty"` + After string `json:"after,omitempty"` + Before string `json:"before,omitempty"` + } `json:"data,omitempty"` +} + +// Submission is a submitted post on Reddit +type Submission struct { + ID string `json:"id,omitempty"` + FullID string `json:"name,omitempty"` + Created float64 `json:"created"` + CreatedUTC float64 `json:"created_utc"` + + Permalink string `json:"permalink,omitempty"` + URL string `json:"url,omitempty"` + + Title string `json:"title,omitempty"` + Body string `json:"selftext,omitempty"` + Score int `json:"score"` + NumberOfComments int `json:"num_comments"` + + SubredditID string `json:"t5_2qo4s,omitempty"` + SubredditName string `json:"subreddit,omitempty"` + SubredditNamePrefixed string `json:"subreddit_name_prefixed,omitempty"` + + AuthorID string `json:"author_fullname,omitempty"` + AuthorName string `json:"author,omitempty"` + + Spoiler bool `json:"spoiler"` + Locked bool `json:"locked"` + NSFW bool `json:"over_18"` + IsSelfPost bool `json:"is_self"` + Saved bool `json:"saved"` + Stickied bool `json:"stickied"` +} + +// SubmissionList holds information about a list of subreddits +// The after and before fields help decide the anchor point for a subsequent +// call that returns a list +type SubmissionList struct { + Submissions []Submission `json:"submissions,omitempty"` + After string `json:"after,omitempty"` + Before string `json:"before,omitempty"` } // GetByName gets a subreddit by name @@ -47,8 +145,7 @@ func (s *SubredditServiceOp) GetByName(ctx context.Context, name string) (*Subre return nil, nil, errors.New("empty subreddit name provided") } - path := fmt.Sprintf("r/%s/about.json", name) - + path := fmt.Sprintf("r/%s/about", name) req, err := s.client.NewRequest(http.MethodGet, path, nil) if err != nil { return nil, nil, err @@ -62,3 +159,224 @@ func (s *SubredditServiceOp) GetByName(ctx context.Context, name string) (*Subre return root.Data, resp, nil } + +// GetPopular returns popular subreddits +func (s *SubredditServiceOp) GetPopular(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) { + return s.getSubreddits(ctx, "subreddits/popular", opts) +} + +// GetNew returns new subreddits +func (s *SubredditServiceOp) GetNew(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) { + return s.getSubreddits(ctx, "subreddits/new", opts) +} + +// GetGold returns gold subreddits +func (s *SubredditServiceOp) GetGold(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) { + return s.getSubreddits(ctx, "subreddits/gold", opts) +} + +// GetDefault returns default subreddits +func (s *SubredditServiceOp) GetDefault(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) { + return s.getSubreddits(ctx, "subreddits/default", opts) +} + +// GetMineWhereSubscriber returns the list of subreddits the client is subscribed to +func (s *SubredditServiceOp) GetMineWhereSubscriber(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) { + return s.getSubreddits(ctx, "subreddits/mine/subscriber", opts) +} + +// GetMineWhereContributor returns the list of subreddits the client is a contributor to +func (s *SubredditServiceOp) GetMineWhereContributor(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) { + return s.getSubreddits(ctx, "subreddits/mine/contributor", opts) +} + +// GetMineWhereModerator returns the list of subreddits the client is a moderator in +func (s *SubredditServiceOp) GetMineWhereModerator(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) { + return s.getSubreddits(ctx, "subreddits/mine/contributor", opts) +} + +// GetMineWhereStreams returns the list of subreddits the client is subscribed to and has hosted videos in +func (s *SubredditServiceOp) GetMineWhereStreams(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) { + return s.getSubreddits(ctx, "subreddits/mine/contributor", opts) +} + +type sort int + +const ( + sortHot sort = iota + sortBest + sortNew + sortRising + sortControversial + sortTop +) + +var sorts = [...]string{ + "hot", + "best", + "new", + "rising", + "controversial", + "top", +} + +// GetHotPosts returns the hot posts +// If no subreddit names are provided, then it runs the search against /r/all +// IMPORTANT: for subreddits, this will include the stickied posts (if any) +// PLUS the number of posts from the limit parameter (which is 25 by default) +func (s *SubredditServiceOp) GetHotPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) { + return s.getPosts(ctx, sortHot, opts, names...) +} + +// GetBestPosts returns the best posts +// If no subreddit names are provided, then it runs the search against /r/all +// IMPORTANT: for subreddits, this will include the stickied posts (if any) +// PLUS the number of posts from the limit parameter (which is 25 by default) +func (s *SubredditServiceOp) GetBestPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) { + return s.getPosts(ctx, sortBest, opts, names...) +} + +// GetNewPosts returns the new posts +// If no subreddit names are provided, then it runs the search against /r/all +func (s *SubredditServiceOp) GetNewPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) { + return s.getPosts(ctx, sortNew, opts, names...) +} + +// GetRisingPosts returns the rising posts +// If no subreddit names are provided, then it runs the search against /r/all +func (s *SubredditServiceOp) GetRisingPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) { + return s.getPosts(ctx, sortRising, opts, names...) +} + +// GetControversialPosts returns the controversial posts +// If no subreddit names are provided, then it runs the search against /r/all +func (s *SubredditServiceOp) GetControversialPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) { + return s.getPosts(ctx, sortControversial, opts, names...) +} + +// GetTopPosts returns the top posts +// If no subreddit names are provided, then it runs the search against /r/all +func (s *SubredditServiceOp) GetTopPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) { + return s.getPosts(ctx, sortTop, opts, names...) +} + +type sticky int + +const ( + sticky1 sticky = iota + 1 + sticky2 +) + +// GetSticky1 returns the first stickied post on a subreddit (if it exists) +func (s *SubredditServiceOp) GetSticky1(ctx context.Context, name string) (interface{}, *Response, error) { + return s.getSticky(ctx, name, sticky1) +} + +// GetSticky2 returns the second stickied post on a subreddit (if it exists) +func (s *SubredditServiceOp) GetSticky2(ctx context.Context, name string) (interface{}, *Response, error) { + return s.getSticky(ctx, name, sticky2) +} + +func (s *SubredditServiceOp) getSubreddits(ctx context.Context, path string, opts *ListOptions) (*SubredditList, *Response, error) { + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(subredditRootListing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + if root.Data == nil { + return nil, resp, nil + } + + sl := new(SubredditList) + var subreddits []Subreddit + + for _, child := range root.Data.Roots { + subreddits = append(subreddits, *child.Data) + } + sl.Subreddits = subreddits + sl.After = root.Data.After + sl.Before = root.Data.Before + + return sl, resp, nil +} + +func (s *SubredditServiceOp) getPosts(ctx context.Context, sort sort, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) { + path := sorts[sort] + if len(names) > 0 { + path = fmt.Sprintf("r/%s/%s", strings.Join(names, "+"), sorts[sort]) + } + + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(submissionRootListing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + if root.Data == nil { + return nil, resp, nil + } + + sl := new(SubmissionList) + var submissions []Submission + + for _, child := range root.Data.Roots { + submissions = append(submissions, *child.Data) + } + sl.Submissions = submissions + sl.After = root.Data.After + sl.Before = root.Data.Before + + return sl, resp, nil +} + +// getSticky returns one of the 2 stickied posts of the subreddit +// Num should be equal to 1 or 2, depending on which one you want +// If it's <= 1, it's 1 +// If it's >= 2, it's 2 +// todo +func (s *SubredditServiceOp) getSticky(ctx context.Context, name string, num sticky) (interface{}, *Response, error) { + // type query struct { + // Num sticky `url:"num"` + // } + + // path := fmt.Sprintf("r/%s/about/sticky", name) + // path, err := addOptions(path, query{num}) + // if err != nil { + // return nil, nil, err + // } + + // req, err := s.client.NewRequest(http.MethodGet, path, nil) + // if err != nil { + // return nil, nil, err + // } + + // root := new(submissionRootListing) + // resp, err := s.client.Do(ctx, req, root) + // if err != nil { + // return nil, resp, err + // } + + // return nil, resp, nil + return nil, nil, nil +} diff --git a/testdata/comment-submit-edit.json b/testdata/comment-submit-edit.json new file mode 100644 index 0000000..d5ee17a --- /dev/null +++ b/testdata/comment-submit-edit.json @@ -0,0 +1,72 @@ +{ + "total_awards_received": 0, + "approved_at_utc": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "replies": "", + "author_flair_type": "richtext", + "removal_reason": null, + "link_id": "t3_link1", + "author_flair_template_id": "024b2b66-05ca-11e1-96f4-12313d096aae", + "likes": true, + "no_follow": false, + "author_fullname": "t2_user1", + "user_reports": [], + "body_html": "

test comment

\n
", + "send_replies": true, + "saved": false, + "id": "test2", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "report_reasons": null, + "author": "reddit_username", + "can_mod_post": false, + "ups": 1, + "parent_id": "t1_test", + "score": 1, + "approved_by": null, + "author_premium": false, + "all_awardings": [], + "subreddit_id": "t5_test", + "body": "test comment", + "edited": false, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [ + { + "e": "text", + "t": "Beginner - Strength" + } + ], + "author_patreon_flair": false, + "collapsed_reason": null, + "gildings": {}, + "associated_award": null, + "stickied": false, + "subreddit_type": "public", + "can_gild": false, + "subreddit": "subreddit", + "author_flair_text_color": "dark", + "score_hidden": false, + "permalink": "/r/subreddit/comments/test1/some_thread/test2/", + "num_reports": null, + "locked": false, + "name": "t1_test2", + "created": 1588147787, + "author_flair_text": "Flair", + "treatment_tags": [], + "rte_mode": "markdown", + "created_utc": 1588118987, + "subreddit_name_prefixed": "r/subreddit", + "controversiality": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "mod_note": null, + "distinguished": null +} diff --git a/testdata/flairs-v2.json b/testdata/flairs-v2.json new file mode 100644 index 0000000..7ceb03b --- /dev/null +++ b/testdata/flairs-v2.json @@ -0,0 +1,30 @@ +[ + { + "allowable_content": "all", + "text": "Beginner", + "text_color": "dark", + "mod_only": false, + "background_color": "", + "id": "b8a1c822-3feb-11e8-88e1-0e5f55d58ce0", + "css_class": "Beginner1", + "max_emojis": 10, + "richtext": [], + "text_editable": false, + "override_css": false, + "type": "text" + }, + { + "allowable_content": "all", + "text": "Moderator", + "text_color": "dark", + "mod_only": true, + "background_color": "", + "id": "b8ea0fce-3feb-11e8-af7a-0e263a127cf8", + "css_class": "Moderator1", + "max_emojis": 10, + "richtext": [], + "text_editable": false, + "override_css": false, + "type": "text" + } +] diff --git a/testdata/flairs.json b/testdata/flairs.json new file mode 100644 index 0000000..ccf40ed --- /dev/null +++ b/testdata/flairs.json @@ -0,0 +1,20 @@ +[ + { + "text": "Beginner", + "richtext": [], + "text_editable": false, + "override_css": false, + "css_class": "Beginner1", + "type": "text", + "id": "b8a1c822-3feb-11e8-88e1-0e5f55d58ce0" + }, + { + "text": "Beginner", + "richtext": [], + "text_editable": false, + "override_css": false, + "css_class": "Beginner2", + "type": "text", + "id": "b8ea0fce-3feb-11e8-af7a-0e263a127cf8" + } +] diff --git a/user.go b/user.go new file mode 100644 index 0000000..b2bd3aa --- /dev/null +++ b/user.go @@ -0,0 +1,210 @@ +package geddit + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// UserService handles communication with the user +// related methods of the Reddit API +type UserService interface { + Get(ctx context.Context, username string) (*User, *Response, error) + GetMultipleByID(ctx context.Context, ids ...string) (map[string]*UserShort, *Response, error) + UsernameAvailable(ctx context.Context, username string) (bool, *Response, error) + + GetComments(ctx context.Context, sort sort, opts *ListOptions) (*CommentList, *Response, error) + GetCommentsOf(ctx context.Context, username string, sort sort, opts *ListOptions) (*CommentList, *Response, error) +} + +// UserServiceOp implements the UserService interface +type UserServiceOp struct { + client *Client +} + +var _ UserService = &UserServiceOp{} + +type userRoot struct { + Kind *string `json:"kind,omitempty"` + Data *User `json:"data,omitempty"` +} + +// User represents a Reddit user +type User struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Created float64 `json:"created"` + CreatedUTC float64 `json:"created_utc"` + + LinkKarma int `json:"link_karma"` + CommentKarma int `json:"comment_karma"` + + IsFriend bool `json:"is_friend"` + IsEmployee bool `json:"is_employee"` + HasVerifiedEmail bool `json:"has_verified_email"` + NSFW bool `json:"over_18"` +} + +// UserShort represents a Reddit user, but contains fewer pieces of information +// It is returned from the GET /api/user_data_by_account_ids endpoint +type UserShort struct { + Name string `json:"name,omitempty"` + CreatedUTC float64 `json:"created_utc"` + + LinkKarma int `json:"link_karma"` + CommentKarma int `json:"comment_karma"` + + NSFW bool `json:"profile_over_18"` +} + +// Get returns information about the user +func (s *UserServiceOp) Get(ctx context.Context, username string) (*User, *Response, error) { + path := fmt.Sprintf("user/%s/about", username) + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(userRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Data, resp, nil +} + +// GetMultipleByID returns multiple users from their full IDs +// The response body is a map where the keys are the IDs (if they exist), and the value is the user +func (s *UserServiceOp) GetMultipleByID(ctx context.Context, ids ...string) (map[string]*UserShort, *Response, error) { + type query struct { + IDs []string `url:"ids,omitempty,comma"` + } + + path := "api/user_data_by_account_ids" + path, err := addOptions(path, query{ids}) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(map[string]*UserShort) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return *root, resp, nil +} + +// GetComments returns a list of the client's comments +func (s *UserServiceOp) GetComments(ctx context.Context, sort sort, opts *ListOptions) (*CommentList, *Response, error) { + return s.GetCommentsOf(ctx, s.client.Username, sort, opts) +} + +// GetCommentsOf returns a list of the user's comments +func (s *UserServiceOp) GetCommentsOf(ctx context.Context, username string, sort sort, opts *ListOptions) (*CommentList, *Response, error) { + path := fmt.Sprintf("user/%s/comments?sort=%s", username, sorts[sort]) + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(commentRootListing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + if root.Data == nil { + return nil, resp, nil + } + + cl := new(CommentList) + var comments []Comment + + for _, child := range root.Data.Roots { + comments = append(comments, *child.Data) + } + cl.Comments = comments + cl.After = root.Data.After + cl.Before = root.Data.Before + + return cl, resp, nil +} + +// UsernameAvailable checks whether a username is available for registration +// If a valid username is provided, this endpoint returns a body with just "true" or "false" +// If an invalid username is provided, it returns the following: +/* +{ + "json": { + "errors": [ + [ + "BAD_USERNAME", + "invalid username", + "user" + ] + ] + } +} +*/ +func (s *UserServiceOp) UsernameAvailable(ctx context.Context, username string) (bool, *Response, error) { + type query struct { + User string `url:"user,omitempty"` + } + + path := "api/username_available" + path, err := addOptions(path, query{username}) + if err != nil { + return false, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return false, nil, err + } + + root := new(bool) + resp, err := s.client.Do(ctx, req, root) + if _, ok := err.(*json.UnmarshalTypeError); ok { + return false, resp, fmt.Errorf("must provide username conforming to Reddit's criteria; username provided: %q", username) + } + if err != nil { + return false, resp, err + } + + return *root, resp, nil +} + +// Friend creates or updates a "friend" relationship +// Request body contains JSON data with: +// name: existing Reddit username +// note: a string no longer than 300 characters +func (s *UserServiceOp) Friend(ctx context.Context, username string, note string) (interface{}, *Response, error) { + type request struct { + Name string `url:"name"` + Note string `url:"note"` + } + + path := fmt.Sprintf("api/v1/me/friends/%s", username) + body := request{Name: username, Note: note} + + _, err := s.client.NewRequest(http.MethodPut, path, body) + if err != nil { + return false, nil, err + } + + // todo: requires gold + return nil, nil, nil +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..59e65c6 --- /dev/null +++ b/util.go @@ -0,0 +1,118 @@ +package geddit + +import ( + "bytes" + "fmt" + "io" + "reflect" +) + +// String is a helper routine that allocates a new string value +// to store v and returns a pointer to it. +func String(v string) *string { + p := new(string) + *p = v + return p +} + +// Int is a helper routine that allocates a new int32 value +// to store v and returns a pointer to it, but unlike Int32 +// its argument value is an int. +func Int(v int) *int { + p := new(int) + *p = v + return p +} + +// Bool is a helper routine that allocates a new bool value +// to store v and returns a pointer to it. +func Bool(v bool) *bool { + p := new(bool) + *p = v + return p +} + +// StreamToString converts a reader to a string +func StreamToString(stream io.Reader) string { + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(stream) + return buf.String() +} + +// var timestampType = reflect.TypeOf(Timestamp{}) + +// Stringify attempts to create a string representation of types +func Stringify(message interface{}) string { + var buf bytes.Buffer + v := reflect.ValueOf(message) + stringifyValue(&buf, v) + return buf.String() +} + +// stringifyValue was graciously cargoculted from the goprotubuf library +func stringifyValue(w io.Writer, val reflect.Value) { + if val.Kind() == reflect.Ptr && val.IsNil() { + _, _ = w.Write([]byte("")) + return + } + + v := reflect.Indirect(val) + + switch v.Kind() { + case reflect.String: + fmt.Fprintf(w, `"%s"`, v) + case reflect.Slice: + stringifySlice(w, v) + return + case reflect.Struct: + stringifyStruct(w, v) + default: + if v.CanInterface() { + fmt.Fprint(w, v.Interface()) + } + } +} + +func stringifySlice(w io.Writer, v reflect.Value) { + _, _ = w.Write([]byte{'['}) + for i := 0; i < v.Len(); i++ { + if i > 0 { + _, _ = w.Write([]byte{' '}) + } + + stringifyValue(w, v.Index(i)) + } + + _, _ = w.Write([]byte{']'}) +} + +func stringifyStruct(w io.Writer, v reflect.Value) { + if v.Type().Name() != "" { + _, _ = w.Write([]byte(v.Type().String())) + } + + _, _ = w.Write([]byte{'{'}) + + var sep bool + for i := 0; i < v.NumField(); i++ { + fv := v.Field(i) + if fv.Kind() == reflect.Ptr && fv.IsNil() { + continue + } + if fv.Kind() == reflect.Slice && fv.IsNil() { + continue + } + + if sep { + _, _ = w.Write([]byte(", ")) + } else { + sep = true + } + + _, _ = w.Write([]byte(v.Type().Field(i).Name)) + _, _ = w.Write([]byte{':'}) + stringifyValue(w, fv) + } + + _, _ = w.Write([]byte{'}'}) +} diff --git a/vote.go b/vote.go new file mode 100644 index 0000000..a0cf8c8 --- /dev/null +++ b/vote.go @@ -0,0 +1,67 @@ +package geddit + +import ( + "context" + "fmt" + "net/url" +) + +// VoteService handles communication with the upvote/downvote +// related methods of the Reddit API +type VoteService interface { + Up(ctx context.Context, id string) (*Response, error) + Down(ctx context.Context, id string) (*Response, error) + Remove(ctx context.Context, id string) (*Response, error) +} + +// VoteServiceOp implements the Vote interface +type VoteServiceOp struct { + client *Client +} + +var _ VoteService = &VoteServiceOp{} + +type vote int + +// Reddit interprets -1, 0, 1 as downvote, no vote, and upvote, respectively. +const ( + downvote vote = iota - 1 + novote + upvote +) + +func (s *VoteServiceOp) vote(ctx context.Context, id string, vote vote) (*Response, error) { + path := "api/vote" + + form := url.Values{} + form.Set("id", id) + form.Set("dir", fmt.Sprint(vote)) + form.Set("rank", "10") + + req, err := s.client.NewPostForm(path, form) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +// Up upvotes a link or a comment +func (s *VoteServiceOp) Up(ctx context.Context, id string) (*Response, error) { + return s.vote(ctx, id, upvote) +} + +// Down downvotes a link or a comment +func (s *VoteServiceOp) Down(ctx context.Context, id string) (*Response, error) { + return s.vote(ctx, id, downvote) +} + +// Remove removes the user's vote on a link or a comment +func (s *VoteServiceOp) Remove(ctx context.Context, id string) (*Response, error) { + return s.vote(ctx, id, novote) +} diff --git a/vote_test.go b/vote_test.go new file mode 100644 index 0000000..3745f8f --- /dev/null +++ b/vote_test.go @@ -0,0 +1,87 @@ +package geddit + +import ( + "fmt" + "net/http" + "net/url" + "reflect" + "testing" +) + +func TestVoteServiceOp_Up(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + + form := url.Values{} + form.Set("id", "t3_test") + form.Set("dir", fmt.Sprint(upvote)) + form.Set("rank", "10") + + _ = r.ParseForm() + if expect, actual := form, r.PostForm; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + fmt.Fprint(w, `{}`) + }) + + _, err := client.Vote.Up(ctx, "t3_test") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } +} + +func TestVoteServiceOp_Down(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + + form := url.Values{} + form.Set("id", "t3_test") + form.Set("dir", fmt.Sprint(downvote)) + form.Set("rank", "10") + + _ = r.ParseForm() + if expect, actual := form, r.PostForm; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + fmt.Fprint(w, `{}`) + }) + + _, err := client.Vote.Down(ctx, "t3_test") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } +} + +func TestVoteServiceOp_Remove(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + + form := url.Values{} + form.Set("id", "t3_test") + form.Set("dir", fmt.Sprint(novote)) + form.Set("rank", "10") + + _ = r.ParseForm() + if expect, actual := form, r.PostForm; !reflect.DeepEqual(expect, actual) { + t.Fatalf("got unexpected value\nexpect: %s\nactual: %s", Stringify(expect), Stringify(actual)) + } + + fmt.Fprint(w, `{}`) + }) + + _, err := client.Vote.Remove(ctx, "t3_test") + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } +}