Add good bit of functionality, some tests

Signed-off-by: Vartan Benohanian <vartanbeno@gmail.com>
This commit is contained in:
Vartan Benohanian
2020-04-29 15:59:18 -04:00
parent 3c7aee142d
commit 9a171b1c90
21 changed files with 2345 additions and 40 deletions
+223
View File
@@ -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
View File
@@ -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))
}
}
+74
View File
@@ -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
}
+82
View File
@@ -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))
}
}
+214 -15
View File
@@ -9,20 +9,67 @@ import (
"io/ioutil"
"net/http"
"net/url"
"reflect"
"strings"
"github.com/google/go-querystring/query"
"golang.org/x/oauth2"
)
const (
libraryVersion = "0.0.1"
defaultBaseURL = "https://reddit.com"
defaultBaseURLOauth = "https://oauth.reddit.com"
userAgent = "geddit/" + libraryVersion
mediaType = "application/json"
libraryName = "github.com/vartanbeno/geddit"
libraryVersion = "0.0.1"
defaultBaseURL = "https://oauth.reddit.com"
defaultTokenURL = "https://www.reddit.com/api/v1/access_token"
mediaTypeJSON = "application/json"
mediaTypeForm = "application/x-www-form-urlencoded"
headerContentType = "Content-Type"
headerAccept = "Accept"
headerUserAgent = "User-Agent"
)
// cloneRequest returns a clone of the provided *http.Request.
// The clone is a shallow copy of the struct and its Header map,
// since we'll only be modify the headers.
// Per the specification of http.RoundTripper, we should not directly modify a request.
func cloneRequest(r *http.Request) *http.Request {
r2 := new(http.Request)
*r2 = *r
// deep copy of the Header
r2.Header = make(http.Header, len(r.Header))
for k, s := range r.Header {
r2.Header[k] = append([]string(nil), s...)
}
return r2
}
// sets the User-Agent header for requests
type userAgentTransport struct {
ua string
Base http.RoundTripper
}
func (t *userAgentTransport) setUserAgent(req *http.Request) *http.Request {
req2 := cloneRequest(req)
req2.Header.Set(headerUserAgent, t.ua)
return req2
}
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req2 := t.setUserAgent(req)
return t.base().RoundTrip(req2)
}
func (t *userAgentTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return http.DefaultTransport
}
// RequestCompletionCallback defines the type of the request callback function
type RequestCompletionCallback func(*http.Request, *http.Response)
@@ -31,12 +78,25 @@ type Client struct {
// HTTP client used to communicate with the Reddit API
client *http.Client
// Base URL for HTTP requests
BaseURL *url.URL
BaseURL *url.URL
TokenURL *url.URL
UserAgent string
userAgent string
ID string
Secret string
Username string
Password string
Comment CommentService
Flair FlairService
Link LinkService
Listings ListingsService
Subreddit SubredditService
User UserService
Vote VoteService
oauth2Transport *oauth2.Transport
onRequestCompleted RequestCompletionCallback
}
@@ -52,21 +112,47 @@ func newClient(httpClient *http.Client) *Client {
}
baseURL, _ := url.Parse(defaultBaseURL)
tokenURL, _ := url.Parse(defaultTokenURL)
c := &Client{client: httpClient, BaseURL: baseURL, UserAgent: userAgent}
c := &Client{client: httpClient, BaseURL: baseURL, TokenURL: tokenURL}
c.Comment = &CommentServiceOp{client: c}
c.Flair = &FlairServiceOp{client: c}
c.Link = &LinkServiceOp{client: c}
c.Listings = &ListingsServiceOp{client: c}
c.Subreddit = &SubredditServiceOp{client: c}
c.User = &UserServiceOp{client: c}
c.Vote = &VoteServiceOp{client: c}
return c
}
// New returns a client that can make requests to the Reddit API
func New(httpClient *http.Client, opts ...Opt) (c *Client, err error) {
// NewClient returns a client that can make requests to the Reddit API
func NewClient(httpClient *http.Client, opts ...Opt) (c *Client, err error) {
c = newClient(httpClient)
for _, opt := range opts {
if err = opt(c); err != nil {
return
}
}
c.userAgent = fmt.Sprintf("golang:%s:v%s (by /u/%s)", libraryName, libraryVersion, c.Username)
userAgentTransport := &userAgentTransport{
ua: c.userAgent,
Base: c.client.Transport,
}
oauth2Config := oauth2Config{
id: c.ID,
secret: c.Secret,
username: c.Username,
password: c.Password,
tokenURL: c.TokenURL.String(),
userAgentTransport: userAgentTransport,
}
c.client.Transport = oauth2Transport(oauth2Config)
return
}
@@ -93,9 +179,28 @@ func (c *Client) NewRequest(method, path string, body interface{}) (*http.Reques
return nil, err
}
req.Header.Add(headerContentType, mediaType)
req.Header.Add(headerAccept, mediaType)
req.Header.Add(headerUserAgent, c.UserAgent)
req.Header.Add(headerContentType, mediaTypeJSON)
req.Header.Add(headerAccept, mediaTypeJSON)
return req, nil
}
// NewPostForm creates an API request with a POST form
// The path is the relative URL which will be resolves to the BaseURL of the Client
// It should always be specified without a preceding slash
func (c *Client) NewPostForm(path string, form url.Values) (*http.Request, error) {
u, err := c.BaseURL.Parse(path)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Add(headerContentType, mediaTypeForm)
req.Header.Add(headerAccept, mediaTypeJSON)
return req, nil
}
@@ -164,6 +269,29 @@ func DoRequestWithClient(ctx context.Context, client *http.Client, req *http.Req
return client.Do(req)
}
// JSONErrorResponse is an error response that sometimes gets returned with a 200 code
type JSONErrorResponse struct {
// HTTP response that caused this error
Response *http.Response
JSON *struct {
Errors [][]string `json:"errors,omitempty"`
} `json:"json,omitempty"`
}
func (r *JSONErrorResponse) Error() string {
var message string
if r.JSON != nil && len(r.JSON.Errors) > 0 {
for _, errList := range r.JSON.Errors {
message += strings.Join(errList, ", ")
}
}
return fmt.Sprintf(
"%v %v: %d %v",
r.Response.Request.Method, r.Response.Request.URL, r.Response.StatusCode, message,
)
}
// An ErrorResponse reports the error caused by an API request
type ErrorResponse struct {
// HTTP response that caused this error
@@ -182,13 +310,27 @@ func (r *ErrorResponse) Error() string {
// CheckResponse checks the API response for errors, and returns them if present.
// A response is considered an error if it has a status code outside the 200 range.
// Reddit also sometimes sends errors with 200 codes; we check for those too.
func CheckResponse(r *http.Response) error {
jsonErrorResponse := &JSONErrorResponse{Response: r}
data, err := ioutil.ReadAll(r.Body)
if err == nil && len(data) > 0 {
json.Unmarshal(data, jsonErrorResponse)
if jsonErrorResponse.JSON != nil {
return jsonErrorResponse
}
}
// reset response body
r.Body = ioutil.NopCloser(bytes.NewBuffer(data))
if c := r.StatusCode; c >= 200 && c <= 299 {
return nil
}
errorResponse := &ErrorResponse{Response: r}
data, err := ioutil.ReadAll(r.Body)
data, err = ioutil.ReadAll(r.Body)
if err == nil && len(data) > 0 {
err := json.Unmarshal(data, errorResponse)
if err != nil {
@@ -198,3 +340,60 @@ func CheckResponse(r *http.Response) error {
return errorResponse
}
// ListOptions are the optional parameters to the various endpoints that return lists
type ListOptions struct {
Sort string `url:"sort,omitempty"`
Type string `url:"type,omitempty"` // links or comments
// For getting submissions
// all, year, month, week, day, hour
Timespan string `url:"t,omitempty"`
// Common for all listing endpoints
After string `url:"after,omitempty"`
Before string `url:"before,omitempty"`
Limit int `url:"limit,omitempty"` // default: 25
Count int `url:"count,omitempty"` // default: 0
}
func addOptions(s string, opt interface{}) (string, error) {
v := reflect.ValueOf(opt)
if v.Kind() == reflect.Ptr && v.IsNil() {
return s, nil
}
origURL, err := url.Parse(s)
if err != nil {
return s, err
}
origValues := origURL.Query()
newValues, err := query.Values(opt)
if err != nil {
return s, err
}
for k, v := range newValues {
origValues[k] = v
}
origURL.RawQuery = origValues.Encode()
return origURL.String(), nil
}
type root struct {
Kind string `json:"kind,omitempty"`
Data interface{} `json:"data,omitempty"`
}
const (
kindListing string = "Listing"
kindComment string = "t1"
kindAccount string = "t2"
kindLink string = "t3"
kindMessage string = "t4"
kindSubreddit string = "t5"
kindAward string = "t6"
)
+99
View File
@@ -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
}
}
+62
View File
@@ -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
}
}
+61
View File
@@ -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))
}
}
-14
View File
@@ -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
View File
@@ -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))
}
}
+72
View File
@@ -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
}
+81
View File
@@ -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
View File
@@ -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
View File
@@ -5,12 +5,32 @@ import (
"errors"
"fmt"
"net/http"
"strings"
)
// SubredditService handles communication with the subreddit
// related methods of the Reddit API
type SubredditService interface {
GetByName(ctx context.Context, name string) (*Subreddit, *Response, error)
GetPopular(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error)
GetNew(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error)
GetGold(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error)
GetDefault(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error)
GetMineWhereSubscriber(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error)
GetMineWhereContributor(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error)
GetMineWhereModerator(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error)
GetMineWhereStreams(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error)
GetHotPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error)
GetNewPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error)
GetRisingPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error)
GetControversialPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error)
GetTopPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error)
GetSticky1(ctx context.Context, name string) (interface{}, *Response, error)
GetSticky2(ctx context.Context, name string) (interface{}, *Response, error)
}
// SubredditServiceOp implements the SubredditService interface
@@ -25,20 +45,98 @@ type subredditRoot struct {
Data *Subreddit `json:"data,omitempty"`
}
type subredditRootListing struct {
Kind *string `json:"kind,omitempty"`
Data *struct {
Dist int `json:"dist"`
Roots []subredditRoot `json:"children,omitempty"`
After string `json:"after,omitempty"`
Before string `json:"before,omitempty"`
} `json:"data,omitempty"`
}
// Subreddit holds information about a subreddit
type Subreddit struct {
ID *string `json:"id,omitempty"`
FullID *string `json:"name,omitempty"`
Created *float64 `json:"created_utc,omitempty"`
ID string `json:"id,omitempty"`
FullID string `json:"name,omitempty"`
Created float64 `json:"created"`
CreatedUTC float64 `json:"created_utc"`
URL *string `json:"url,omitempty"`
DisplayName *string `json:"display_name,omitempty"`
DisplayNamePrefixed *string `json:"display_name_prefixed,omitempty"`
Title *string `json:"title,omitempty"`
PublicDescription *string `json:"public_description,omitempty"`
URL string `json:"url,omitempty"`
DisplayName string `json:"display_name,omitempty"`
DisplayNamePrefixed string `json:"display_name_prefixed,omitempty"`
Title string `json:"title,omitempty"`
PublicDescription string `json:"public_description,omitempty"`
Type string `json:"subreddit_type,omitempty"`
SuggestedCommentSort string `json:"suggested_comment_sort,omitempty"`
Subscribers *int `json:"subscribers,omitempty"`
Subscribers int `json:"subscribers"`
ActiveUserCount *int `json:"active_user_count,omitempty"`
NSFW bool `json:"over18"`
UserIsMod bool `json:"user_is_moderator"`
}
// SubredditList holds information about a list of subreddits
// The after and before fields help decide the anchor point for a subsequent
// call that returns a list
type SubredditList struct {
Subreddits []Subreddit `json:"subreddits,omitempty"`
After string `json:"after,omitempty"`
Before string `json:"before,omitempty"`
}
type submissionRoot struct {
Kind *string `json:"kind,omitempty"`
Data *Submission `json:"data,omitempty"`
}
type submissionRootListing struct {
Kind *string `json:"kind,omitempty"`
Data *struct {
Dist int `json:"dist"`
Roots []submissionRoot `json:"children,omitempty"`
After string `json:"after,omitempty"`
Before string `json:"before,omitempty"`
} `json:"data,omitempty"`
}
// Submission is a submitted post on Reddit
type Submission struct {
ID string `json:"id,omitempty"`
FullID string `json:"name,omitempty"`
Created float64 `json:"created"`
CreatedUTC float64 `json:"created_utc"`
Permalink string `json:"permalink,omitempty"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"selftext,omitempty"`
Score int `json:"score"`
NumberOfComments int `json:"num_comments"`
SubredditID string `json:"t5_2qo4s,omitempty"`
SubredditName string `json:"subreddit,omitempty"`
SubredditNamePrefixed string `json:"subreddit_name_prefixed,omitempty"`
AuthorID string `json:"author_fullname,omitempty"`
AuthorName string `json:"author,omitempty"`
Spoiler bool `json:"spoiler"`
Locked bool `json:"locked"`
NSFW bool `json:"over_18"`
IsSelfPost bool `json:"is_self"`
Saved bool `json:"saved"`
Stickied bool `json:"stickied"`
}
// SubmissionList holds information about a list of subreddits
// The after and before fields help decide the anchor point for a subsequent
// call that returns a list
type SubmissionList struct {
Submissions []Submission `json:"submissions,omitempty"`
After string `json:"after,omitempty"`
Before string `json:"before,omitempty"`
}
// GetByName gets a subreddit by name
@@ -47,8 +145,7 @@ func (s *SubredditServiceOp) GetByName(ctx context.Context, name string) (*Subre
return nil, nil, errors.New("empty subreddit name provided")
}
path := fmt.Sprintf("r/%s/about.json", name)
path := fmt.Sprintf("r/%s/about", name)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
@@ -62,3 +159,224 @@ func (s *SubredditServiceOp) GetByName(ctx context.Context, name string) (*Subre
return root.Data, resp, nil
}
// GetPopular returns popular subreddits
func (s *SubredditServiceOp) GetPopular(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) {
return s.getSubreddits(ctx, "subreddits/popular", opts)
}
// GetNew returns new subreddits
func (s *SubredditServiceOp) GetNew(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) {
return s.getSubreddits(ctx, "subreddits/new", opts)
}
// GetGold returns gold subreddits
func (s *SubredditServiceOp) GetGold(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) {
return s.getSubreddits(ctx, "subreddits/gold", opts)
}
// GetDefault returns default subreddits
func (s *SubredditServiceOp) GetDefault(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) {
return s.getSubreddits(ctx, "subreddits/default", opts)
}
// GetMineWhereSubscriber returns the list of subreddits the client is subscribed to
func (s *SubredditServiceOp) GetMineWhereSubscriber(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) {
return s.getSubreddits(ctx, "subreddits/mine/subscriber", opts)
}
// GetMineWhereContributor returns the list of subreddits the client is a contributor to
func (s *SubredditServiceOp) GetMineWhereContributor(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) {
return s.getSubreddits(ctx, "subreddits/mine/contributor", opts)
}
// GetMineWhereModerator returns the list of subreddits the client is a moderator in
func (s *SubredditServiceOp) GetMineWhereModerator(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) {
return s.getSubreddits(ctx, "subreddits/mine/contributor", opts)
}
// GetMineWhereStreams returns the list of subreddits the client is subscribed to and has hosted videos in
func (s *SubredditServiceOp) GetMineWhereStreams(ctx context.Context, opts *ListOptions) (*SubredditList, *Response, error) {
return s.getSubreddits(ctx, "subreddits/mine/contributor", opts)
}
type sort int
const (
sortHot sort = iota
sortBest
sortNew
sortRising
sortControversial
sortTop
)
var sorts = [...]string{
"hot",
"best",
"new",
"rising",
"controversial",
"top",
}
// GetHotPosts returns the hot posts
// If no subreddit names are provided, then it runs the search against /r/all
// IMPORTANT: for subreddits, this will include the stickied posts (if any)
// PLUS the number of posts from the limit parameter (which is 25 by default)
func (s *SubredditServiceOp) GetHotPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) {
return s.getPosts(ctx, sortHot, opts, names...)
}
// GetBestPosts returns the best posts
// If no subreddit names are provided, then it runs the search against /r/all
// IMPORTANT: for subreddits, this will include the stickied posts (if any)
// PLUS the number of posts from the limit parameter (which is 25 by default)
func (s *SubredditServiceOp) GetBestPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) {
return s.getPosts(ctx, sortBest, opts, names...)
}
// GetNewPosts returns the new posts
// If no subreddit names are provided, then it runs the search against /r/all
func (s *SubredditServiceOp) GetNewPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) {
return s.getPosts(ctx, sortNew, opts, names...)
}
// GetRisingPosts returns the rising posts
// If no subreddit names are provided, then it runs the search against /r/all
func (s *SubredditServiceOp) GetRisingPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) {
return s.getPosts(ctx, sortRising, opts, names...)
}
// GetControversialPosts returns the controversial posts
// If no subreddit names are provided, then it runs the search against /r/all
func (s *SubredditServiceOp) GetControversialPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) {
return s.getPosts(ctx, sortControversial, opts, names...)
}
// GetTopPosts returns the top posts
// If no subreddit names are provided, then it runs the search against /r/all
func (s *SubredditServiceOp) GetTopPosts(ctx context.Context, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) {
return s.getPosts(ctx, sortTop, opts, names...)
}
type sticky int
const (
sticky1 sticky = iota + 1
sticky2
)
// GetSticky1 returns the first stickied post on a subreddit (if it exists)
func (s *SubredditServiceOp) GetSticky1(ctx context.Context, name string) (interface{}, *Response, error) {
return s.getSticky(ctx, name, sticky1)
}
// GetSticky2 returns the second stickied post on a subreddit (if it exists)
func (s *SubredditServiceOp) GetSticky2(ctx context.Context, name string) (interface{}, *Response, error) {
return s.getSticky(ctx, name, sticky2)
}
func (s *SubredditServiceOp) getSubreddits(ctx context.Context, path string, opts *ListOptions) (*SubredditList, *Response, error) {
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(subredditRootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
if root.Data == nil {
return nil, resp, nil
}
sl := new(SubredditList)
var subreddits []Subreddit
for _, child := range root.Data.Roots {
subreddits = append(subreddits, *child.Data)
}
sl.Subreddits = subreddits
sl.After = root.Data.After
sl.Before = root.Data.Before
return sl, resp, nil
}
func (s *SubredditServiceOp) getPosts(ctx context.Context, sort sort, opts *ListOptions, names ...string) (*SubmissionList, *Response, error) {
path := sorts[sort]
if len(names) > 0 {
path = fmt.Sprintf("r/%s/%s", strings.Join(names, "+"), sorts[sort])
}
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(submissionRootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
if root.Data == nil {
return nil, resp, nil
}
sl := new(SubmissionList)
var submissions []Submission
for _, child := range root.Data.Roots {
submissions = append(submissions, *child.Data)
}
sl.Submissions = submissions
sl.After = root.Data.After
sl.Before = root.Data.Before
return sl, resp, nil
}
// getSticky returns one of the 2 stickied posts of the subreddit
// Num should be equal to 1 or 2, depending on which one you want
// If it's <= 1, it's 1
// If it's >= 2, it's 2
// todo
func (s *SubredditServiceOp) getSticky(ctx context.Context, name string, num sticky) (interface{}, *Response, error) {
// type query struct {
// Num sticky `url:"num"`
// }
// path := fmt.Sprintf("r/%s/about/sticky", name)
// path, err := addOptions(path, query{num})
// if err != nil {
// return nil, nil, err
// }
// req, err := s.client.NewRequest(http.MethodGet, path, nil)
// if err != nil {
// return nil, nil, err
// }
// root := new(submissionRootListing)
// resp, err := s.client.Do(ctx, req, root)
// if err != nil {
// return nil, resp, err
// }
// return nil, resp, nil
return nil, nil, nil
}
+72
View File
@@ -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
}
+30
View File
@@ -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"
}
]
+20
View File
@@ -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"
}
]
+210
View File
@@ -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
}
+118
View File
@@ -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{'}'})
}
+67
View File
@@ -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)
}
+87
View File
@@ -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)
}
}