diff --git a/geddit.go b/geddit.go index 5d167d5..e3d575e 100644 --- a/geddit.go +++ b/geddit.go @@ -274,18 +274,52 @@ func DoRequestWithClient(ctx context.Context, client *http.Client, req *http.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 + Response *http.Response `json:"-"` JSON *struct { - Errors [][]string `json:"errors,omitempty"` + Errors []RedditError `json:"errors,omitempty"` } `json:"json,omitempty"` } +// RedditError is an error coming from Reddit +type RedditError struct { + Label string + Reason string + Field string +} + +func (e *RedditError) Error() string { + return fmt.Sprintf("%s: %s because of field %q", e.Label, e.Reason, e.Field) +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (e *RedditError) UnmarshalJSON(data []byte) error { + var info []string + + err := json.Unmarshal(data, &info) + if err != nil { + return err + } + + if len(info) != 3 { + return fmt.Errorf("got unexpected Reddit error: %v", info) + } + + e.Label = info[0] + e.Reason = info[1] + e.Field = info[2] + + return nil +} + 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, ", ") + for i, err := range r.JSON.Errors { + message += err.Error() + if i < len(r.JSON.Errors)-1 { + message += ";" + } } } return fmt.Sprintf( diff --git a/listings.go b/listings.go index 9c3bf79..6cb7769 100644 --- a/listings.go +++ b/listings.go @@ -13,6 +13,7 @@ import ( type ListingsService interface { Get(ctx context.Context, ids ...string) ([]Comment, []Link, []Subreddit, *Response, error) GetLinks(ctx context.Context, ids ...string) ([]Link, *Response, error) + GetLink(ctx context.Context, id string) (*LinkAndComments, *Response, error) } // ListingsServiceOp implements the Vote interface @@ -22,7 +23,7 @@ type ListingsServiceOp struct { var _ ListingsService = &ListingsServiceOp{} -// Get returns comments, link, and subreddits from their IDs +// Get returns comments, links, and subreddits from their IDs func (s *ListingsServiceOp) Get(ctx context.Context, ids ...string) ([]Comment, []Link, []Subreddit, *Response, error) { type query struct { IDs []string `url:"id,comma"` @@ -52,7 +53,7 @@ func (s *ListingsServiceOp) Get(ctx context.Context, ids ...string) ([]Comment, return comments, links, subreddits, resp, nil } -// GetLinks returns links from their IDs +// GetLinks returns links from their full IDs func (s *ListingsServiceOp) GetLinks(ctx context.Context, ids ...string) ([]Link, *Response, error) { if len(ids) == 0 { return nil, nil, errors.New("must provide at least 1 id") @@ -72,3 +73,20 @@ func (s *ListingsServiceOp) GetLinks(ctx context.Context, ids ...string) ([]Link return root.getLinks().Links, resp, nil } + +// GetLink returns a link with its comments +func (s *ListingsServiceOp) GetLink(ctx context.Context, id string) (*LinkAndComments, *Response, error) { + path := fmt.Sprintf("comments/%s", id) + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(LinkAndComments) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} diff --git a/subreddit.go b/subreddit.go index 04dda58..c35254e 100644 --- a/subreddit.go +++ b/subreddit.go @@ -7,13 +7,12 @@ import ( "net/http" "net/url" "strings" - "time" ) // SubredditService handles communication with the subreddit // related methods of the Reddit API type SubredditService interface { - GetByName(ctx context.Context, name string) (*Subreddit, *Response, error) + GetByName(ctx context.Context, subreddit string) (*Subreddit, *Response, error) GetPopular(ctx context.Context, opts *ListOptions) (*Subreddits, *Response, error) GetNew(ctx context.Context, opts *ListOptions) (*Subreddits, *Response, error) @@ -25,22 +24,20 @@ type SubredditService interface { GetMineWhereModerator(ctx context.Context, opts *ListOptions) (*Subreddits, *Response, error) GetMineWhereStreams(ctx context.Context, opts *ListOptions) (*Subreddits, *Response, error) - GetHotLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) - GetBestLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) - GetNewLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) - GetRisingLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) - GetControversialLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) - GetTopLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) + GetHotLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) + GetBestLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) + GetNewLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) + GetRisingLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) + GetControversialLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) + GetTopLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) - // GetSticky1(ctx context.Context, name string) (interface{}, *Response, error) - // GetSticky2(ctx context.Context, name string) (interface{}, *Response, error) + GetSticky1(ctx context.Context, subreddit string) (*LinkAndComments, *Response, error) + GetSticky2(ctx context.Context, subreddit string) (*LinkAndComments, *Response, error) - Subscribe(ctx context.Context, names ...string) (*Response, error) + Subscribe(ctx context.Context, subreddits ...string) (*Response, error) SubscribeByID(ctx context.Context, ids ...string) (*Response, error) - Unsubscribe(ctx context.Context, names ...string) (*Response, error) + Unsubscribe(ctx context.Context, subreddits ...string) (*Response, error) UnsubscribeByID(ctx context.Context, ids ...string) (*Response, error) - - StreamLinks(ctx context.Context, names ...string) (<-chan Link, chan<- bool, error) } // SubredditServiceOp implements the SubredditService interface @@ -51,12 +48,12 @@ type SubredditServiceOp struct { var _ SubredditService = &SubredditServiceOp{} // GetByName gets a subreddit by name -func (s *SubredditServiceOp) GetByName(ctx context.Context, name string) (*Subreddit, *Response, error) { - if name == "" { +func (s *SubredditServiceOp) GetByName(ctx context.Context, subreddit string) (*Subreddit, *Response, error) { + if subreddit == "" { return nil, nil, errors.New("empty subreddit name provided") } - path := fmt.Sprintf("r/%s/about", name) + path := fmt.Sprintf("r/%s/about", subreddit) req, err := s.client.NewRequest(http.MethodGet, path, nil) if err != nil { return nil, nil, err @@ -112,68 +109,61 @@ func (s *SubredditServiceOp) GetMineWhereStreams(ctx context.Context, opts *List } // GetHotLinks returns the hot links -// If no subreddit names are provided, then it runs the search against all those the client is subscribed to +// If no subreddit are provided, then it runs the search against all those the client is subscribed to // 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) GetHotLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) { - return s.getLinks(ctx, sortHot, opts, names...) +func (s *SubredditServiceOp) GetHotLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) { + return s.getLinks(ctx, sortHot, opts, subreddits...) } // GetBestLinks returns the best links -// If no subreddit names are provided, then it runs the search against all those the client is subscribed to +// If no subreddit are provided, then it runs the search against all those the client is subscribed to // 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) GetBestLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) { - return s.getLinks(ctx, sortBest, opts, names...) +func (s *SubredditServiceOp) GetBestLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) { + return s.getLinks(ctx, sortBest, opts, subreddits...) } // GetNewLinks returns the new links -// If no subreddit names are provided, then it runs the search against all those the client is subscribed to -func (s *SubredditServiceOp) GetNewLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) { - return s.getLinks(ctx, sortNew, opts, names...) +// If no subreddit are provided, then it runs the search against all those the client is subscribed to +func (s *SubredditServiceOp) GetNewLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) { + return s.getLinks(ctx, sortNew, opts, subreddits...) } // GetRisingLinks returns the rising links -// If no subreddit names are provided, then it runs the search against all those the client is subscribed to -func (s *SubredditServiceOp) GetRisingLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) { - return s.getLinks(ctx, sortRising, opts, names...) +// If no subreddit are provided, then it runs the search against all those the client is subscribed to +func (s *SubredditServiceOp) GetRisingLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) { + return s.getLinks(ctx, sortRising, opts, subreddits...) } // GetControversialLinks returns the controversial links -// If no subreddit names are provided, then it runs the search against all those the client is subscribed to -func (s *SubredditServiceOp) GetControversialLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) { - return s.getLinks(ctx, sortControversial, opts, names...) +// If no subreddit are provided, then it runs the search against all those the client is subscribed to +func (s *SubredditServiceOp) GetControversialLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) { + return s.getLinks(ctx, sortControversial, opts, subreddits...) } // GetTopLinks returns the top links -// If no subreddit names are provided, then it runs the search against all those the client is subscribed to -func (s *SubredditServiceOp) GetTopLinks(ctx context.Context, opts *ListOptions, names ...string) (*Links, *Response, error) { - return s.getLinks(ctx, sortTop, opts, names...) +// If no subreddit are provided, then it runs the search against all those the client is subscribed to +func (s *SubredditServiceOp) GetTopLinks(ctx context.Context, opts *ListOptions, subreddits ...string) (*Links, *Response, error) { + return s.getLinks(ctx, sortTop, opts, subreddits...) } -type sticky int +// GetSticky1 returns the first stickied post on a subreddit (if it exists) +func (s *SubredditServiceOp) GetSticky1(ctx context.Context, name string) (*LinkAndComments, *Response, error) { + return s.getSticky(ctx, name, sticky1) +} -const ( - sticky1 sticky = iota + 1 - sticky2 -) +// GetSticky2 returns the second stickied post on a subreddit (if it exists) +func (s *SubredditServiceOp) GetSticky2(ctx context.Context, name string) (*LinkAndComments, *Response, error) { + return s.getSticky(ctx, name, 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) -// } - -// Subscribe subscribes to subreddits based on their name +// Subscribe subscribes to subreddits based on their names // Returns {} on success -func (s *SubredditServiceOp) Subscribe(ctx context.Context, names ...string) (*Response, error) { +func (s *SubredditServiceOp) Subscribe(ctx context.Context, subreddits ...string) (*Response, error) { form := url.Values{} form.Set("action", "sub") - form.Set("sr_name", strings.Join(names, ",")) + form.Set("sr_name", strings.Join(subreddits, ",")) return s.handleSubscription(ctx, form) } @@ -186,12 +176,12 @@ func (s *SubredditServiceOp) SubscribeByID(ctx context.Context, ids ...string) ( return s.handleSubscription(ctx, form) } -// Unsubscribe unsubscribes from subreddits +// Unsubscribe unsubscribes from subreddits based on their names // Returns {} on success -func (s *SubredditServiceOp) Unsubscribe(ctx context.Context, names ...string) (*Response, error) { +func (s *SubredditServiceOp) Unsubscribe(ctx context.Context, subreddits ...string) (*Response, error) { form := url.Values{} form.Set("action", "unsub") - form.Set("sr_name", strings.Join(names, ",")) + form.Set("sr_name", strings.Join(subreddits, ",")) return s.handleSubscription(ctx, form) } @@ -247,10 +237,10 @@ func (s *SubredditServiceOp) getSubreddits(ctx context.Context, path string, opt return l, resp, nil } -func (s *SubredditServiceOp) getLinks(ctx context.Context, sort sort, opts *ListOptions, names ...string) (*Links, *Response, error) { +func (s *SubredditServiceOp) getLinks(ctx context.Context, sort sort, opts *ListOptions, subreddits ...string) (*Links, *Response, error) { path := sorts[sort] - if len(names) > 0 { - path = fmt.Sprintf("r/%s/%s", strings.Join(names, "+"), sorts[sort]) + if len(subreddits) > 0 { + path = fmt.Sprintf("r/%s/%s", strings.Join(subreddits, "+"), sorts[sort]) } path, err := addOptions(path, opts) @@ -280,159 +270,31 @@ func (s *SubredditServiceOp) getLinks(ctx context.Context, sort sort, opts *List return l, resp, nil } -// getSticky returns one of the 2 stickied posts of the subreddit +// getSticky returns one of the 2 stickied posts of the subreddit (if they exist) // 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 -// } - -// var root []rootListing -// resp, err := s.client.Do(ctx, req, &root) -// if err != nil { -// return nil, resp, err -// } - -// // test, _ := json.MarshalIndent(root, "", " ") -// // fmt.Println(string(test)) - -// linkRoot := new(linkRoot) - -// link := root[0].Data.Children[0] -// byteValue, err := json.Marshal(link) -// if err != nil { -// return nil, resp, err -// } - -// err = json.Unmarshal(byteValue, linkRoot) -// if err != nil { -// return nil, resp, err -// } - -// // these are all the comments in the post -// comments := root[1].Data.Children - -// var commentsRoot []commentRoot -// byteValue, err = json.Marshal(comments) -// if err != nil { -// return nil, resp, err -// } - -// err = json.Unmarshal(byteValue, &commentsRoot) -// if err != nil { -// return nil, resp, err -// } - -// test, _ := json.MarshalIndent(commentsRoot, "", " ") -// fmt.Println(string(test)) - -// for _, comment := range commentsRoot { -// if string(comment.Data.RepliesRaw) == `""` { -// comment.Data.Replies = nil -// continue -// } - -// // var -// } - -// return commentsRoot, resp, nil -// } - -// func handleComments(comments []commentRoot) { -// for _, comment := range comments { -// if string(comment.Data.RepliesRaw) == `""` { -// comment.Data.Replies = nil -// continue -// } - -// } -// } - -// StreamLinks returns a channel that receives new submissions from the subreddits -// To stop the stream, simply send a bool value to the stop channel -func (s *SubredditServiceOp) StreamLinks(ctx context.Context, names ...string) (<-chan Link, chan<- bool, error) { - if len(names) == 0 { - return nil, nil, errors.New("must specify at least one subreddit") +func (s *SubredditServiceOp) getSticky(ctx context.Context, subreddit string, num sticky) (*LinkAndComments, *Response, error) { + type query struct { + Num sticky `url:"num"` } - submissionCh := make(chan Link) - stop := make(chan bool, 1) + path := fmt.Sprintf("r/%s/about/sticky", subreddit) + path, err := addOptions(path, query{num}) + if err != nil { + return nil, nil, err + } - go func() { - // todo: if the post with the before gets deleted, you keep getting 0 posts - var last *Timestamp - for { - select { - case <-stop: - close(submissionCh) - return - default: - sl, _, err := s.GetNewLinks(ctx, nil, names...) - if err != nil { - continue - } + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } - var newest *Timestamp - for i, submission := range sl.Links { - if i == 0 { - newest = submission.Created - } - if last == nil { - submissionCh <- submission - continue - } - if last.Before(*submission.Created) { - submissionCh <- submission - } - } - last = newest - } - <-time.After(time.Second * 3) - fmt.Println() - } - }() + root := new(LinkAndComments) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } - // go func() { - // var before string - // for { - // select { - // case <-stop: - // close(submissionCh) - // return - // default: - // sl, _, err := s.GetSubmissions(ctx, SortNew, &ListOptions{Before: before}, names...) - // if err != nil { - // continue - // } - // fmt.Printf("Received %d posts\n", len(sl.Submissions)) - - // if len(sl.Submissions) == 0 { - // continue - // } - - // for _, submission := range sl.Submissions { - // submissionCh <- submission - // } - // before = sl.Submissions[0].FullID - // } - // <-time.After(time.Second * 5) - // fmt.Println() - // } - // }() - - return submissionCh, stop, nil + return root, resp, nil } diff --git a/things.go b/things.go index d736403..8d2aa5e 100644 --- a/things.go +++ b/things.go @@ -1,6 +1,9 @@ package geddit -import "encoding/json" +import ( + "encoding/json" + "errors" +) const ( kindComment = "t1" @@ -40,6 +43,13 @@ var sorts = [...]string{ "comments", } +type sticky int + +const ( + sticky1 sticky = iota + 1 + sticky2 +) + type root struct { Kind string `json:"kind,omitempty"` Data interface{} `json:"data,omitempty"` @@ -97,6 +107,7 @@ func (l *Things) UnmarshalJSON(b []byte) error { for _, child := range children { byteValue, _ := json.Marshal(child) switch child["kind"] { + // todo: kindMore case kindComment: root := new(commentRoot) if err := json.Unmarshal(byteValue, root); err == nil && root.Data != nil { @@ -168,12 +179,27 @@ type Comment struct { CanGild bool `json:"can_gild"` NSFW bool `json:"over_18"` - // If a comment has no replies, its "replies" value is "", - // which the unmarshaler doesn't like - // So we capture this varying field in RepliesRaw, and then - // fill it in Replies - RepliesRaw json.RawMessage `json:"replies,omitempty"` - Replies []commentRoot `json:"-"` + Replies Replies `json:"replies"` +} + +// Replies are replies to a comment +type Replies []Comment + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (r *Replies) UnmarshalJSON(data []byte) error { + // if a comment has no replies, its "replies" field is set to "" + if string(data) == `""` { + return nil + } + + root := new(rootListing) + err := json.Unmarshal(data, root) + if err != nil { + return err + } + + *r = root.getComments().Comments + return nil } // Link is a submitted post on Reddit @@ -328,3 +354,34 @@ type CommentsLinksSubreddits struct { Links []Link `json:"links,omitempty"` Subreddits []Subreddit `json:"subreddits,omitempty"` } + +// LinkAndComments is a link and its comments +type LinkAndComments struct { + Link Link `json:"link,omitempty"` + Comments []Comment `json:"comments,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// When getting a sticky post, you get an array of 2 Listings +// The 1st one contains the single post in its children array +// The 2nd one contains the comments to the post +func (rl *LinkAndComments) UnmarshalJSON(data []byte) error { + var l []rootListing + + err := json.Unmarshal(data, &l) + if err != nil { + return err + } + + if len(l) < 2 { + return errors.New("unexpected json response when getting link") + } + + stickyLink := l[0].getLinks().Links[0] + stickyComments := l[1].getComments().Comments + + rl.Link = stickyLink + rl.Comments = stickyComments + + return nil +}