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: "
",
+ 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": "",
+ "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)
+ }
+}