Add good bit of functionality, some tests
Signed-off-by: Vartan Benohanian <vartanbeno@gmail.com>
This commit is contained in:
+223
@@ -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
|
||||||
|
}
|
||||||
+216
@@ -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: "<div class=\"md\"><p>test comment</p>\n</div>",
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,20 +9,67 @@ import (
|
|||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/go-querystring/query"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
libraryName = "github.com/vartanbeno/geddit"
|
||||||
libraryVersion = "0.0.1"
|
libraryVersion = "0.0.1"
|
||||||
defaultBaseURL = "https://reddit.com"
|
|
||||||
defaultBaseURLOauth = "https://oauth.reddit.com"
|
defaultBaseURL = "https://oauth.reddit.com"
|
||||||
userAgent = "geddit/" + libraryVersion
|
defaultTokenURL = "https://www.reddit.com/api/v1/access_token"
|
||||||
mediaType = "application/json"
|
|
||||||
|
mediaTypeJSON = "application/json"
|
||||||
|
mediaTypeForm = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
headerContentType = "Content-Type"
|
headerContentType = "Content-Type"
|
||||||
headerAccept = "Accept"
|
headerAccept = "Accept"
|
||||||
headerUserAgent = "User-Agent"
|
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
|
// RequestCompletionCallback defines the type of the request callback function
|
||||||
type RequestCompletionCallback func(*http.Request, *http.Response)
|
type RequestCompletionCallback func(*http.Request, *http.Response)
|
||||||
|
|
||||||
@@ -31,12 +78,25 @@ type Client struct {
|
|||||||
// HTTP client used to communicate with the Reddit API
|
// HTTP client used to communicate with the Reddit API
|
||||||
client *http.Client
|
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
|
Subreddit SubredditService
|
||||||
|
User UserService
|
||||||
|
Vote VoteService
|
||||||
|
|
||||||
|
oauth2Transport *oauth2.Transport
|
||||||
|
|
||||||
onRequestCompleted RequestCompletionCallback
|
onRequestCompleted RequestCompletionCallback
|
||||||
}
|
}
|
||||||
@@ -52,21 +112,47 @@ func newClient(httpClient *http.Client) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
baseURL, _ := url.Parse(defaultBaseURL)
|
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.Subreddit = &SubredditServiceOp{client: c}
|
||||||
|
c.User = &UserServiceOp{client: c}
|
||||||
|
c.Vote = &VoteServiceOp{client: c}
|
||||||
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a client that can make requests to the Reddit API
|
// NewClient returns a client that can make requests to the Reddit API
|
||||||
func New(httpClient *http.Client, opts ...Opt) (c *Client, err error) {
|
func NewClient(httpClient *http.Client, opts ...Opt) (c *Client, err error) {
|
||||||
c = newClient(httpClient)
|
c = newClient(httpClient)
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
if err = opt(c); err != nil {
|
if err = opt(c); err != nil {
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +179,28 @@ func (c *Client) NewRequest(method, path string, body interface{}) (*http.Reques
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Add(headerContentType, mediaType)
|
req.Header.Add(headerContentType, mediaTypeJSON)
|
||||||
req.Header.Add(headerAccept, mediaType)
|
req.Header.Add(headerAccept, mediaTypeJSON)
|
||||||
req.Header.Add(headerUserAgent, c.UserAgent)
|
|
||||||
|
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
|
return req, nil
|
||||||
}
|
}
|
||||||
@@ -164,6 +269,29 @@ func DoRequestWithClient(ctx context.Context, client *http.Client, req *http.Req
|
|||||||
return client.Do(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
|
// An ErrorResponse reports the error caused by an API request
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
// HTTP response that caused this error
|
// 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.
|
// 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.
|
// 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 {
|
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 {
|
if c := r.StatusCode; c >= 200 && c <= 299 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
errorResponse := &ErrorResponse{Response: r}
|
errorResponse := &ErrorResponse{Response: r}
|
||||||
data, err := ioutil.ReadAll(r.Body)
|
data, err = ioutil.ReadAll(r.Body)
|
||||||
if err == nil && len(data) > 0 {
|
if err == nil && len(data) > 0 {
|
||||||
err := json.Unmarshal(data, errorResponse)
|
err := json.Unmarshal(data, errorResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -198,3 +340,60 @@ func CheckResponse(r *http.Response) error {
|
|||||||
|
|
||||||
return errorResponse
|
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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+124
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -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
|
||||||
+329
-11
@@ -5,12 +5,32 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SubredditService handles communication with the subreddit
|
// SubredditService handles communication with the subreddit
|
||||||
// related methods of the Reddit API
|
// related methods of the Reddit API
|
||||||
type SubredditService interface {
|
type SubredditService interface {
|
||||||
GetByName(ctx context.Context, name string) (*Subreddit, *Response, error)
|
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
|
// SubredditServiceOp implements the SubredditService interface
|
||||||
@@ -25,20 +45,98 @@ type subredditRoot struct {
|
|||||||
Data *Subreddit `json:"data,omitempty"`
|
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
|
// Subreddit holds information about a subreddit
|
||||||
type Subreddit struct {
|
type Subreddit struct {
|
||||||
ID *string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
FullID *string `json:"name,omitempty"`
|
FullID string `json:"name,omitempty"`
|
||||||
Created *float64 `json:"created_utc,omitempty"`
|
Created float64 `json:"created"`
|
||||||
|
CreatedUTC float64 `json:"created_utc"`
|
||||||
|
|
||||||
URL *string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
DisplayName *string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
DisplayNamePrefixed *string `json:"display_name_prefixed,omitempty"`
|
DisplayNamePrefixed string `json:"display_name_prefixed,omitempty"`
|
||||||
Title *string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
PublicDescription *string `json:"public_description,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"`
|
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
|
// 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")
|
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)
|
req, err := s.client.NewRequest(http.MethodGet, path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -62,3 +159,224 @@ func (s *SubredditServiceOp) GetByName(ctx context.Context, name string) (*Subre
|
|||||||
|
|
||||||
return root.Data, resp, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+72
@@ -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": "<div class=\"md\"><p>test comment</p>\n</div>",
|
||||||
|
"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
|
||||||
|
}
|
||||||
Vendored
+30
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
Vendored
+20
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("<nil>"))
|
||||||
|
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{'}'})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user