Move everything to new reddit/ folder

Signed-off-by: Vartan Benohanian <vartanbeno@gmail.com>
This commit is contained in:
Vartan Benohanian
2020-08-20 14:37:59 -04:00
parent cf5d6b89c1
commit f86a559d76
39 changed files with 108 additions and 108 deletions
+428
View File
@@ -0,0 +1,428 @@
package reddit
import (
"context"
"net/http"
"net/url"
)
// AccountService handles communication with the account
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_account
type AccountService struct {
client *Client
}
type rootSubredditKarma struct {
Kind string `json:"kind,omitempty"`
Data []SubredditKarma `json:"data,omitempty"`
}
// SubredditKarma holds user karma data for the subreddit.
type SubredditKarma struct {
Subreddit string `json:"sr"`
PostKarma int `json:"link_karma"`
CommentKarma int `json:"comment_karma"`
}
// Settings are the user's account settings.
// Some of the fields' descriptions are taken from:
// https://praw.readthedocs.io/en/latest/code_overview/other/preferences.html#praw.models.Preferences.update
type Settings struct {
// Control whose private messages you see.
// - "everyone": everyone except blocked users
// - "whitelisted": only trusted users
AcceptPrivateMessages *string `json:"accept_pms,omitempty"`
// Allow Reddit to use your activity on Reddit to show you more relevant advertisements.
ActivityRelevantAds *bool `json:"activity_relevant_ads,omitempty"`
// Allow reddit to log my outbound clicks for personalization.
AllowClickTracking *bool `json:"allow_clicktracking,omitempty"`
// Beta test features for reddit. By enabling, you will join r/beta immediately.
Beta *bool `json:"beta,omitempty"`
// Show me links I've recently viewed.
ShowRecentlyViewedPosts *bool `json:"clickgadget,omitempty"`
CollapseReadMessages *bool `json:"collapse_read_messages,omitempty"`
// Compress the post display (make them look more compact).
Compress *bool `json:"compress,omitempty"`
CredditAutorenew *bool `json:"creddit_autorenew,omitempty"`
// One of "confidence", "top", "new", "controversial", "old", "random", "qa", "live".
DefaultCommentSort *string `json:"default_comment_sort,omitempty"`
// Show additional details in the domain text when available,
// such as the source subreddit or the content authors url/name.
ShowDomainDetails *bool `json:"domain_details,omitempty"`
SendEmailDigests *bool `json:"email_digests,omitempty"`
SendMessagesAsEmails *bool `json:"email_messages,omitempty"`
UnsubscribeFromAllEmails *bool `json:"email_unsubscribe_all,omitempty"`
// Disable subreddits from displaying their custom themes.
DisableCustomThemes *bool `json:"enable_default_themes,omitempty"`
// One of "GLOBAL", "AR", "AU", "BG", "CA", "CL", "CO", "CZ", "FI", "GB", "GR", "HR", "HU",
// "IE", "IN", "IS", "JP", "MX", "MY", "NZ", "PH", "PL", "PR", "PT", "RO", "RS", "SE", "SG",
// "TH", "TR", "TW", "US", "US_AK", "US_AL", "US_AR", "US_AZ", "US_CA", "US_CO", "US_CT",
// "US_DC", "US_DE", "US_FL", "US_GA", "US_HI", "US_IA", "US_ID", "US_IL", "US_IN", "US_KS",
// "US_KY", "US_LA", "US_MA", "US_MD", "US_ME", "US_MI", "US_MN", "US_MO", "US_MS", "US_MT",
// "US_NC", "US_ND", "US_NE", "US_NH", "US_NJ", "US_NM", "US_NV", "US_NY", "US_OH", "US_OK",
// "US_OR", "US_PA", "US_RI", "US_SC", "US_SD", "US_TN", "US_TX", "US_UT", "US_VA", "US_VT",
// "US_WA", "US_WI", "US_WV", "US_WY".
Location *string `json:"geopopular,omitempty"`
HideAds *bool `json:"hide_ads,omitempty"`
// Don't allow search engines to index my user profile.
HideFromSearchEngines *bool `json:"hide_from_robots,omitempty"`
// Dont show me posts after Ive upvoted them, except my own.
HideUpvotedPosts *bool `json:"hide_ups,omitempty"`
// Dont show me posts after Ive downvoted them, except my own.
HideDownvotedPosts *bool `json:"hide_downs,omitempty"`
// Show a dagger (†) on comments voted controversial (one that's been
// upvoted and downvoted significantly).
HighlightControversialComments *bool `json:"highlight_controversial,omitempty"`
HighlightNewComments *bool `json:"highlight_new_comments,omitempty"`
// Ignore suggested sorts for specific threads/subreddits, like Q&As.
IgnoreSuggestedSorts *bool `json:"ignore_suggested_sort,omitempty"`
// Use new Reddit as my default experience.
// Use this to SET the setting.
UseNewReddit *bool `json:"in_redesign_beta,omitempty"`
// Use new Reddit as my default experience.
// Use this to GET the setting.
UsesNewReddit *bool `json:"design_beta,omitempty"`
// Label posts that are not safe for work (NSFW).
LabelNSFW *bool `json:"label_nsfw,omitempty"`
// A valid IETF language tag (underscore separated).
Language *string `json:"lang,omitempty"`
ShowOldSearchPage *bool `json:"legacy_search,omitempty"`
// Send message notifications in my browser.
EnableNotifications *bool `json:"live_orangereds,omitempty"`
MarkMessagesAsRead *bool `json:"mark_messages_read,omitempty"`
// Determine whether to show thumbnails next to posts in subreddits.
// - "on": show thumbnails next to posts
// - "off": do not show thumbnails next to posts
// - "subreddit": show thumbnails next to posts based on the subreddit's preferences
ShowThumbnails *string `json:"media,omitempty"`
// Determine whether to auto-expand media in subreddits.
// - "on": auto-expand media previews
// - "off": do not auto-expand media previews
// - "subreddit": auto-expand media previews based on the subreddit's preferences
AutoExpandMedia *string `json:"media_preview,omitempty"`
// Don't show me comments with a score less than this number.
// Must be between -100 and 100 (inclusive).
MinimumCommentScore *int `json:"min_comment_score,omitempty"`
// Don't show me posts with a score less than this number.
// Must be between -100 and 100 (inclusive).
MinimumPostScore *int `json:"min_link_score,omitempty"`
// Notify me when people say my username.
EnableMentionNotifications *bool `json:"monitor_mentions,omitempty"`
// Opens link in a new window/tab.
OpenLinksInNewWindow *bool `json:"newwindow,omitempty"`
DarkMode *bool `json:"nightmode,omitempty"`
DisableProfanity *bool `json:"no_profanity,omitempty"`
// Display this many comments by default.
// Must be between 1 and 500 (inclusive).
NumberOfComments *int `json:"num_comments,omitempty,omitempty"`
// Display this many posts by default.
// Must be between 1 and 100 (inclusive).
NumberOfPosts *int `json:"numsites,omitempty,omitempty"`
// Show the spotlight box on the home feed.
// Not sure what this is though...
ShowSpotlightBox *bool `json:"organic,omitempty"`
SubredditTheme *string `json:"other_theme,omitempty"`
// Show content that is labeled not safe for work (NSFW).
ShowNSFW *bool `json:"over_18,omitempty"`
EnablePrivateRSSFeeds *bool `json:"private_feeds,omitempty"`
// View user profiles on desktop using legacy mode.
ProfileOptOut *bool `json:"profile_opt_out,omitempty"`
// Make my upvotes and downvotes public.
PublicizeVotes *bool `json:"public_votes,omitempty"`
// Allow my data to be used for research purposes.
AllowResearch *bool `json:"research,omitempty"`
IncludeNSFWSearchResults *bool `json:"search_include_over_18,omitempty"`
// Receive a message when my post gets cross-posted.
ReceiveCrosspostMessages *bool `json:"send_crosspost_messages,omitempty"`
// Receive welcome messages from moderators when I join a community.
ReceiveWelcomeMessages *bool `json:"send_welcome_messages,omitempty"`
// Show a user's flair (next to their name on a post or comment).
ShowUserFlair *bool `json:"show_flair,omitempty"`
// Show a post's flair.
ShowPostFlair *bool `json:"show_link_flair,omitempty"`
// Show how much gold you have remaining on your profile.
ShowGoldExpiration *bool `json:"show_gold_expiration,omitempty"`
ShowLocationBasedRecommendations *bool `json:"show_location_based_recommendations,omitempty"`
ShowPromote *bool `json:"show_promote,omitempty"`
ShowCustomSubredditThemes *bool `json:"show_stylesheets,omitempty"`
// Show trending subreddits on the home feed.
ShowTrendingSubreddits *bool `json:"show_trending,omitempty"`
ShowTwitter *bool `json:"show_twitter,omitempty"`
// Store whether or not you want to track posts you've visited.
StoreVisits *bool `json:"store_visits,omitempty"`
ThemeSelector *string `json:"theme_selector,omitempty"`
// Allow Reddit to use data provided by third-parties to show you more relevant advertisements on Reddit.i
AllowThirdPartyDataAdPersonalization *bool `json:"third_party_data_personalized_ads,omitempty"`
// Allow personalization of advertisements using data from third-party websites.
AllowThirdPartySiteDataAdPersonalization *bool `json:"third_party_site_data_personalized_ads,omitempty"`
// Allow personalization of content using data from third-party websites.
AllowThirdPartySiteDataContentPersonalization *bool `json:"third_party_site_data_personalized_content,omitempty"`
EnableThreadedMessages *bool `json:"threaded_messages,omitempty"`
EnableThreadedModmail *bool `json:"threaded_modmail,omitempty"`
// Show the communities you are active in on your profile (mobile only).
TopKarmaSubreddits *bool `json:"top_karma_subreddits,omitempty"`
UseGlobalDefaults *bool `json:"use_global_defaults,omitempty"`
EnableVideoAutoplay *bool `json:"video_autoplay,omitempty"`
}
type rootRelationshipList struct {
Kind string `json:"kind,omitempty"`
Data struct {
Relationships []Relationship `json:"children"`
} `json:"data"`
}
// Info returns some general information about your account.
func (s *AccountService) Info(ctx context.Context) (*User, *Response, error) {
path := "api/v1/me"
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(User)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// Karma returns a breakdown of your karma per subreddit.
func (s *AccountService) Karma(ctx context.Context) ([]SubredditKarma, *Response, error) {
path := "api/v1/me/karma"
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(rootSubredditKarma)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Data, resp, nil
}
// Settings returns your account settings.
func (s *AccountService) Settings(ctx context.Context) (*Settings, *Response, error) {
path := "api/v1/me/prefs"
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(Settings)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// UpdateSettings updates your account settings and returns the modified version.
func (s *AccountService) UpdateSettings(ctx context.Context, settings *Settings) (*Settings, *Response, error) {
path := "api/v1/me/prefs"
req, err := s.client.NewRequest(http.MethodPatch, path, settings)
if err != nil {
return nil, nil, err
}
root := new(Settings)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// Trophies returns a list of your trophies.
func (s *AccountService) Trophies(ctx context.Context) ([]Trophy, *Response, error) {
path := "api/v1/me/trophies"
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(rootTrophyListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
var trophies []Trophy
for _, trophy := range root.Data.Trophies {
if trophy.Data != nil {
trophies = append(trophies, *trophy.Data)
}
}
return trophies, resp, nil
}
// Friends returns a list of your friends.
func (s *AccountService) Friends(ctx context.Context) ([]Relationship, *Response, error) {
path := "prefs/friends"
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := make([]rootRelationshipList, 2)
resp, err := s.client.Do(ctx, req, &root)
if err != nil {
return nil, resp, err
}
return root[0].Data.Relationships, resp, nil
}
// Blocked returns a list of your blocked users.
func (s *AccountService) Blocked(ctx context.Context) ([]Relationship, *Response, error) {
path := "prefs/blocked"
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(rootRelationshipList)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Data.Relationships, resp, nil
}
// Messaging returns blocked users and trusted users, respectively.
func (s *AccountService) Messaging(ctx context.Context) ([]Relationship, []Relationship, *Response, error) {
path := "prefs/messaging"
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, nil, err
}
root := make([]rootRelationshipList, 2)
resp, err := s.client.Do(ctx, req, &root)
if err != nil {
return nil, nil, resp, err
}
blocked := root[0].Data.Relationships
trusted := root[1].Data.Relationships
return blocked, trusted, resp, nil
}
// Trusted returns a list of your trusted users.
func (s *AccountService) Trusted(ctx context.Context) ([]Relationship, *Response, error) {
path := "prefs/trusted"
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(rootRelationshipList)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Data.Relationships, resp, nil
}
// AddTrusted adds a user to your trusted users.
// This is not visible in the Reddit API docs.
func (s *AccountService) AddTrusted(ctx context.Context, username string) (*Response, error) {
path := "api/add_whitelisted"
form := url.Values{}
form.Set("api_type", "json")
form.Set("name", username)
// todo: you can also do this with the user id. form.Set("id", id). should we? or is this enough?
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// RemoveTrusted removes a user from your trusted users.
// This is not visible in the Reddit API docs.
func (s *AccountService) RemoveTrusted(ctx context.Context, username string) (*Response, error) {
path := "api/remove_whitelisted"
form := url.Values{}
form.Set("name", username)
// todo: you can also do this with the user id. form.Set("id", id). should we? or is this enough?
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
+324
View File
@@ -0,0 +1,324 @@
package reddit
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var expectedInfo = &User{
ID: "164ab8",
Name: "v_95",
Created: &Timestamp{time.Date(2017, 3, 12, 4, 56, 47, 0, time.UTC)},
PostKarma: 488,
CommentKarma: 22223,
HasVerifiedEmail: true,
NSFW: true,
}
var expectedKarma = []SubredditKarma{
{Subreddit: "nba", PostKarma: 144, CommentKarma: 21999},
{Subreddit: "redditdev", PostKarma: 19, CommentKarma: 4},
{Subreddit: "test", PostKarma: 1, CommentKarma: 0},
{Subreddit: "golang", PostKarma: 1, CommentKarma: 0},
}
var expectedSettings = &Settings{
AcceptPrivateMessages: String("everyone"),
ActivityRelevantAds: Bool(false),
AllowClickTracking: Bool(false),
Beta: Bool(false),
ShowRecentlyViewedPosts: Bool(true),
CollapseReadMessages: Bool(false),
Compress: Bool(false),
CredditAutorenew: nil,
DefaultCommentSort: String("top"),
ShowDomainDetails: Bool(false),
SendEmailDigests: Bool(false),
SendMessagesAsEmails: Bool(false),
UnsubscribeFromAllEmails: Bool(true),
DisableCustomThemes: Bool(false),
Location: String("GLOBAL"),
HideAds: Bool(false),
HideFromSearchEngines: Bool(false),
HideUpvotedPosts: Bool(false),
HideDownvotedPosts: Bool(false),
HighlightControversialComments: Bool(true),
HighlightNewComments: Bool(true),
IgnoreSuggestedSorts: Bool(true),
UseNewReddit: nil,
UsesNewReddit: Bool(false),
LabelNSFW: Bool(true),
Language: String("en-ca"),
ShowOldSearchPage: Bool(false),
EnableNotifications: Bool(true),
MarkMessagesAsRead: Bool(true),
ShowThumbnails: String("subreddit"),
AutoExpandMedia: String("off"),
MinimumCommentScore: nil,
MinimumPostScore: nil,
EnableMentionNotifications: Bool(true),
OpenLinksInNewWindow: Bool(true),
DarkMode: Bool(true),
DisableProfanity: Bool(false),
NumberOfComments: Int(200),
NumberOfPosts: Int(25),
ShowSpotlightBox: nil,
SubredditTheme: nil,
ShowNSFW: Bool(true),
EnablePrivateRSSFeeds: Bool(true),
ProfileOptOut: Bool(false),
PublicizeVotes: Bool(false),
AllowResearch: Bool(false),
IncludeNSFWSearchResults: Bool(true),
ReceiveCrosspostMessages: Bool(false),
ReceiveWelcomeMessages: Bool(true),
ShowUserFlair: Bool(true),
ShowPostFlair: Bool(true),
ShowGoldExpiration: Bool(false),
ShowLocationBasedRecommendations: Bool(false),
ShowPromote: nil,
ShowCustomSubredditThemes: Bool(true),
ShowTrendingSubreddits: Bool(true),
ShowTwitter: Bool(false),
StoreVisits: Bool(false),
ThemeSelector: nil,
AllowThirdPartyDataAdPersonalization: Bool(false),
AllowThirdPartySiteDataAdPersonalization: Bool(false),
AllowThirdPartySiteDataContentPersonalization: Bool(false),
EnableThreadedMessages: Bool(true),
EnableThreadedModmail: Bool(false),
TopKarmaSubreddits: Bool(false),
UseGlobalDefaults: Bool(false),
EnableVideoAutoplay: Bool(true),
}
var expectedRelationships = []Relationship{
{
ID: "r9_1r4879",
User: "test1",
UserID: "t2_test1",
Created: &Timestamp{time.Date(2020, 6, 28, 16, 43, 55, 0, time.UTC)},
},
{
ID: "r9_1re930",
User: "test2",
UserID: "t2_test2",
Created: &Timestamp{time.Date(2020, 6, 28, 16, 44, 2, 0, time.UTC)},
},
}
var expectedRelationships2 = []Relationship{
{
ID: "r9_1re60i",
User: "test3",
UserID: "t2_test3",
Created: &Timestamp{time.Date(2020, 3, 6, 2, 27, 0, 0, time.UTC)},
},
}
func TestAccountService_Info(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/account/info.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/me", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
info, _, err := client.Account.Info(ctx)
require.NoError(t, err)
require.Equal(t, expectedInfo, info)
}
func TestAccountService_Karma(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/account/karma.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/me/karma", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
karma, _, err := client.Account.Karma(ctx)
require.NoError(t, err)
require.Equal(t, expectedKarma, karma)
}
func TestAccountService_Settings(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/account/settings.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/me/prefs", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
settings, _, err := client.Account.Settings(ctx)
require.NoError(t, err)
require.Equal(t, expectedSettings, settings)
}
func TestAccountService_UpdateSettings(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/account/settings.json")
require.NoError(t, err)
expectedSettingsBody := &Settings{NumberOfPosts: Int(10), MinimumCommentScore: Int(5), Compress: Bool(true)}
mux.HandleFunc("/api/v1/me/prefs", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPatch, r.Method)
settingsBody := new(Settings)
err := json.NewDecoder(r.Body).Decode(settingsBody)
require.NoError(t, err)
require.Equal(t, expectedSettingsBody, settingsBody)
fmt.Fprint(w, blob)
})
settings, _, err := client.Account.UpdateSettings(ctx, expectedSettingsBody)
require.NoError(t, err)
require.Equal(t, expectedSettings, settings)
}
func TestAccountService_Trophies(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/account/trophies.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/me/trophies", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
trophies, _, err := client.Account.Trophies(ctx)
require.NoError(t, err)
require.Equal(t, expectedTrophies, trophies)
}
func TestAccountService_Friends(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/account/friends.json")
require.NoError(t, err)
mux.HandleFunc("/prefs/friends", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
relationships, _, err := client.Account.Friends(ctx)
require.NoError(t, err)
require.Equal(t, expectedRelationships, relationships)
}
func TestAccountService_Blocked(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/account/blocked.json")
require.NoError(t, err)
mux.HandleFunc("/prefs/blocked", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
relationships, _, err := client.Account.Blocked(ctx)
require.NoError(t, err)
require.Equal(t, expectedRelationships, relationships)
}
func TestAccountService_Messaging(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/account/messaging.json")
require.NoError(t, err)
mux.HandleFunc("/prefs/messaging", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
blocked, trusted, _, err := client.Account.Messaging(ctx)
require.NoError(t, err)
require.Equal(t, expectedRelationships, blocked)
require.Equal(t, expectedRelationships2, trusted)
}
func TestAccountService_Trusted(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/account/trusted.json")
require.NoError(t, err)
mux.HandleFunc("/prefs/trusted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
relationships, _, err := client.Account.Trusted(ctx)
require.NoError(t, err)
require.Equal(t, expectedRelationships, relationships)
}
func TestAccountService_AddTrusted(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/add_whitelisted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("api_type", "json")
form.Set("name", "test123")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Account.AddTrusted(ctx, "test123")
require.NoError(t, err)
}
func TestAccountService_RemoveTrusted(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/remove_whitelisted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("name", "test123")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Account.RemoveTrusted(ctx, "test123")
require.NoError(t, err)
}
+288
View File
@@ -0,0 +1,288 @@
package reddit
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"github.com/google/go-querystring/query"
)
// CollectionService handles communication with the collection
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_collections
type CollectionService struct {
client *Client
}
// Collection is a mod curated group of posts within a subreddit.
type Collection struct {
ID string `json:"collection_id,omitempty"`
Created *Timestamp `json:"created_at_utc,omitempty"`
Updated *Timestamp `json:"last_update_utc,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Permalink string `json:"permalink,omitempty"`
Layout string `json:"display_layout,omitempty"`
SubredditID string `json:"subreddit_id,omitempty"`
Author string `json:"author_name,omitempty"`
AuthorID string `json:"author_id,omitempty"`
// Post at the top of the collection.
// This does not appear when getting a list of collections.
PrimaryPostID string `json:"primary_link_id,omitempty"`
PostIDs []string `json:"link_ids,omitempty"`
}
// CollectionCreateRequest represents a request to create a collection.
type CollectionCreateRequest struct {
Title string `url:"title"`
Description string `url:"description,omitempty"`
SubredditID string `url:"sr_fullname"`
// One of: TIMELINE, GALLERY.
Layout string `url:"display_layout,omitempty"`
}
// Get gets a collection by its ID.
func (s *CollectionService) Get(ctx context.Context, id string) (*Collection, *Response, error) {
path := "api/v1/collections/collection"
type params struct {
ID string `url:"collection_id"`
IncludePosts bool `url:"include_links"`
}
path, err := addOptions(path, params{id, false})
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
collection := new(Collection)
resp, err := s.client.Do(ctx, req, collection)
if err != nil {
return nil, resp, err
}
return collection, resp, nil
}
// FromSubreddit gets all collections in the subreddit.
func (s *CollectionService) FromSubreddit(ctx context.Context, id string) ([]*Collection, *Response, error) {
path := "api/v1/collections/subreddit_collections"
type params struct {
SubredditID string `url:"sr_fullname"`
}
path, err := addOptions(path, params{id})
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
var collections []*Collection
resp, err := s.client.Do(ctx, req, &collections)
if err != nil {
return nil, resp, err
}
return collections, resp, nil
}
// Create creates a collection.
func (s *CollectionService) Create(ctx context.Context, createRequest *CollectionCreateRequest) (*Collection, *Response, error) {
if createRequest == nil {
return nil, nil, errors.New("createRequest: cannot be nil")
}
path := "api/v1/collections/create_collection"
form, err := query.Values(createRequest)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, nil, err
}
collection := new(Collection)
resp, err := s.client.Do(ctx, req, collection)
if err != nil {
return nil, resp, err
}
return collection, resp, nil
}
// Delete deletes a collection via its id.
func (s *CollectionService) Delete(ctx context.Context, id string) (*Response, error) {
path := "api/v1/collections/delete_collection"
form := url.Values{}
form.Set("collection_id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// AddPost adds a post (via its full ID) to a collection (via its id).
func (s *CollectionService) AddPost(ctx context.Context, postID, collectionID string) (*Response, error) {
path := "api/v1/collections/add_post_to_collection"
form := url.Values{}
form.Set("link_fullname", postID)
form.Set("collection_id", collectionID)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// RemovePost removes a post (via its full ID) from a collection (via its id).
func (s *CollectionService) RemovePost(ctx context.Context, postID, collectionID string) (*Response, error) {
path := "api/v1/collections/remove_post_in_collection"
form := url.Values{}
form.Set("link_fullname", postID)
form.Set("collection_id", collectionID)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// ReorderPosts reorders posts in a collection.
func (s *CollectionService) ReorderPosts(ctx context.Context, collectionID string, postIDs ...string) (*Response, error) {
path := "api/v1/collections/reorder_collection"
form := url.Values{}
form.Set("collection_id", collectionID)
form.Set("link_ids", strings.Join(postIDs, ","))
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// UpdateTitle updates a collection's title.
func (s *CollectionService) UpdateTitle(ctx context.Context, id string, title string) (*Response, error) {
path := "api/v1/collections/update_collection_title"
form := url.Values{}
form.Set("collection_id", id)
form.Set("title", title)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// UpdateDescription updates a collection's description.
func (s *CollectionService) UpdateDescription(ctx context.Context, id string, description string) (*Response, error) {
path := "api/v1/collections/update_collection_description"
form := url.Values{}
form.Set("collection_id", id)
form.Set("description", description)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// UpdateLayoutTimeline updates a collection's layout to the timeline format.
func (s *CollectionService) UpdateLayoutTimeline(ctx context.Context, id string) (*Response, error) {
path := "api/v1/collections/update_collection_display_layout"
form := url.Values{}
form.Set("collection_id", id)
form.Set("display_layout", "TIMELINE")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// UpdateLayoutGallery updates a collection's layout to the gallery format.
func (s *CollectionService) UpdateLayoutGallery(ctx context.Context, id string) (*Response, error) {
path := "api/v1/collections/update_collection_display_layout"
form := url.Values{}
form.Set("collection_id", id)
form.Set("display_layout", "GALLERY")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Follow follows a collection.
func (s *CollectionService) Follow(ctx context.Context, id string) (*Response, error) {
path := "api/v1/collections/follow_collection"
form := url.Values{}
form.Set("collection_id", id)
form.Set("follow", "true")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Unfollow unfollows a collection.
func (s *CollectionService) Unfollow(ctx context.Context, id string) (*Response, error) {
path := "api/v1/collections/follow_collection"
form := url.Values{}
form.Set("collection_id", id)
form.Set("follow", "false")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
+355
View File
@@ -0,0 +1,355 @@
package reddit
import (
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var expectedCollection = &Collection{
ID: "37f1e52d-7ec9-466b-b4cc-59e86e071ed7",
Created: &Timestamp{time.Date(2020, 8, 6, 23, 25, 3, 0, time.UTC)},
Updated: &Timestamp{time.Date(2020, 8, 7, 1, 59, 32, 0, time.UTC)},
Title: "Test Title",
Permalink: "https://www.reddit.com/r/helloworldtestt/collection/37f1e52d-7ec9-466b-b4cc-59e86e071ed7",
Layout: "TIMELINE",
SubredditID: "t5_2uquw1",
Author: "v_95",
AuthorID: "t2_164ab8",
PrimaryPostID: "t3_hs0cyh",
PostIDs: []string{"t3_hs0cyh", "t3_hqrg8s", "t3_hs03f3"},
}
var expectedCollections = []*Collection{
{
ID: "37f1e52d-7ec9-466b-b4cc-59e86e071ed7",
Created: &Timestamp{time.Date(2020, 8, 6, 23, 25, 3, 0, time.UTC)},
Updated: &Timestamp{time.Date(2020, 8, 7, 1, 59, 32, 0, time.UTC)},
Title: "Test Title",
Permalink: "https://www.reddit.com/r/helloworldtestt/collection/37f1e52d-7ec9-466b-b4cc-59e86e071ed7",
Layout: "TIMELINE",
SubredditID: "t5_2uquw1",
Author: "v_95",
AuthorID: "t2_164ab8",
PostIDs: []string{"t3_hs0cyh", "t3_hqrg8s", "t3_hs03f3"},
},
{
ID: "8e94db00-6605-46c6-b0d2-44653d6f538c",
Created: &Timestamp{time.Date(2020, 8, 7, 0, 56, 29, 0, time.UTC)},
Updated: &Timestamp{time.Date(2020, 8, 7, 1, 59, 27, 0, time.UTC)},
Title: "Test Title 2",
Description: "Test Description",
Permalink: "https://www.reddit.com/r/helloworldtestt/collection/8e94db00-6605-46c6-b0d2-44653d6f538c",
SubredditID: "t5_2uquw1",
Author: "v_95",
AuthorID: "t2_164ab8",
PostIDs: []string{},
},
{
ID: "a1b3e088-f6b8-4d98-9e93-adaacef113cd",
Created: &Timestamp{time.Date(2020, 8, 7, 0, 55, 24, 0, time.UTC)},
Updated: &Timestamp{time.Date(2020, 8, 7, 0, 55, 24, 0, time.UTC)},
Title: "Test Title 3",
Permalink: "https://www.reddit.com/r/helloworldtestt/collection/a1b3e088-f6b8-4d98-9e93-adaacef113cd",
SubredditID: "t5_2uquw1",
Author: "v_95",
AuthorID: "t2_164ab8",
PostIDs: []string{},
},
}
func TestCollectionService_Get(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/collection/collection.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/collections/collection", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
form.Set("include_links", "false")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
collection, _, err := client.Collection.Get(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
require.NoError(t, err)
require.Equal(t, expectedCollection, collection)
}
func TestCollectionService_FromSubreddit(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/collection/collections.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/collections/subreddit_collections", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("sr_fullname", "t5_2uquw1")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
collections, _, err := client.Collection.FromSubreddit(ctx, "t5_2uquw1")
require.NoError(t, err)
require.Equal(t, expectedCollections, collections)
}
func TestCollectionService_Create(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/collection/collection.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/collections/create_collection", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("title", "Test Title")
form.Set("sr_fullname", "t5_2uquw1")
form.Set("display_layout", "TIMELINE")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
_, _, err = client.Collection.Create(ctx, nil)
require.EqualError(t, err, "createRequest: cannot be nil")
collection, _, err := client.Collection.Create(ctx, &CollectionCreateRequest{
Title: "Test Title",
SubredditID: "t5_2uquw1",
Layout: "TIMELINE",
})
require.NoError(t, err)
require.Equal(t, expectedCollection, collection)
}
func TestCollectionService_Delete(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/collections/delete_collection", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Collection.Delete(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
require.NoError(t, err)
}
func TestCollectionService_AddPost(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/collections/add_post_to_collection", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("link_fullname", "t3_hs03f3")
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Collection.AddPost(ctx, "t3_hs03f3", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
require.NoError(t, err)
}
func TestCollectionService_RemovePost(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/collections/remove_post_in_collection", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("link_fullname", "t3_hs03f3")
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Collection.RemovePost(ctx, "t3_hs03f3", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
require.NoError(t, err)
}
func TestCollectionService_ReorderPosts(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/collections/reorder_collection", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
form.Set("link_ids", "t3_hs0cyh,t3_hqrg8s,t3_hs03f3")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Collection.ReorderPosts(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7", "t3_hs0cyh", "t3_hqrg8s", "t3_hs03f3")
require.NoError(t, err)
}
func TestCollectionService_UpdateTitle(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/collections/update_collection_title", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
form.Set("title", "Test Title")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Collection.UpdateTitle(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7", "Test Title")
require.NoError(t, err)
}
func TestCollectionService_UpdateDescription(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/collections/update_collection_description", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
form.Set("description", "Test Description")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Collection.UpdateDescription(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7", "Test Description")
require.NoError(t, err)
}
func TestCollectionService_UpdateLayoutTimeline(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/collections/update_collection_display_layout", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
form.Set("display_layout", "TIMELINE")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Collection.UpdateLayoutTimeline(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
require.NoError(t, err)
}
func TestCollectionService_UpdateLayoutGallery(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/collections/update_collection_display_layout", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
form.Set("display_layout", "GALLERY")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Collection.UpdateLayoutGallery(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
require.NoError(t, err)
}
func TestCollectionService_Follow(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/collections/follow_collection", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
form.Set("follow", "true")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Collection.Follow(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
require.NoError(t, err)
}
func TestCollectionService_Unfollow(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/collections/follow_collection", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
form.Set("follow", "false")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Collection.Unfollow(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7")
require.NoError(t, err)
}
+122
View File
@@ -0,0 +1,122 @@
package reddit
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
)
// CommentService handles communication with the comment
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_links_and_comments
type CommentService struct {
*postAndCommentService
client *Client
}
// Submit submits a comment as a reply to a post, comment, or message.
// parentID is the full ID of the thing being replied to.
func (s *CommentService) Submit(ctx context.Context, parentID 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", parentID)
form.Set("text", text)
req, err := s.client.NewRequestWithForm(http.MethodPost, 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 a comment.
func (s *CommentService) Edit(ctx context.Context, id string, text string) (*Comment, *Response, error) {
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.NewRequestWithForm(http.MethodPost, 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
}
// LoadMoreReplies retrieves more replies that were left out when initially fetching the comment.
func (s *CommentService) LoadMoreReplies(ctx context.Context, comment *Comment) (*Response, error) {
if comment == nil {
return nil, errors.New("comment: cannot be nil")
}
if !comment.HasMore() {
return nil, nil
}
postID := comment.PostID
commentIDs := comment.Replies.More.Children
form := url.Values{}
form.Set("api_type", "json")
form.Set("link_id", postID)
form.Set("children", strings.Join(commentIDs, ","))
path := "api/morechildren"
// This was originally a GET, but with POST you can send a bigger payload
// since it's in the body and not the URI.
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
root := new(struct {
JSON struct {
Data struct {
Things things `json:"things"`
} `json:"data"`
} `json:"json"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return resp, err
}
comments := root.JSON.Data.Things.Comments
mores := root.JSON.Data.Things.Mores
for _, c := range comments {
comment.addCommentToReplies(c)
}
if len(mores) > 0 {
comment.Replies.More = mores[0]
} else {
comment.Replies.More = nil
}
return resp, nil
}
+349
View File
@@ -0,0 +1,349 @@
package reddit
import (
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var expectedCommentSubmitOrEdit = &Comment{
ID: "test2",
FullID: "t1_test2",
ParentID: "t1_test",
Permalink: "/r/subreddit/comments/test1/some_thread/test2/",
Body: "test comment",
Author: "reddit_username",
AuthorID: "t2_user1",
AuthorFlairText: "Flair",
AuthorFlairID: "024b2b66-05ca-11e1-96f4-12313d096aae",
SubredditName: "subreddit",
SubredditNamePrefixed: "r/subreddit",
SubredditID: "t5_test",
Likes: Bool(true),
Score: 1,
Controversiality: 0,
Created: &Timestamp{time.Date(2020, 4, 29, 0, 9, 47, 0, time.UTC)},
// todo: this should just be nil
Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)},
PostID: "t3_link1",
}
func TestCommentService_Submit(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/comment/submit-or-edit.json")
require.NoError(t, err)
mux.HandleFunc("/api/comment", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("api_type", "json")
form.Set("return_rtjson", "true")
form.Set("parent", "t1_test")
form.Set("text", "test comment")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
fmt.Fprint(w, blob)
})
comment, _, err := client.Comment.Submit(ctx, "t1_test", "test comment")
require.NoError(t, err)
require.Equal(t, expectedCommentSubmitOrEdit, comment)
}
func TestCommentService_Edit(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/comment/submit-or-edit.json")
require.NoError(t, err)
mux.HandleFunc("/api/editusertext", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("api_type", "json")
form.Set("return_rtjson", "true")
form.Set("thing_id", "t1_test")
form.Set("text", "test comment")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
fmt.Fprint(w, blob)
})
comment, _, err := client.Comment.Edit(ctx, "t1_test", "test comment")
require.NoError(t, err)
require.Equal(t, expectedCommentSubmitOrEdit, comment)
}
func TestCommentService_Delete(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/del", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t1_test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
})
res, err := client.Comment.Delete(ctx, "t1_test")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestCommentService_Save(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/save", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t1_test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
})
res, err := client.Comment.Save(ctx, "t1_test")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestCommentService_Unsave(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/unsave", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t1_test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
})
res, err := client.Comment.Unsave(ctx, "t1_test")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestCommentService_EnableReplies(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/sendreplies", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t1_test")
form.Set("state", "true")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
})
res, err := client.Comment.EnableReplies(ctx, "t1_test")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestCommentService_DisableReplies(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/sendreplies", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t1_test")
form.Set("state", "false")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
})
res, err := client.Comment.DisableReplies(ctx, "t1_test")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestCommentService_Lock(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/lock", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t1_test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
})
res, err := client.Comment.Lock(ctx, "t1_test")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestCommentService_Unlock(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/unlock", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t1_test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
})
res, err := client.Comment.Unlock(ctx, "t1_test")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestCommentService_Upvote(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t1_test")
form.Set("dir", "1")
form.Set("rank", "10")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
})
res, err := client.Comment.Upvote(ctx, "t1_test")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestCommentService_Downvote(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t1_test")
form.Set("dir", "-1")
form.Set("rank", "10")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
})
res, err := client.Comment.Downvote(ctx, "t1_test")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestCommentService_RemoveVote(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t1_test")
form.Set("dir", "0")
form.Set("rank", "10")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
})
res, err := client.Comment.RemoveVote(ctx, "t1_test")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestCommentService_LoadMoreReplies(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/comment/more.json")
require.NoError(t, err)
mux.HandleFunc("/api/morechildren", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("link_id", "t3_123")
form.Set("children", "def,ghi,jkl")
form.Set("api_type", "json")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.PostForm)
fmt.Fprint(w, blob)
})
_, err = client.Comment.LoadMoreReplies(ctx, nil)
require.EqualError(t, err, "comment: cannot be nil")
resp, err := client.Comment.LoadMoreReplies(ctx, &Comment{})
require.Nil(t, resp)
require.Nil(t, err)
comment := &Comment{
FullID: "t1_abc",
PostID: "t3_123",
Replies: Replies{
More: &More{
Children: []string{"def", "ghi", "jkl"},
},
},
}
_, err = client.Comment.LoadMoreReplies(ctx, comment)
require.Nil(t, err)
require.False(t, comment.HasMore())
require.Len(t, comment.Replies.Comments, 2)
require.Len(t, comment.Replies.Comments[0].Replies.Comments, 1)
}
+285
View File
@@ -0,0 +1,285 @@
package reddit
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"strings"
"github.com/google/go-querystring/query"
"golang.org/x/net/context/ctxhttp"
)
// EmojiService handles communication with the emoji
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_emoji
type EmojiService struct {
client *Client
}
// Emoji is a graphic element you can include in a post flair or user flair.
type Emoji struct {
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
UserFlairAllowed bool `json:"user_flair_allowed,omitempty"`
PostFlairAllowed bool `json:"post_flair_allowed,omitempty"`
ModFlairOnly bool `json:"mod_flair_only,omitempty"`
// ID of the user who created this emoji.
CreatedBy string `json:"created_by,omitempty"`
}
// EmojiCreateOrUpdateRequest represents a request to create/update an emoji.
type EmojiCreateOrUpdateRequest struct {
Name string `url:"name"`
UserFlairAllowed *bool `url:"user_flair_allowed,omitempty"`
PostFlairAllowed *bool `url:"post_flair_allowed,omitempty"`
ModFlairOnly *bool `url:"mod_flair_only,omitempty"`
}
func (r *EmojiCreateOrUpdateRequest) validate() error {
if r.Name == "" {
return errors.New("name: cannot be empty")
}
return nil
}
type emojis []*Emoji
func (e *emojis) UnmarshalJSON(data []byte) (err error) {
emojiMap := make(map[string]json.RawMessage)
err = json.Unmarshal(data, &emojiMap)
if err != nil {
return
}
for emojiName, emojiValue := range emojiMap {
emoji := new(Emoji)
err = json.Unmarshal(emojiValue, emoji)
if err != nil {
return
}
emoji.Name = emojiName
*e = append(*e, emoji)
}
return
}
// Get returns the default set of Reddit emojis, and those of the subreddit, respectively.
func (s *EmojiService) Get(ctx context.Context, subreddit string) ([]*Emoji, []*Emoji, *Response, error) {
path := fmt.Sprintf("api/v1/%s/emojis/all", subreddit)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, nil, err
}
root := make(map[string]emojis)
resp, err := s.client.Do(ctx, req, &root)
if err != nil {
return nil, nil, resp, err
}
defaultEmojis := root["snoomojis"]
var subredditEmojis []*Emoji
for k, v := range root {
if strings.HasPrefix(k, kindSubreddit) {
subredditEmojis = v
break
}
}
return defaultEmojis, subredditEmojis, resp, nil
}
// Delete deletes the emoji from the subreddit.
func (s *EmojiService) Delete(ctx context.Context, subreddit string, emoji string) (*Response, error) {
path := fmt.Sprintf("api/v1/%s/emoji/%s", subreddit, emoji)
req, err := s.client.NewRequest(http.MethodDelete, path, nil)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// SetSize sets the custom emoji size in the subreddit.
// Both height and width must be between 1 and 40 (inclusive).
func (s *EmojiService) SetSize(ctx context.Context, subreddit string, height, width int) (*Response, error) {
path := fmt.Sprintf("api/v1/%s/emoji_custom_size", subreddit)
form := url.Values{}
form.Set("height", fmt.Sprint(height))
form.Set("width", fmt.Sprint(width))
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// DisableCustomSize disables the custom emoji size in the subreddit.
func (s *EmojiService) DisableCustomSize(ctx context.Context, subreddit string) (*Response, error) {
path := fmt.Sprintf("api/v1/%s/emoji_custom_size", subreddit)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, nil)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
func (s *EmojiService) lease(ctx context.Context, subreddit, imagePath string) (string, map[string]string, *Response, error) {
path := fmt.Sprintf("api/v1/%s/emoji_asset_upload_s3.json", subreddit)
form := url.Values{}
form.Set("filepath", imagePath)
form.Set("mimetype", "image/jpeg")
if strings.HasSuffix(strings.ToLower(imagePath), ".png") {
form.Set("mimetype", "image/png")
}
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return "", nil, nil, err
}
var response struct {
S3UploadLease struct {
Action string `json:"action"`
Fields []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"fields"`
} `json:"s3UploadLease"`
}
resp, err := s.client.Do(ctx, req, &response)
if err != nil {
return "", nil, resp, err
}
uploadURL := fmt.Sprintf("http:%s", response.S3UploadLease.Action)
fields := make(map[string]string)
for _, field := range response.S3UploadLease.Fields {
fields[field.Name] = field.Value
}
return uploadURL, fields, resp, nil
}
func (s *EmojiService) upload(ctx context.Context, subreddit string, createRequest *EmojiCreateOrUpdateRequest, awsKey string) (*Response, error) {
path := fmt.Sprintf("api/v1/%s/emoji.json", subreddit)
form, err := query.Values(createRequest)
if err != nil {
return nil, err
}
form.Set("s3_key", awsKey)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Upload uploads an emoji to the subreddit.
func (s *EmojiService) Upload(ctx context.Context, subreddit string, createRequest *EmojiCreateOrUpdateRequest, imagePath string) (*Response, error) {
if createRequest == nil {
return nil, errors.New("createRequest: cannot be nil")
}
err := createRequest.validate()
if err != nil {
return nil, err
}
uploadURL, fields, resp, err := s.lease(ctx, subreddit, imagePath)
if err != nil {
return resp, err
}
file, err := os.Open(imagePath)
if err != nil {
return nil, err
}
defer file.Close()
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
// AWS ignores all fields in the request that come after the file field, so we need to set these before
// https://stackoverflow.com/questions/15234496/upload-directly-to-amazon-s3-using-ajax-returning-error-bucket-post-must-contai/15235866#15235866
for k, v := range fields {
writer.WriteField(k, v)
}
part, err := writer.CreateFormFile("file", file.Name())
if err != nil {
return nil, err
}
_, err = io.Copy(part, file)
if err != nil {
return nil, err
}
err = writer.Close()
if err != nil {
return nil, err
}
httpResponse, err := ctxhttp.Post(ctx, nil, uploadURL, writer.FormDataContentType(), body)
if err != nil {
return nil, err
}
err = CheckResponse(httpResponse)
if err != nil {
return &Response{httpResponse}, err
}
return s.upload(ctx, subreddit, createRequest, fields["key"])
}
// Update updates an emoji on the subreddit.
func (s *EmojiService) Update(ctx context.Context, subreddit string, updateRequest *EmojiCreateOrUpdateRequest) (*Response, error) {
if updateRequest == nil {
return nil, errors.New("updateRequest: cannot be nil")
}
err := updateRequest.validate()
if err != nil {
return nil, err
}
path := fmt.Sprintf("api/v1/%s/emoji_permissions", subreddit)
form, err := query.Values(updateRequest)
if err != nil {
return nil, err
}
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
+236
View File
@@ -0,0 +1,236 @@
package reddit
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"testing"
"github.com/stretchr/testify/require"
)
var expectedDefaultEmojis = []*Emoji{
{
Name: "cake",
URL: "https://emoji.redditmedia.com/46kel8lf1guz_t5_3nqvj/cake",
UserFlairAllowed: true,
PostFlairAllowed: true,
ModFlairOnly: false,
CreatedBy: "t2_6zfp6ii",
},
{
Name: "cat_blep",
URL: "https://emoji.redditmedia.com/p9sxc1zh1guz_t5_3nqvj/cat_blep",
UserFlairAllowed: true,
PostFlairAllowed: true,
ModFlairOnly: false,
CreatedBy: "t2_6zfp6ii",
},
}
var expectedSubredditEmojis = []*Emoji{
{
Name: "TestEmoji",
URL: "https://emoji.redditmedia.com/fxe5a674hpf51_t5_2uquw1/TestEmoji",
UserFlairAllowed: true,
PostFlairAllowed: true,
ModFlairOnly: false,
CreatedBy: "t2_164ab8",
},
}
func TestEmojiService_Get(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/emoji/emojis.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/test/emojis/all", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
defaultEmojis, subredditEmojis, _, err := client.Emoji.Get(ctx, "test")
require.NoError(t, err)
require.Len(t, defaultEmojis, 2)
require.Contains(t, expectedDefaultEmojis, defaultEmojis[0])
require.Contains(t, expectedDefaultEmojis, defaultEmojis[1])
require.Equal(t, expectedSubredditEmojis, subredditEmojis)
}
func TestEmojiService_Delete(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/testsubreddit/emoji/testemoji", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodDelete, r.Method)
})
_, err := client.Emoji.Delete(ctx, "testsubreddit", "testemoji")
require.NoError(t, err)
}
func TestEmojiService_SetSize(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/testsubreddit/emoji_custom_size", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("height", "20")
form.Set("width", "20")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Emoji.SetSize(ctx, "testsubreddit", 20, 20)
require.NoError(t, err)
}
func TestEmojiService_DisableCustomSize(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/testsubreddit/emoji_custom_size", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Emoji.DisableCustomSize(ctx, "testsubreddit")
require.NoError(t, err)
}
func TestEmojiService_Upload(t *testing.T) {
setup()
defer teardown()
u, err := url.Parse(server.URL)
require.NoError(t, err)
uploadURL := u.Host + "/api/emoji_upload"
blob, err := readFileContents("../testdata/emoji/lease.json")
require.NoError(t, err)
blob = fmt.Sprintf(blob, uploadURL)
emojiFile, err := ioutil.TempFile("/tmp", "emoji*.png")
require.NoError(t, err)
defer func() {
emojiFile.Close()
os.Remove(emojiFile.Name())
}()
_, err = emojiFile.WriteString("this is a test")
require.NoError(t, err)
mux.HandleFunc("/api/v1/testsubreddit/emoji_asset_upload_s3.json", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("filepath", emojiFile.Name())
form.Set("mimetype", "image/png")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
mux.HandleFunc("/api/emoji_upload", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
_, file, err := r.FormFile("file")
require.NoError(t, err)
rdr, err := file.Open()
require.NoError(t, err)
buf := new(bytes.Buffer)
_, err = io.Copy(buf, rdr)
require.NoError(t, err)
require.Equal(t, "this is a test", buf.String())
form := url.Values{}
form.Set("key", "t5_2uquw1/t2_164ab8/a94a8f45ccb199a61c4c0873d391e98c982fabd3")
form.Set("test name", "test value")
// for some reason this has to come after the FormFile call
err = r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
mux.HandleFunc("/api/v1/testsubreddit/emoji.json", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("name", "testemoji")
form.Set("user_flair_allowed", "false")
form.Set("post_flair_allowed", "true")
form.Set("mod_flair_only", "true")
form.Set("s3_key", "t5_2uquw1/t2_164ab8/a94a8f45ccb199a61c4c0873d391e98c982fabd3")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err = client.Emoji.Upload(ctx, "testsubreddit", nil, emojiFile.Name())
require.EqualError(t, err, "createRequest: cannot be nil")
_, err = client.Emoji.Upload(ctx, "testsubreddit", &EmojiCreateOrUpdateRequest{Name: ""}, emojiFile.Name())
require.EqualError(t, err, "name: cannot be empty")
_, err = client.Emoji.Upload(ctx, "testsubreddit", &EmojiCreateOrUpdateRequest{
Name: "testemoji",
UserFlairAllowed: Bool(false),
PostFlairAllowed: Bool(true),
ModFlairOnly: Bool(true),
}, emojiFile.Name())
require.NoError(t, err)
}
func TestEmojiService_Update(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/testsubreddit/emoji_permissions", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("name", "testemoji")
form.Set("post_flair_allowed", "false")
form.Set("mod_flair_only", "true")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Emoji.Update(ctx, "testsubreddit", nil)
require.EqualError(t, err, "updateRequest: cannot be nil")
_, err = client.Emoji.Update(ctx, "testsubreddit", &EmojiCreateOrUpdateRequest{Name: ""})
require.EqualError(t, err, "name: cannot be empty")
_, err = client.Emoji.Update(ctx, "testsubreddit", &EmojiCreateOrUpdateRequest{
Name: "testemoji",
PostFlairAllowed: Bool(false),
ModFlairOnly: Bool(true),
})
require.NoError(t, err)
}
+74
View File
@@ -0,0 +1,74 @@
package reddit
import (
"encoding/json"
"fmt"
"net/http"
)
// APIError is an error coming from Reddit.
type APIError struct {
Label string
Reason string
Field string
}
func (e *APIError) Error() string {
return fmt.Sprintf("field %q caused %s: %s", e.Field, e.Label, e.Reason)
}
// UnmarshalJSON implements the json.Unmarshaler interface.
func (e *APIError) UnmarshalJSON(data []byte) error {
var info [3]string
err := json.Unmarshal(data, &info)
if err != nil {
return err
}
e.Label = info[0]
e.Reason = info[1]
e.Field = info[2]
return nil
}
// 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:"-"`
JSON struct {
Errors []APIError `json:"errors,omitempty"`
} `json:"json"`
}
func (r *JSONErrorResponse) Error() string {
var message string
if len(r.JSON.Errors) > 0 {
message = r.JSON.Errors[0].Error()
}
return fmt.Sprintf(
"%s %s: %d %s",
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
Response *http.Response `json:"-"`
// Error message
Message string `json:"message"`
}
func (r *ErrorResponse) Error() string {
return fmt.Sprintf(
"%s %s: %d %s",
r.Response.Request.Method, r.Response.Request.URL, r.Response.StatusCode, r.Message,
)
}
// todo: rate limit errors
+92
View File
@@ -0,0 +1,92 @@
package reddit
import (
"context"
"fmt"
"net/http"
)
// FlairService handles communication with the flair
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_flair
type FlairService struct {
client *Client
}
// Flair is a tag that can be attached to a user or a post.
type Flair struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
Color string `json:"text_color,omitempty"`
BackgroundColor string `json:"background_color,omitempty"`
CSSClass string `json:"css_class,omitempty"`
Editable bool `json:"text_editable"`
ModOnly bool `json:"mod_only"`
}
// FlairSummary is a condensed version of Flair.
type FlairSummary struct {
User string `json:"user,omitempty"`
Text string `json:"flair_text,omitempty"`
CSSClass string `json:"flair_css_class,omitempty"`
}
// GetUserFlairs returns the user flairs from the subreddit.
func (s *FlairService) GetUserFlairs(ctx context.Context, subreddit string) ([]*Flair, *Response, error) {
path := fmt.Sprintf("r/%s/api/user_flair_v2", subreddit)
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
}
// GetPostFlairs returns the post flairs from the subreddit.
func (s *FlairService) GetPostFlairs(ctx context.Context, subreddit string) ([]*Flair, *Response, error) {
path := fmt.Sprintf("r/%s/api/link_flair_v2", subreddit)
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
}
// ListUserFlairs returns all flairs of individual users in the subreddit.
func (s *FlairService) ListUserFlairs(ctx context.Context, subreddit string) ([]*FlairSummary, *Response, error) {
path := fmt.Sprintf("r/%s/api/flairlist", subreddit)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
var root struct {
UserFlairs []*FlairSummary `json:"users"`
}
resp, err := s.client.Do(ctx, req, &root)
if err != nil {
return nil, resp, err
}
return root.UserFlairs, resp, nil
}
+113
View File
@@ -0,0 +1,113 @@
package reddit
import (
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
var expectedUserFlairs = []*Flair{
{
ID: "b8a1c822-3feb-11e8-88e1-0e5f55d58ce0",
Type: "text",
Text: "Beginner",
Color: "dark",
BackgroundColor: "",
CSSClass: "Beginner1",
Editable: false,
ModOnly: false,
},
{
ID: "b8ea0fce-3feb-11e8-af7a-0e263a127cf8",
Text: "Moderator",
Type: "text",
Color: "dark",
BackgroundColor: "",
CSSClass: "Moderator1",
Editable: false,
ModOnly: true,
},
}
var expectedPostFlairs = []*Flair{
{
ID: "305b503e-da60-11ea-9681-0e9f1d580d2d",
Type: "richtext",
Text: "test",
Color: "light",
BackgroundColor: "#373c3f",
CSSClass: "test",
Editable: false,
ModOnly: true,
},
}
var expectedListUserFlairs = []*FlairSummary{
{
User: "TestUser1",
Text: "TestFlair1",
},
{
User: "TestUser2",
Text: "TestFlair2",
},
}
func TestFlairService_GetUserFlairs(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/flair/user-flairs.json")
require.NoError(t, err)
mux.HandleFunc("/r/testsubreddit/api/user_flair_v2", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
userFlairs, _, err := client.Flair.GetUserFlairs(ctx, "testsubreddit")
require.NoError(t, err)
require.Equal(t, expectedUserFlairs, userFlairs)
}
func TestFlairService_GetPostFlairs(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/flair/post-flairs.json")
require.NoError(t, err)
mux.HandleFunc("/r/testsubreddit/api/link_flair_v2", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
postFlairs, _, err := client.Flair.GetPostFlairs(ctx, "testsubreddit")
require.NoError(t, err)
require.Equal(t, expectedPostFlairs, postFlairs)
}
func TestFlairService_ListUserFlairs(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/flair/list-user-flairs.json")
require.NoError(t, err)
mux.HandleFunc("/r/testsubreddit/api/flairlist", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
userFlairs, _, err := client.Flair.ListUserFlairs(ctx, "testsubreddit")
require.NoError(t, err)
require.Equal(t, expectedListUserFlairs, userFlairs)
}
+57
View File
@@ -0,0 +1,57 @@
package reddit
import (
"context"
"fmt"
"net/http"
"strings"
)
// ListingsService handles communication with the listing
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_listings
type ListingsService struct {
client *Client
}
// Get returns posts, comments, and subreddits from their full IDs.
func (s *ListingsService) Get(ctx context.Context, ids ...string) ([]*Post, []*Comment, []*Subreddit, *Response, error) {
path := fmt.Sprintf("api/info?id=%s", strings.Join(ids, ","))
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, nil, nil, err
}
root := new(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, nil, nil, resp, err
}
posts := root.getPosts().Posts
comments := root.getComments().Comments
subreddits := root.getSubreddits().Subreddits
return posts, comments, subreddits, resp, nil
}
// GetPosts returns posts from their full IDs.
func (s *ListingsService) GetPosts(ctx context.Context, ids ...string) ([]*Post, *Response, error) {
path := fmt.Sprintf("by_id/%s", strings.Join(ids, ","))
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
posts := root.getPosts().Posts
return posts, resp, nil
}
+182
View File
@@ -0,0 +1,182 @@
package reddit
import (
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var expectedListingPosts = []*Post{
{
ID: "i2gvg4",
FullID: "t3_i2gvg4",
Created: &Timestamp{time.Date(2020, 8, 2, 18, 23, 8, 0, time.UTC)},
Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)},
Permalink: "/r/test/comments/i2gvg4/this_is_a_title/",
URL: "https://www.reddit.com/r/test/comments/i2gvg4/this_is_a_title/",
Title: "This is a title",
Body: "This is some text",
Likes: Bool(true),
Score: 1,
UpvoteRatio: 1,
NumberOfComments: 1,
SubredditName: "test",
SubredditNamePrefixed: "r/test",
SubredditID: "t5_2qh23",
Author: "v_95",
AuthorID: "t2_164ab8",
IsSelfPost: true,
},
}
var expectedListingComments = []*Comment{
{
ID: "g05v931",
FullID: "t1_g05v931",
Created: &Timestamp{time.Date(2020, 8, 3, 1, 15, 40, 0, time.UTC)},
Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)},
ParentID: "t3_i2gvg4",
Permalink: "/r/test/comments/i2gvg4/this_is_a_title/g05v931/",
Body: "Test comment",
Author: "v_95",
AuthorID: "t2_164ab8",
SubredditName: "test",
SubredditNamePrefixed: "r/test",
SubredditID: "t5_2qh23",
Likes: Bool(true),
Score: 1,
Controversiality: 0,
PostID: "t3_i2gvg4",
IsSubmitter: true,
},
}
var expectedListingSubreddits = []*Subreddit{
{
ID: "2qh23",
FullID: "t5_2qh23",
Created: &Timestamp{time.Date(2008, 1, 25, 5, 11, 28, 0, time.UTC)},
URL: "/r/test/",
Name: "test",
NamePrefixed: "r/test",
Title: "Testing",
Type: "public",
Subscribers: 8202,
Subscribed: true,
},
}
var expectedListingPosts2 = []*Post{
{
ID: "i2gvg4",
FullID: "t3_i2gvg4",
Created: &Timestamp{time.Date(2020, 8, 2, 18, 23, 8, 0, time.UTC)},
Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)},
Permalink: "/r/test/comments/i2gvg4/this_is_a_title/",
URL: "https://www.reddit.com/r/test/comments/i2gvg4/this_is_a_title/",
Title: "This is a title",
Body: "This is some text",
Likes: Bool(true),
Score: 1,
UpvoteRatio: 1,
NumberOfComments: 1,
SubredditName: "test",
SubredditNamePrefixed: "r/test",
SubredditID: "t5_2qh23",
Author: "v_95",
AuthorID: "t2_164ab8",
IsSelfPost: true,
},
{
ID: "i2gvs1",
FullID: "t3_i2gvs1",
Created: &Timestamp{time.Date(2020, 8, 2, 18, 23, 37, 0, time.UTC)},
Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)},
Permalink: "/r/test/comments/i2gvs1/this_is_a_title/",
URL: "http://example.com",
Title: "This is a title",
Likes: Bool(true),
Score: 1,
UpvoteRatio: 1,
NumberOfComments: 0,
SubredditName: "test",
SubredditNamePrefixed: "r/test",
SubredditID: "t5_2qh23",
Author: "v_95",
AuthorID: "t2_164ab8",
},
}
func TestListingsService_Get(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/listings/posts-comments-subreddits.json")
require.NoError(t, err)
mux.HandleFunc("/api/info", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("id", "t5_2qh23,t3_i2gvg4,t1_g05v931")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
posts, comments, subreddits, _, err := client.Listings.Get(ctx, "t5_2qh23", "t3_i2gvg4", "t1_g05v931")
require.NoError(t, err)
require.Equal(t, expectedListingPosts, posts)
require.Equal(t, expectedListingComments, comments)
require.Equal(t, expectedListingSubreddits, subreddits)
}
func TestListingsService_GetPosts(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/listings/posts.json")
require.NoError(t, err)
mux.HandleFunc("/by_id/t3_i2gvg4,t3_i2gwgz", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, _, err := client.Listings.GetPosts(ctx, "t3_i2gvg4", "t3_i2gwgz")
require.NoError(t, err)
require.Equal(t, expectedListingPosts2, posts)
}
+310
View File
@@ -0,0 +1,310 @@
package reddit
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"github.com/google/go-querystring/query"
)
// MessageService handles communication with the message
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_messages
type MessageService struct {
client *Client
}
// Message is a message.
type Message struct {
ID string `json:"id"`
FullID string `json:"name"`
Created *Timestamp `json:"created_utc"`
Subject string `json:"subject"`
Text string `json:"body"`
ParentID string `json:"parent_id"`
Author string `json:"author"`
To string `json:"dest"`
IsComment bool `json:"was_comment"`
}
// Messages is a list of messages.
type Messages struct {
Messages []*Message `json:"messages"`
After string `json:"after"`
Before string `json:"before"`
}
type rootInboxListing struct {
Kind string `json:"kind"`
Data inboxListing `json:"data"`
}
type inboxListing struct {
Things inboxThings `json:"children"`
After string `json:"after"`
Before string `json:"before"`
}
// The returned JSON for comments is a bit different.
// It looks for like the Message struct.
type inboxThings struct {
Comments []*Message
Messages []*Message
}
// init initializes or clears the inbox.
func (t *inboxThings) init() {
t.Comments = make([]*Message, 0)
t.Messages = make([]*Message, 0)
}
// UnmarshalJSON implements the json.Unmarshaler interface.
func (t *inboxThings) UnmarshalJSON(b []byte) error {
t.init()
var things []thing
if err := json.Unmarshal(b, &things); err != nil {
return err
}
for _, thing := range things {
switch thing.Kind {
case kindComment:
v := new(Message)
if err := json.Unmarshal(thing.Data, v); err == nil {
t.Comments = append(t.Comments, v)
}
case kindMessage:
v := new(Message)
if err := json.Unmarshal(thing.Data, v); err == nil {
t.Messages = append(t.Messages, v)
}
}
}
return nil
}
func (l *rootInboxListing) getComments() *Messages {
return &Messages{
Messages: l.Data.Things.Comments,
After: l.Data.After,
Before: l.Data.Before,
}
}
func (l *rootInboxListing) getMessages() *Messages {
return &Messages{
Messages: l.Data.Things.Messages,
After: l.Data.After,
Before: l.Data.Before,
}
}
// SendMessageRequest represents a request to send a message.
type SendMessageRequest struct {
// Username, or /r/name for that subreddit's moderators.
To string `url:"to"`
Subject string `url:"subject"`
Text string `url:"text"`
// Optional. If specified, the message will look like it came from the subreddit.
FromSubreddit string `url:"from_sr,omitempty"`
}
// ReadAll marks all messages/comments as read. It queues up the task on Reddit's end.
// A successful response returns 202 to acknowledge acceptance of the request.
// This endpoint is heavily rate limited.
func (s *MessageService) ReadAll(ctx context.Context) (*Response, error) {
path := "api/read_all_messages"
req, err := s.client.NewRequest(http.MethodPost, path, nil)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Read marks a message/comment as read via its full ID.
func (s *MessageService) Read(ctx context.Context, ids ...string) (*Response, error) {
if len(ids) == 0 {
return nil, errors.New("must provide at least 1 id")
}
path := "api/read_message"
form := url.Values{}
form.Set("id", strings.Join(ids, ","))
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Unread marks a message/comment as unread via its full ID.
func (s *MessageService) Unread(ctx context.Context, ids ...string) (*Response, error) {
if len(ids) == 0 {
return nil, errors.New("must provide at least 1 id")
}
path := "api/unread_message"
form := url.Values{}
form.Set("id", strings.Join(ids, ","))
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Block blocks the author of a thing via the thing's full ID.
// The thing can be a post, comment or message.
func (s *MessageService) Block(ctx context.Context, id string) (*Response, error) {
path := "api/block"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Collapse collapses messages.
func (s *MessageService) Collapse(ctx context.Context, ids ...string) (*Response, error) {
if len(ids) == 0 {
return nil, errors.New("must provide at least 1 id")
}
path := "api/collapse_message"
form := url.Values{}
form.Set("id", strings.Join(ids, ","))
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Uncollapse uncollapses messages.
func (s *MessageService) Uncollapse(ctx context.Context, ids ...string) (*Response, error) {
if len(ids) == 0 {
return nil, errors.New("must provide at least 1 id")
}
path := "api/uncollapse_message"
form := url.Values{}
form.Set("id", strings.Join(ids, ","))
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Delete deletes a message.
func (s *MessageService) Delete(ctx context.Context, id string) (*Response, error) {
path := "api/del_msg"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Send sends a message.
func (s *MessageService) Send(ctx context.Context, sendRequest *SendMessageRequest) (*Response, error) {
if sendRequest == nil {
return nil, errors.New("sendRequest: cannot be nil")
}
path := "api/compose"
form, err := query.Values(sendRequest)
if err != nil {
return nil, err
}
form.Set("api_type", "json")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Inbox returns comments and messages that appear in your inbox, respectively.
func (s *MessageService) Inbox(ctx context.Context, opts *ListOptions) (*Messages, *Messages, *Response, error) {
root, resp, err := s.inbox(ctx, "message/inbox", opts)
if err != nil {
return nil, nil, resp, err
}
return root.getComments(), root.getMessages(), resp, nil
}
// InboxUnread returns unread comments and messages that appear in your inbox, respectively.
func (s *MessageService) InboxUnread(ctx context.Context, opts *ListOptions) (*Messages, *Messages, *Response, error) {
root, resp, err := s.inbox(ctx, "message/unread", opts)
if err != nil {
return nil, nil, resp, err
}
return root.getComments(), root.getMessages(), resp, nil
}
// Sent returns messages that you've sent.
func (s *MessageService) Sent(ctx context.Context, opts *ListOptions) (*Messages, *Response, error) {
root, resp, err := s.inbox(ctx, "message/sent", opts)
if err != nil {
return nil, resp, err
}
return root.getMessages(), resp, nil
}
func (s *MessageService) inbox(ctx context.Context, path string, opts *ListOptions) (*rootInboxListing, *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(rootInboxListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, nil, err
}
return root, resp, nil
}
+277
View File
@@ -0,0 +1,277 @@
package reddit
import (
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var expectedCommentMessages = &Messages{
Messages: []*Message{
{
ID: "g1xi2m9",
FullID: "t1_g1xi2m9",
Created: &Timestamp{time.Date(2020, 8, 18, 0, 24, 13, 0, time.UTC)},
Subject: "post reply",
Text: "u/testuser2 hello",
ParentID: "t3_hs03f3",
Author: "testuser1",
To: "testuser2",
IsComment: true,
},
},
After: "",
Before: "",
}
var expectedMessages = &Messages{
Messages: []*Message{
{
ID: "qwki97",
FullID: "t4_qwki97",
Created: &Timestamp{time.Date(2020, 8, 18, 0, 16, 53, 0, time.UTC)},
Subject: "re: test",
Text: "test",
ParentID: "t4_qwki4m",
Author: "testuser1",
To: "testuser2",
IsComment: false,
},
},
After: "",
Before: "",
}
func TestMessageService_ReadAll(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/read_all_messages", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
w.WriteHeader(http.StatusAccepted)
})
res, err := client.Message.ReadAll(ctx)
require.NoError(t, err)
require.Equal(t, http.StatusAccepted, res.StatusCode)
}
func TestMessageService_Read(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/read_message", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "test1,test2,test3")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Message.Read(ctx)
require.EqualError(t, err, "must provide at least 1 id")
_, err = client.Message.Read(ctx, "test1", "test2", "test3")
require.NoError(t, err)
}
func TestMessageService_Unread(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/unread_message", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "test1,test2,test3")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Message.Unread(ctx)
require.EqualError(t, err, "must provide at least 1 id")
_, err = client.Message.Unread(ctx, "test1", "test2", "test3")
require.NoError(t, err)
}
func TestMessageService_Block(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/block", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Message.Block(ctx, "test")
require.NoError(t, err)
}
func TestMessageService_Collapse(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/collapse_message", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "test1,test2,test3")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Message.Collapse(ctx)
require.EqualError(t, err, "must provide at least 1 id")
_, err = client.Message.Collapse(ctx, "test1", "test2", "test3")
require.NoError(t, err)
}
func TestMessageService_Uncollapse(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/uncollapse_message", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "test1,test2,test3")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Message.Uncollapse(ctx)
require.EqualError(t, err, "must provide at least 1 id")
_, err = client.Message.Uncollapse(ctx, "test1", "test2", "test3")
require.NoError(t, err)
}
func TestMessageService_Delete(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/del_msg", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Message.Delete(ctx, "test")
require.NoError(t, err)
}
func TestMessageService_Send(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/compose", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("api_type", "json")
form.Set("to", "test")
form.Set("subject", "test subject")
form.Set("text", "test text")
form.Set("from_sr", "hello world")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Message.Send(ctx, nil)
require.EqualError(t, err, "sendRequest: cannot be nil")
_, err = client.Message.Send(ctx, &SendMessageRequest{
To: "test",
Subject: "test subject",
Text: "test text",
FromSubreddit: "hello world",
})
require.NoError(t, err)
}
func TestMessageService_Inbox(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/message/inbox.json")
require.NoError(t, err)
mux.HandleFunc("/message/inbox", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
comments, messages, _, err := client.Message.Inbox(ctx, nil)
require.NoError(t, err)
require.Equal(t, expectedCommentMessages, comments)
require.Equal(t, expectedMessages, messages)
}
func TestMessageService_InboxUnread(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/message/inbox.json")
require.NoError(t, err)
mux.HandleFunc("/message/unread", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
comments, messages, _, err := client.Message.InboxUnread(ctx, nil)
require.NoError(t, err)
require.Equal(t, expectedCommentMessages, comments)
require.Equal(t, expectedMessages, messages)
}
func TestMessageService_Sent(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/message/inbox.json")
require.NoError(t, err)
mux.HandleFunc("/message/sent", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
messages, _, err := client.Message.Sent(ctx, nil)
require.NoError(t, err)
require.Equal(t, expectedMessages, messages)
}
+182
View File
@@ -0,0 +1,182 @@
package reddit
import (
"context"
"fmt"
"net/http"
"net/url"
)
// ModerationService handles communication with the moderation
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_moderation
type ModerationService struct {
client *Client
}
// ModAction is an action executed by a moderator of a subreddit, such
// as inviting another user to be a mod, or setting permissions.
type ModAction struct {
ID string `json:"id,omitempty"`
Action string `json:"action,omitempty"`
Created *Timestamp `json:"created_utc,omitempty"`
Moderator string `json:"mod,omitempty"`
// Not the full ID, just the ID36.
ModeratorID string `json:"mod_id36,omitempty"`
// The author of whatever the action was produced on, e.g. a user, post, comment, etc.
TargetAuthor string `json:"target_author,omitempty"`
// This is the full ID of whatever the target was.
TargetID string `json:"target_fullname,omitempty"`
TargetTitle string `json:"target_title,omitempty"`
TargetPermalink string `json:"target_permalink,omitempty"`
TargetBody string `json:"target_body,omitempty"`
Subreddit string `json:"subreddit,omitempty"`
// Not the full ID, just the ID36.
SubredditID string `json:"sr_id36,omitempty"`
}
// GetActions gets a list of moderator actions on a subreddit.
func (s *ModerationService) GetActions(ctx context.Context, subreddit string, opts *ListModActionOptions) (*ModActions, *Response, error) {
path := fmt.Sprintf("r/%s/about/log", subreddit)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getModActions(), resp, nil
}
// AcceptInvite accepts a pending invite to moderate the specified subreddit.
func (s *ModerationService) AcceptInvite(ctx context.Context, subreddit string) (*Response, error) {
path := fmt.Sprintf("r/%s/api/accept_moderator_invite", subreddit)
form := url.Values{}
form.Set("api_type", "json")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Approve approves a post or comment via its full ID.
func (s *ModerationService) Approve(ctx context.Context, id string) (*Response, error) {
path := "api/approve"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Remove removes a post, comment or modmail message via its full ID.
func (s *ModerationService) Remove(ctx context.Context, id string) (*Response, error) {
path := "api/remove"
form := url.Values{}
form.Set("id", id)
form.Set("spam", "false")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// RemoveSpam removes a post, comment or modmail message via its full ID and marks it as spam.
func (s *ModerationService) RemoveSpam(ctx context.Context, id string) (*Response, error) {
path := "api/remove"
form := url.Values{}
form.Set("id", id)
form.Set("spam", "true")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Leave abdicates your moderator status in a subreddit via its full ID.
func (s *ModerationService) Leave(ctx context.Context, subredditID string) (*Response, error) {
path := "api/leavemoderator"
form := url.Values{}
form.Set("id", subredditID)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// LeaveContributor abdicates your approved user status in a subreddit via its full ID.
func (s *ModerationService) LeaveContributor(ctx context.Context, subredditID string) (*Response, error) {
path := "api/leavecontributor"
form := url.Values{}
form.Set("id", subredditID)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Edited gets posts and comments that have been edited recently.
func (s *ModerationService) Edited(ctx context.Context, subreddit string, opts *ListOptions) (*Posts, *Comments, *Response, error) {
path := fmt.Sprintf("r/%s/about/edited", subreddit)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, nil, err
}
root := new(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, nil, nil, err
}
return root.getPosts(), root.getComments(), resp, nil
}
+224
View File
@@ -0,0 +1,224 @@
package reddit
import (
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var expectedModActions = &ModActions{
ModActions: []*ModAction{
{
ID: "ModAction_b4e7979a-c4ad-11ea-8440-0ea1b7c2b8f9",
Action: "spamcomment",
Created: &Timestamp{time.Date(2020, 7, 13, 2, 8, 14, 0, time.UTC)},
Moderator: "v_95",
ModeratorID: "164ab8",
TargetAuthor: "testuser",
TargetID: "t1_fxw10aa",
TargetPermalink: "/r/helloworldtestt/comments/hq6r3t/yo/fxw10aa/",
TargetBody: "hi",
Subreddit: "helloworldtestt",
SubredditID: "2uquw1",
},
{
ID: "ModAction_a0408162-c4ad-11ea-8239-0e3b48262e8b",
Action: "sticky",
Created: &Timestamp{time.Date(2020, 7, 13, 2, 7, 38, 0, time.UTC)},
Moderator: "v_95",
ModeratorID: "164ab8",
TargetAuthor: "testuser",
TargetID: "t3_hq6r3t",
TargetTitle: "yo",
TargetPermalink: "/r/helloworldtestt/comments/hq6r3t/yo/",
Subreddit: "helloworldtestt",
SubredditID: "2uquw1",
},
},
After: "ModAction_a0408162-c4ad-11ea-8239-0e3b48262e8b",
Before: "",
}
func TestModerationService_GetActions(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/moderation/actions.json")
require.NoError(t, err)
mux.HandleFunc("/r/testsubreddit/about/log", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("type", "testtype")
form.Set("mod", "testmod")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
modActions, _, err := client.Moderation.GetActions(ctx, "testsubreddit", &ListModActionOptions{Type: "testtype", Moderator: "testmod"})
require.NoError(t, err)
require.Equal(t, expectedModActions, modActions)
}
func TestModerationService_AcceptInvite(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/moderation/actions.json")
require.NoError(t, err)
mux.HandleFunc("/r/testsubreddit/api/accept_moderator_invite", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("api_type", "json")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
_, err = client.Moderation.AcceptInvite(ctx, "testsubreddit")
require.NoError(t, err)
}
func TestModerationService_Approve(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/approve", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t3_test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Moderation.Approve(ctx, "t3_test")
require.NoError(t, err)
}
func TestModerationService_Remove(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/remove", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t3_test")
form.Set("spam", "false")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Moderation.Remove(ctx, "t3_test")
require.NoError(t, err)
}
func TestModerationService_RemoveSpam(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/remove", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t3_test")
form.Set("spam", "true")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Moderation.RemoveSpam(ctx, "t3_test")
require.NoError(t, err)
}
func TestModerationService_Leave(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/leavemoderator", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t5_test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Moderation.Leave(ctx, "t5_test")
require.NoError(t, err)
}
func TestModerationService_LeaveContributor(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/leavecontributor", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "t5_test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Moderation.LeaveContributor(ctx, "t5_test")
require.NoError(t, err)
}
func TestModerationService_Edited(t *testing.T) {
setup()
defer teardown()
// contains posts and comments
blob, err := readFileContents("../testdata/user/overview.json")
require.NoError(t, err)
mux.HandleFunc("/r/testsubreddit/about/edited", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, comments, _, err := client.Moderation.Edited(ctx, "testsubreddit", nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t1_f0zsa37", posts.After)
require.Equal(t, "", posts.Before)
require.Len(t, comments.Comments, 1)
require.Equal(t, expectedComment, comments.Comments[0])
require.Equal(t, "t1_f0zsa37", comments.After)
require.Equal(t, "", comments.Before)
}
+334
View File
@@ -0,0 +1,334 @@
package reddit
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/google/go-querystring/query"
)
// MultiService handles communication with the multireddit
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api#section_multis
type MultiService struct {
client *Client
}
type multiRoot struct {
Kind string `json:"kind,omitempty"`
Data *Multi `json:"data,omitempty"`
}
// Multi is a multireddit, i.e. a customizable group of subreddits.
// Users can create multis for custom navigation, instead of browsing
// one subreddit or all subreddits at a time.
type Multi struct {
Name string `json:"name,omitempty"`
DisplayName string `json:"display_name,omitempty"`
// Format: user/{username}/m/{multiname}
Path string `json:"path,omitempty"`
Description string `json:"description_md,omitempty"`
Subreddits SubredditNames `json:"subreddits"`
CopiedFrom *string `json:"copied_from"`
Owner string `json:"owner,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
Created *Timestamp `json:"created_utc,omitempty"`
NumberOfSubscribers int `json:"num_subscribers"`
Visibility string `json:"visibility,omitempty"`
Subscribed bool `json:"is_subscriber"`
Favorite bool `json:"is_favorited"`
CanEdit bool `json:"can_edit"`
NSFW bool `json:"over_18"`
}
// SubredditNames is a list of subreddit names.
type SubredditNames []string
// UnmarshalJSON implements the json.Unmarshaler interface.
func (n *SubredditNames) UnmarshalJSON(data []byte) error {
type subreddit struct {
Name string `json:"name"`
}
var subreddits []subreddit
err := json.Unmarshal(data, &subreddits)
if err != nil {
return err
}
for _, sr := range subreddits {
*n = append(*n, sr.Name)
}
return nil
}
// MarshalJSON implements the json.Marshaler interface.
func (n *SubredditNames) MarshalJSON() ([]byte, error) {
type subreddit struct {
Name string `json:"name"`
}
var subreddits []subreddit
for _, name := range *n {
subreddits = append(subreddits, subreddit{name})
}
return json.Marshal(subreddits)
}
// MultiCopyRequest represents a request to copy a multireddit.
type MultiCopyRequest struct {
FromPath string `url:"from"`
ToPath string `url:"to"`
// Raw markdown text.
Description string `url:"description_md,omitempty"`
// No longer than 50 characters.
DisplayName string `url:"display_name,omitempty"`
}
// MultiCreateOrUpdateRequest represents a request to create/update a multireddit.
type MultiCreateOrUpdateRequest struct {
// For updates, this is the display name, i.e. the header of the multi.
// Not part of the path necessarily.
Name string `json:"display_name,omitempty"`
Description string `json:"description_md,omitempty"`
Subreddits SubredditNames `json:"subreddits,omitempty"`
// One of: private, public, hidden.
Visibility string `json:"visibility,omitempty"`
}
// Form parameterizes the fields and returns the form.
func (r *MultiCreateOrUpdateRequest) Form() url.Values {
byteValue, _ := json.Marshal(r)
form := url.Values{}
form.Set("model", string(byteValue))
return form
}
type rootMultiDescription struct {
Data struct {
Body string `json:"body_md"`
} `json:"data"`
}
// Get gets information about the multireddit from its url path.
func (s *MultiService) Get(ctx context.Context, multiPath string) (*Multi, *Response, error) {
path := fmt.Sprintf("api/multi/%s", multiPath)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(multiRoot)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Data, resp, nil
}
// Mine returns your multireddits.
func (s *MultiService) Mine(ctx context.Context) ([]Multi, *Response, error) {
path := "api/multi/mine"
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
var root []multiRoot
resp, err := s.client.Do(ctx, req, &root)
if err != nil {
return nil, resp, err
}
multis := make([]Multi, 0)
for _, multi := range root {
multis = append(multis, *multi.Data)
}
return multis, resp, nil
}
// Of returns the user's public multireddits.
// Or, if the user is you, all of your multireddits.
func (s *MultiService) Of(ctx context.Context, username string) ([]Multi, *Response, error) {
path := fmt.Sprintf("api/multi/user/%s", username)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
var root []multiRoot
resp, err := s.client.Do(ctx, req, &root)
if err != nil {
return nil, resp, err
}
multis := make([]Multi, 0)
for _, multi := range root {
multis = append(multis, *multi.Data)
}
return multis, resp, nil
}
// Copy copies a multireddit.
func (s *MultiService) Copy(ctx context.Context, copyRequest *MultiCopyRequest) (*Multi, *Response, error) {
if copyRequest == nil {
return nil, nil, errors.New("copyRequest: cannot be nil")
}
path := "api/multi/copy"
form, err := query.Values(copyRequest)
if err != nil {
return nil, nil, err
}
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, nil, err
}
root := new(multiRoot)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Data, resp, nil
}
// Create creates a multireddit.
func (s *MultiService) Create(ctx context.Context, createRequest *MultiCreateOrUpdateRequest) (*Multi, *Response, error) {
if createRequest == nil {
return nil, nil, errors.New("createRequest: cannot be nil")
}
path := "api/multi"
req, err := s.client.NewRequestWithForm(http.MethodPost, path, createRequest.Form())
if err != nil {
return nil, nil, err
}
root := new(multiRoot)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Data, resp, nil
}
// Update updates a multireddit.
// If the multireddit does not exist, it will be created.
func (s *MultiService) Update(ctx context.Context, multiPath string, updateRequest *MultiCreateOrUpdateRequest) (*Multi, *Response, error) {
if updateRequest == nil {
return nil, nil, errors.New("updateRequest: cannot be nil")
}
path := fmt.Sprintf("api/multi/%s", multiPath)
req, err := s.client.NewRequestWithForm(http.MethodPut, path, updateRequest.Form())
if err != nil {
return nil, nil, err
}
root := new(multiRoot)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Data, resp, nil
}
// Delete deletes a multireddit.
func (s *MultiService) Delete(ctx context.Context, multiPath string) (*Response, error) {
path := fmt.Sprintf("api/multi/%s", multiPath)
req, err := s.client.NewRequest(http.MethodDelete, path, nil)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// GetDescription gets a multireddit's description.
func (s *MultiService) GetDescription(ctx context.Context, multiPath string) (string, *Response, error) {
path := fmt.Sprintf("api/multi/%s/description", multiPath)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return "", nil, err
}
root := new(rootMultiDescription)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return "", resp, err
}
return root.Data.Body, resp, nil
}
// UpdateDescription updates a multireddit's description.
func (s *MultiService) UpdateDescription(ctx context.Context, multiPath string, description string) (string, *Response, error) {
path := fmt.Sprintf("api/multi/%s/description", multiPath)
form := url.Values{}
form.Set("model", fmt.Sprintf(`{"body_md":"%s"}`, description))
req, err := s.client.NewRequestWithForm(http.MethodPut, path, form)
if err != nil {
return "", nil, err
}
root := new(rootMultiDescription)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return "", resp, err
}
return root.Data.Body, resp, nil
}
// AddSubreddit adds a subreddit to a multireddit.
func (s *MultiService) AddSubreddit(ctx context.Context, multiPath string, subreddit string) (*Response, error) {
path := fmt.Sprintf("api/multi/%s/r/%s", multiPath, subreddit)
form := url.Values{}
form.Set("model", fmt.Sprintf(`{"name":"%s"}`, subreddit))
req, err := s.client.NewRequestWithForm(http.MethodPut, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// DeleteSubreddit removes a subreddit from a multireddit.
func (s *MultiService) DeleteSubreddit(ctx context.Context, multiPath string, subreddit string) (*Response, error) {
path := fmt.Sprintf("api/multi/%s/r/%s", multiPath, subreddit)
req, err := s.client.NewRequest(http.MethodDelete, path, nil)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
+291
View File
@@ -0,0 +1,291 @@
package reddit
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var expectedMulti = &Multi{
Name: "test",
DisplayName: "test",
Path: "/user/v_95/m/test/",
Subreddits: []string{"nba", "golang"},
CopiedFrom: nil,
Owner: "v_95",
OwnerID: "t2_164ab8",
Created: &Timestamp{time.Date(2020, 7, 11, 4, 55, 12, 0, time.UTC)},
NumberOfSubscribers: 0,
Visibility: "private",
CanEdit: true,
}
var expectedMulti2 = &Multi{
Name: "test2",
DisplayName: "test2",
Path: "/user/v_95/m/test2/",
Subreddits: []string{"redditdev", "test"},
CopiedFrom: nil,
Owner: "v_95",
OwnerID: "t2_164ab8",
Created: &Timestamp{time.Date(2020, 7, 11, 4, 57, 3, 0, time.UTC)},
NumberOfSubscribers: 0,
Visibility: "private",
CanEdit: true,
}
func TestMultiService_Get(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/multi/multi.json")
require.NoError(t, err)
mux.HandleFunc("/api/multi/user/testuser/m/testmulti", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
multi, _, err := client.Multi.Get(ctx, "user/testuser/m/testmulti")
require.NoError(t, err)
require.Equal(t, expectedMulti, multi)
}
func TestMultiService_Mine(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/multi/multis.json")
require.NoError(t, err)
mux.HandleFunc("/api/multi/mine", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
multis, _, err := client.Multi.Mine(ctx)
require.NoError(t, err)
require.Equal(t, []Multi{*expectedMulti, *expectedMulti2}, multis)
}
func TestMultiService_Of(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/multi/multis.json")
require.NoError(t, err)
mux.HandleFunc("/api/multi/user/test", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
multis, _, err := client.Multi.Of(ctx, "test")
require.NoError(t, err)
require.Equal(t, []Multi{*expectedMulti, *expectedMulti2}, multis)
}
func TestMultiService_Copy(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/multi/multi.json")
require.NoError(t, err)
mux.HandleFunc("/api/multi/copy", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("from", "user/testuser/m/testmulti")
form.Set("to", "user/testuser2/m/testmulti2")
form.Set("description_md", "this is a multireddit")
form.Set("display_name", "hello")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
_, _, err = client.Multi.Copy(ctx, nil)
require.EqualError(t, err, "copyRequest: cannot be nil")
multi, _, err := client.Multi.Copy(ctx, &MultiCopyRequest{
FromPath: "user/testuser/m/testmulti",
ToPath: "user/testuser2/m/testmulti2",
Description: "this is a multireddit",
DisplayName: "hello",
})
require.NoError(t, err)
require.Equal(t, expectedMulti, multi)
}
func TestMultiService_Create(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/multi/multi.json")
require.NoError(t, err)
createRequest := &MultiCreateOrUpdateRequest{
Name: "testmulti",
Description: "this is a multireddit",
Subreddits: []string{"golang"},
Visibility: "public",
}
mux.HandleFunc("/api/multi", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
err := r.ParseForm()
require.NoError(t, err)
model := r.Form.Get("model")
expectedCreateRequest := new(MultiCreateOrUpdateRequest)
err = json.Unmarshal([]byte(model), expectedCreateRequest)
require.NoError(t, err)
require.Equal(t, expectedCreateRequest, createRequest)
fmt.Fprint(w, blob)
})
_, _, err = client.Multi.Create(ctx, nil)
require.EqualError(t, err, "createRequest: cannot be nil")
multi, _, err := client.Multi.Create(ctx, createRequest)
require.NoError(t, err)
require.Equal(t, expectedMulti, multi)
}
func TestMultiService_Update(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/multi/multi.json")
require.NoError(t, err)
updateRequest := &MultiCreateOrUpdateRequest{
Name: "testmulti",
Description: "this is a multireddit",
Visibility: "public",
}
mux.HandleFunc("/api/multi/user/testuser/m/testmulti", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
err := r.ParseForm()
require.NoError(t, err)
model := r.Form.Get("model")
expectedCreateRequest := new(MultiCreateOrUpdateRequest)
err = json.Unmarshal([]byte(model), expectedCreateRequest)
require.NoError(t, err)
require.Equal(t, expectedCreateRequest, updateRequest)
fmt.Fprint(w, blob)
})
_, _, err = client.Multi.Update(ctx, "user/testuser/m/testmulti", nil)
require.EqualError(t, err, "updateRequest: cannot be nil")
multi, _, err := client.Multi.Update(ctx, "user/testuser/m/testmulti", updateRequest)
require.NoError(t, err)
require.Equal(t, expectedMulti, multi)
}
func TestMultiService_Delete(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/multi/user/testuser/m/testmulti", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodDelete, r.Method)
})
_, err := client.Multi.Delete(ctx, "user/testuser/m/testmulti")
require.NoError(t, err)
}
func TestMultiService_GetDescription(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/multi/description.json")
require.NoError(t, err)
mux.HandleFunc("/api/multi/user/testuser/m/testmulti/description", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
description, _, err := client.Multi.GetDescription(ctx, "user/testuser/m/testmulti")
require.NoError(t, err)
require.Equal(t, "hello world", description)
}
func TestMultiService_UpdateDescription(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/multi/description.json")
require.NoError(t, err)
mux.HandleFunc("/api/multi/user/testuser/m/testmulti/description", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
form := url.Values{}
form.Set("model", `{"body_md":"hello world"}`)
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
description, _, err := client.Multi.UpdateDescription(ctx, "user/testuser/m/testmulti", "hello world")
require.NoError(t, err)
require.Equal(t, "hello world", description)
}
func TestMultiService_AddSubreddit(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/multi/user/testuser/m/testmulti/r/golang", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
form := url.Values{}
form.Set("model", `{"name":"golang"}`)
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.Multi.AddSubreddit(ctx, "user/testuser/m/testmulti", "golang")
require.NoError(t, err)
}
func TestMultiService_DeleteSubreddit(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/multi/user/testuser/m/testmulti/r/golang", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodDelete, r.Method)
})
_, err := client.Multi.DeleteSubreddit(ctx, "user/testuser/m/testmulti", "golang")
require.NoError(t, err)
}
+164
View File
@@ -0,0 +1,164 @@
package reddit
import (
"context"
"fmt"
"net/http"
"net/url"
)
// postAndCommentService handles communication with the post and comment
// related methods of the Reddit API.
// This service holds functionality common to both posts and comments.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_links_and_comments
type postAndCommentService struct {
client *Client
}
type vote int
// Reddit interprets -1, 0, 1 as downvote, no vote, and upvote, respectively.
const (
downvote vote = iota - 1
novote
upvote
)
// Delete deletes a post or comment via its full ID.
func (s *postAndCommentService) Delete(ctx context.Context, id string) (*Response, error) {
path := "api/del"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Save saves a post or comment.
func (s *postAndCommentService) Save(ctx context.Context, id string) (*Response, error) {
path := "api/save"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Unsave unsaves a post or comment.
func (s *postAndCommentService) Unsave(ctx context.Context, id string) (*Response, error) {
path := "api/unsave"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// EnableReplies enables inbox replies for one of your posts or comments.
func (s *postAndCommentService) EnableReplies(ctx context.Context, id string) (*Response, error) {
path := "api/sendreplies"
form := url.Values{}
form.Set("id", id)
form.Set("state", "true")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// DisableReplies dsables inbox replies for one of your posts or comments.
func (s *postAndCommentService) DisableReplies(ctx context.Context, id string) (*Response, error) {
path := "api/sendreplies"
form := url.Values{}
form.Set("id", id)
form.Set("state", "false")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Lock locks a post or comment, preventing it from receiving new comments.
func (s *postAndCommentService) Lock(ctx context.Context, id string) (*Response, error) {
path := "api/lock"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Unlock unlocks a post or comment, allowing it to receive new comments.
func (s *postAndCommentService) Unlock(ctx context.Context, id string) (*Response, error) {
path := "api/unlock"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
func (s *postAndCommentService) 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.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Upvote upvotes a post or a comment.
func (s *postAndCommentService) Upvote(ctx context.Context, id string) (*Response, error) {
return s.vote(ctx, id, upvote)
}
// Downvote downvotes a post or a comment.
func (s *postAndCommentService) Downvote(ctx context.Context, id string) (*Response, error) {
return s.vote(ctx, id, downvote)
}
// RemoveVote removes your vote on a post or a comment.
func (s *postAndCommentService) RemoveVote(ctx context.Context, id string) (*Response, error) {
return s.vote(ctx, id, novote)
}
+578
View File
@@ -0,0 +1,578 @@
package reddit
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/google/go-querystring/query"
)
// PostService handles communication with the post
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_links_and_comments
type PostService struct {
*postAndCommentService
client *Client
}
type submittedLinkRoot struct {
JSON struct {
Data *Submitted `json:"data,omitempty"`
} `json:"json"`
}
// Submitted is a newly submitted post on Reddit.
type Submitted struct {
ID string `json:"id,omitempty"`
FullID string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
}
// SubmitTextOptions are options used for text posts.
type SubmitTextOptions struct {
Subreddit string `url:"sr,omitempty"`
Title string `url:"title,omitempty"`
Text string `url:"text,omitempty"`
FlairID string `url:"flair_id,omitempty"`
FlairText string `url:"flair_text,omitempty"`
SendReplies *bool `url:"sendreplies,omitempty"`
NSFW bool `url:"nsfw,omitempty"`
Spoiler bool `url:"spoiler,omitempty"`
}
// SubmitLinkOptions are options used for link posts.
type SubmitLinkOptions struct {
Subreddit string `url:"sr,omitempty"`
Title string `url:"title,omitempty"`
URL string `url:"url,omitempty"`
FlairID string `url:"flair_id,omitempty"`
FlairText string `url:"flair_text,omitempty"`
SendReplies *bool `url:"sendreplies,omitempty"`
Resubmit bool `url:"resubmit,omitempty"`
NSFW bool `url:"nsfw,omitempty"`
Spoiler bool `url:"spoiler,omitempty"`
}
// Get returns a post with its comments.
// id is the ID36 of the post, not its full id.
// Example: instead of t3_abc123, use abc123.
func (s *PostService) Get(ctx context.Context, id string) (*PostAndComments, *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(PostAndComments)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// Duplicates returns the post with the id, and a list of its duplicates.
// id is the ID36 of the post, not its full id.
// Example: instead of t3_abc123, use abc123.
func (s *PostService) Duplicates(ctx context.Context, id string, opts *ListDuplicatePostOptions) (*Post, *Posts, *Response, error) {
path := fmt.Sprintf("duplicates/%s", id)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, nil, err
}
var root [2]rootListing
resp, err := s.client.Do(ctx, req, &root)
if err != nil {
return nil, nil, resp, err
}
post := root[0].Data.Things.Posts[0]
duplicates := root[1].getPosts()
return post, duplicates, resp, nil
}
func (s *PostService) submit(ctx context.Context, v interface{}) (*Submitted, *Response, error) {
path := "api/submit"
form, err := query.Values(v)
if err != nil {
return nil, nil, err
}
form.Set("api_type", "json")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, nil, err
}
root := new(submittedLinkRoot)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.JSON.Data, resp, nil
}
// SubmitText submits a text post.
func (s *PostService) SubmitText(ctx context.Context, opts SubmitTextOptions) (*Submitted, *Response, error) {
type submit struct {
SubmitTextOptions
Kind string `url:"kind,omitempty"`
}
return s.submit(ctx, &submit{opts, "self"})
}
// SubmitLink submits a link post.
func (s *PostService) SubmitLink(ctx context.Context, opts SubmitLinkOptions) (*Submitted, *Response, error) {
type submit struct {
SubmitLinkOptions
Kind string `url:"kind,omitempty"`
}
return s.submit(ctx, &submit{opts, "link"})
}
// Edit edits a post.
func (s *PostService) Edit(ctx context.Context, id string, text string) (*Post, *Response, error) {
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.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, nil, err
}
root := new(Post)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// Hide hides posts.
func (s *PostService) 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.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Unhide unhides posts.
func (s *PostService) 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.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// MarkNSFW marks a post as NSFW.
func (s *PostService) MarkNSFW(ctx context.Context, id string) (*Response, error) {
path := "api/marknsfw"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// UnmarkNSFW unmarks a post as NSFW.
func (s *PostService) UnmarkNSFW(ctx context.Context, id string) (*Response, error) {
path := "api/unmarknsfw"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Spoiler marks a post as a spoiler.
func (s *PostService) Spoiler(ctx context.Context, id string) (*Response, error) {
path := "api/spoiler"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Unspoiler unmarks a post as a spoiler.
func (s *PostService) Unspoiler(ctx context.Context, id string) (*Response, error) {
path := "api/unspoiler"
form := url.Values{}
form.Set("id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Sticky stickies a post in its subreddit.
// When bottom is true, the post will be set as the bottom sticky (the 2nd one).
// If no top sticky exists, the post will become the top sticky regardless.
// When attempting to sticky a post that's already stickied, it will return a 409 Conflict error.
func (s *PostService) Sticky(ctx context.Context, id string, bottom bool) (*Response, error) {
path := "api/set_subreddit_sticky"
form := url.Values{}
form.Set("api_type", "json")
form.Set("id", id)
form.Set("state", "true")
if !bottom {
form.Set("num", "1")
}
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Unsticky unstickies a post in its subreddit.
func (s *PostService) Unsticky(ctx context.Context, id string) (*Response, error) {
path := "api/set_subreddit_sticky"
form := url.Values{}
form.Set("api_type", "json")
form.Set("id", id)
form.Set("state", "false")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// PinToProfile pins one of your posts to your profile.
// TODO: very inconsistent behaviour, not sure I'm ready to include this parameter yet.
// The pos parameter should be a number between 1-4 (inclusive), indicating the position at which
// the post should appear on your profile.
// Note: The position will be bumped upward if there's space. E.g. if you only have 1 pinned post,
// and you try to pin another post to position 3, it will be pinned at 2.
// When attempting to pin a post that's already pinned, it will return a 409 Conflict error.
func (s *PostService) PinToProfile(ctx context.Context, id string) (*Response, error) {
path := "api/set_subreddit_sticky"
// if pos < 1 {
// pos = 1
// }
// if pos > 4 {
// pos = 4
// }
form := url.Values{}
form.Set("api_type", "json")
form.Set("id", id)
form.Set("state", "true")
form.Set("to_profile", "true")
// form.Set("num", fmt.Sprint(pos))
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// UnpinFromProfile unpins one of your posts from your profile.
func (s *PostService) UnpinFromProfile(ctx context.Context, id string) (*Response, error) {
path := "api/set_subreddit_sticky"
form := url.Values{}
form.Set("api_type", "json")
form.Set("id", id)
form.Set("state", "false")
form.Set("to_profile", "true")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// setSuggestedSort sets the suggested comment sort for the post.
// sort must be one of: confidence (i.e. best), top, new, controversial, old, random, qa, live
func (s *PostService) setSuggestedSort(ctx context.Context, id string, sort string) (*Response, error) {
path := "api/set_suggested_sort"
form := url.Values{}
form.Set("api_type", "json")
form.Set("id", id)
form.Set("sort", sort)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// SetSuggestedSortBest sets the suggested comment sort for the post to best.
func (s *PostService) SetSuggestedSortBest(ctx context.Context, id string) (*Response, error) {
return s.setSuggestedSort(ctx, id, "confidence")
}
// SetSuggestedSortTop sets the suggested comment sort for the post to top.
func (s *PostService) SetSuggestedSortTop(ctx context.Context, id string) (*Response, error) {
return s.setSuggestedSort(ctx, id, "top")
}
// SetSuggestedSortNew sets the suggested comment sort for the post to new.
func (s *PostService) SetSuggestedSortNew(ctx context.Context, id string) (*Response, error) {
return s.setSuggestedSort(ctx, id, "new")
}
// SetSuggestedSortControversial sets the suggested comment sort for the post to controversial.
func (s *PostService) SetSuggestedSortControversial(ctx context.Context, id string) (*Response, error) {
return s.setSuggestedSort(ctx, id, "controversial")
}
// SetSuggestedSortOld sorts the comments on the posts randomly.
func (s *PostService) SetSuggestedSortOld(ctx context.Context, id string) (*Response, error) {
return s.setSuggestedSort(ctx, id, "old")
}
// SetSuggestedSortRandom sets the suggested comment sort for the post to random.
func (s *PostService) SetSuggestedSortRandom(ctx context.Context, id string) (*Response, error) {
return s.setSuggestedSort(ctx, id, "random")
}
// SetSuggestedSortAMA sets the suggested comment sort for the post to a Q&A styled fashion.
func (s *PostService) SetSuggestedSortAMA(ctx context.Context, id string) (*Response, error) {
return s.setSuggestedSort(ctx, id, "qa")
}
// SetSuggestedSortLive sets the suggested comment sort for the post to stream new comments as they're posted.
// As of now, this is still in beta, so it's not a fully developed feature yet. It just sets the sort as "new" for now.
func (s *PostService) SetSuggestedSortLive(ctx context.Context, id string) (*Response, error) {
return s.setSuggestedSort(ctx, id, "live")
}
// ClearSuggestedSort clears the suggested comment sort for the post.
func (s *PostService) ClearSuggestedSort(ctx context.Context, id string) (*Response, error) {
return s.setSuggestedSort(ctx, id, "")
}
// EnableContestMode enables contest mode for the post.
// Comments will be sorted randomly and regular users cannot see comment scores.
func (s *PostService) EnableContestMode(ctx context.Context, id string) (*Response, error) {
path := "api/set_contest_mode"
form := url.Values{}
form.Set("api_type", "json")
form.Set("id", id)
form.Set("state", "true")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// DisableContestMode disables contest mode for the post.
func (s *PostService) DisableContestMode(ctx context.Context, id string) (*Response, error) {
path := "api/set_contest_mode"
form := url.Values{}
form.Set("api_type", "json")
form.Set("id", id)
form.Set("state", "false")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// LoadMoreComments retrieves more comments that were left out when initially fetching the post.
func (s *PostService) LoadMoreComments(ctx context.Context, pc *PostAndComments) (*Response, error) {
if pc == nil {
return nil, errors.New("pc: cannot be nil")
}
if !pc.HasMore() {
return nil, nil
}
postID := pc.Post.FullID
commentIDs := pc.More.Children
form := url.Values{}
form.Set("api_type", "json")
form.Set("link_id", postID)
form.Set("children", strings.Join(commentIDs, ","))
path := "api/morechildren"
// This was originally a GET, but with POST you can send a bigger payload
// since it's in the body and not the URI.
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
root := new(struct {
JSON struct {
Data struct {
Things things `json:"things"`
} `json:"data"`
} `json:"json"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return resp, err
}
comments := root.JSON.Data.Things.Comments
for _, c := range comments {
pc.addCommentToTree(c)
}
noMore := true
mores := root.JSON.Data.Things.Mores
for _, m := range mores {
if strings.HasPrefix(m.ParentID, kindPost+"_") {
noMore = false
}
pc.addMoreToTree(m)
}
if noMore {
pc.More = nil
}
return resp, nil
}
func (s *PostService) random(ctx context.Context, subreddits ...string) (*PostAndComments, *Response, error) {
path := "random"
if len(subreddits) > 0 {
path = fmt.Sprintf("r/%s/random", strings.Join(subreddits, "+"))
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(PostAndComments)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// RandomFromSubreddits returns a random post and its comments from the subreddits.
// If no subreddits are provided, Reddit runs the query against your subscriptions.
func (s *PostService) RandomFromSubreddits(ctx context.Context, subreddits ...string) (*PostAndComments, *Response, error) {
return s.random(ctx, subreddits...)
}
// Random returns a random post and its comments from all of Reddit.
func (s *PostService) Random(ctx context.Context) (*PostAndComments, *Response, error) {
return s.random(ctx, "all")
}
// RandomFromSubscriptions returns a random post and its comments from your subscriptions.
func (s *PostService) RandomFromSubscriptions(ctx context.Context) (*PostAndComments, *Response, error) {
return s.random(ctx)
}
// MarkVisited marks the post(s) as visited.
// This method requires a subscription to Reddit premium.
func (s *PostService) MarkVisited(ctx context.Context, ids ...string) (*Response, error) {
if len(ids) == 0 {
return nil, errors.New("must provide at least 1 id")
}
path := "api/store_visits"
form := url.Values{}
form.Set("links", strings.Join(ids, ","))
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
+1154
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
/*
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 reddit
import (
"context"
"net/http"
"golang.org/x/oauth2"
)
type oauthTokenSource struct {
ctx context.Context
config *oauth2.Config
username, password string
}
func (s *oauthTokenSource) Token() (*oauth2.Token, error) {
return s.config.PasswordCredentialsToken(s.ctx, s.username, s.password)
}
func oauthTransport(client *Client) http.RoundTripper {
// 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{
userAgent: client.UserAgent(),
Base: client.client.Transport,
}
httpClient := &http.Client{Transport: userAgentTransport}
ctx := context.Background()
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
config := &oauth2.Config{
ClientID: client.ID,
ClientSecret: client.Secret,
Endpoint: oauth2.Endpoint{
TokenURL: client.TokenURL.String(),
AuthStyle: oauth2.AuthStyleInHeader,
},
}
tokenSource := oauth2.ReuseTokenSource(nil, &oauthTokenSource{
ctx: ctx,
config: config,
username: client.Username,
password: client.Password,
})
return &oauth2.Transport{
Source: tokenSource,
Base: userAgentTransport,
}
}
+73
View File
@@ -0,0 +1,73 @@
package reddit
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:
// GO_REDDIT_CLIENT_ID to set the client's id.
// GO_REDDIT_CLIENT_SECRET to set the client's secret.
// GO_REDDIT_CLIENT_USERNAME to set the client's username.
// GO_REDDIT_CLIENT_PASSWORD to set the client's password.
func FromEnv(c *Client) error {
if v, ok := os.LookupEnv("GO_REDDIT_CLIENT_ID"); ok {
c.ID = v
}
if v, ok := os.LookupEnv("GO_REDDIT_CLIENT_SECRET"); ok {
c.Secret = v
}
if v, ok := os.LookupEnv("GO_REDDIT_CLIENT_USERNAME"); ok {
c.Username = v
}
if v, ok := os.LookupEnv("GO_REDDIT_CLIENT_PASSWORD"); ok {
c.Password = v
}
return nil
}
// WithCredentials sets the necessary values for the client to authenticate via OAuth2.
func WithCredentials(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
}
}
// 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
}
}
+57
View File
@@ -0,0 +1,57 @@
package reddit
import (
"os"
"testing"
"github.com/stretchr/testify/require"
)
func TestFromEnv(t *testing.T) {
os.Setenv("GO_REDDIT_CLIENT_ID", "id1")
defer os.Unsetenv("GO_REDDIT_CLIENT_ID")
os.Setenv("GO_REDDIT_CLIENT_SECRET", "secret1")
defer os.Unsetenv("GO_REDDIT_CLIENT_SECRET")
os.Setenv("GO_REDDIT_CLIENT_USERNAME", "username1")
defer os.Unsetenv("GO_REDDIT_CLIENT_USERNAME")
os.Setenv("GO_REDDIT_CLIENT_PASSWORD", "password1")
defer os.Unsetenv("GO_REDDIT_CLIENT_PASSWORD")
c, err := NewClient(nil, FromEnv)
require.NoError(t, 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}
require.Equal(t, expect, actual)
}
func TestWithCredentials(t *testing.T) {
withCredentials := WithCredentials("id1", "secret1", "username1", "password1")
c, err := NewClient(nil, withCredentials)
require.NoError(t, err)
require.Equal(t, "id1", c.ID)
require.Equal(t, "secret1", c.Secret)
require.Equal(t, "username1", c.Username)
require.Equal(t, "password1", c.Password)
}
func TestWithBaseURL(t *testing.T) {
baseURL := "http://localhost:8080"
c, err := NewClient(nil, WithBaseURL(baseURL))
require.NoError(t, err)
require.Equal(t, baseURL, c.BaseURL.String())
}
func TestWithTokenURL(t *testing.T) {
tokenURL := "http://localhost:8080/api/v1/access_token"
c, err := NewClient(nil, WithTokenURL(tokenURL))
require.NoError(t, err)
require.Equal(t, tokenURL, c.TokenURL.String())
}
+475
View File
@@ -0,0 +1,475 @@
package reddit
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"reflect"
"strings"
"github.com/google/go-querystring/query"
"golang.org/x/oauth2"
)
const (
libraryName = "github.com/vartanbeno/go-reddit"
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 {
userAgent string
Base http.RoundTripper
}
func (t *userAgentTransport) setUserAgent(req *http.Request) *http.Request {
req2 := cloneRequest(req)
req2.Header.Set(headerUserAgent, t.userAgent)
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)
// Client manages communication with the Reddit API.
type Client struct {
// HTTP client used to communicate with the Reddit API.
client *http.Client
BaseURL *url.URL
TokenURL *url.URL
userAgent string
ID string
Secret string
Username string
Password string
// This is the client's user ID in Reddit's database.
redditID string
Account *AccountService
Collection *CollectionService
Comment *CommentService
Emoji *EmojiService
Flair *FlairService
Listings *ListingsService
Message *MessageService
Moderation *ModerationService
Multi *MultiService
Post *PostService
Subreddit *SubredditService
User *UserService
oauth2Transport *oauth2.Transport
onRequestCompleted RequestCompletionCallback
}
// OnRequestCompleted sets the client's request completion callback.
func (c *Client) OnRequestCompleted(rc RequestCompletionCallback) {
c.onRequestCompleted = rc
}
func newClient(httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{}
}
// todo...
// Some endpoints (notably the ones to get random subreddits/posts) redirect to a
// reddit.com url, which returns a 403 Forbidden for some reason, unless the url's
// host is changed to oauth.reddit.com
httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
redirectURL := req.URL.String()
redirectURL = strings.Replace(redirectURL, "https://www.reddit.com", defaultBaseURL, 1)
reqURL, err := url.Parse(redirectURL)
if err != nil {
return err
}
req.URL = reqURL
return nil
}
baseURL, _ := url.Parse(defaultBaseURL)
tokenURL, _ := url.Parse(defaultTokenURL)
c := &Client{client: httpClient, BaseURL: baseURL, TokenURL: tokenURL}
c.Account = &AccountService{client: c}
c.Collection = &CollectionService{client: c}
c.Emoji = &EmojiService{client: c}
c.Flair = &FlairService{client: c}
c.Listings = &ListingsService{client: c}
c.Message = &MessageService{client: c}
c.Moderation = &ModerationService{client: c}
c.Multi = &MultiService{client: c}
c.Subreddit = &SubredditService{client: c}
c.User = &UserService{client: c}
postAndCommentService := &postAndCommentService{client: c}
c.Comment = &CommentService{client: c, postAndCommentService: postAndCommentService}
c.Post = &PostService{client: c, postAndCommentService: postAndCommentService}
return c
}
// 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
}
}
oauthTransport := oauthTransport(c)
c.client.Transport = oauthTransport
return
}
// UserAgent returns the client's user agent.
func (c *Client) UserAgent() string {
if c.userAgent == "" {
c.userAgent = fmt.Sprintf("golang:%s:v%s (by /u/%s)", libraryName, libraryVersion, c.Username)
}
return c.userAgent
}
// NewRequest creates an API request.
// 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) NewRequest(method string, path string, body interface{}) (*http.Request, error) {
u, err := c.BaseURL.Parse(path)
if err != nil {
return nil, err
}
buf := new(bytes.Buffer)
if body != nil {
err = json.NewEncoder(buf).Encode(body)
if err != nil {
return nil, err
}
}
reqBody := bytes.NewReader(buf.Bytes())
req, err := http.NewRequest(method, u.String(), reqBody)
if err != nil {
return nil, err
}
req.Header.Add(headerContentType, mediaTypeJSON)
req.Header.Add(headerAccept, mediaTypeJSON)
return req, nil
}
// NewRequestWithForm creates an API request with form data.
// 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) NewRequestWithForm(method string, path string, form url.Values) (*http.Request, error) {
u, err := c.BaseURL.Parse(path)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, 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
}
// Response is a PlayNetwork response. This wraps the standard http.Response returned from PlayNetwork.
type Response struct {
*http.Response
}
// newResponse creates a new Response for the provided http.Response.
func newResponse(r *http.Response) *Response {
response := Response{Response: r}
return &response
}
// Do sends an API request and returns the API response. The API response is JSON decoded and stored in the value
// pointed to by v, or returned as an error if an API error has occurred. If v implements the io.Writer interface,
// the raw response will be written to v, without attempting to decode it.
func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Response, error) {
resp, err := DoRequestWithClient(ctx, c.client, req)
if err != nil {
return nil, err
}
if c.onRequestCompleted != nil {
c.onRequestCompleted(req, resp)
}
response := newResponse(resp)
defer func() {
if rerr := response.Body.Close(); err == nil {
err = rerr
}
}()
err = CheckResponse(resp)
if err != nil {
return response, err
}
if v != nil {
if w, ok := v.(io.Writer); ok {
_, err = io.Copy(w, response.Body)
if err != nil {
return nil, err
}
} else {
err = json.NewDecoder(response.Body).Decode(v)
if err != nil {
return nil, err
}
}
}
return response, err
}
// id returns the client's Reddit ID.
func (c *Client) id(ctx context.Context) (string, *Response, error) {
if c.redditID != "" {
return c.redditID, nil, nil
}
self, resp, err := c.User.Get(ctx, c.Username)
if err != nil {
return "", resp, err
}
c.redditID = fmt.Sprintf("%s_%s", kindAccount, self.ID)
return c.redditID, resp, nil
}
// DoRequest submits an HTTP request.
func DoRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
return DoRequestWithClient(ctx, http.DefaultClient, req)
}
// DoRequestWithClient submits an HTTP request using the specified client.
func DoRequestWithClient(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
req = req.WithContext(ctx)
return client.Do(req)
}
// 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 len(jsonErrorResponse.JSON.Errors) > 0 {
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)
if err == nil && len(data) > 0 {
err := json.Unmarshal(data, errorResponse)
if err != nil {
errorResponse.Message = string(data)
}
}
return errorResponse
}
// ListOptions specifies the optional parameters to various API calls that return a listing.
type ListOptions struct {
// Maximum number of items to be returned.
// Generally, the default is 25 and max is 100.
Limit int `url:"limit,omitempty"`
// The full ID of an item in the listing to use
// as the anchor point of the list. Only items
// appearing after it will be returned.
After string `url:"after,omitempty"`
// The full ID of an item in the listing to use
// as the anchor point of the list. Only items
// appearing before it will be returned.
Before string `url:"before,omitempty"`
}
// ListSubredditOptions defines possible options used when searching for subreddits.
type ListSubredditOptions struct {
ListOptions
// One of: relevance, activity.
Sort string `url:"sort,omitempty"`
}
// ListPostOptions defines possible options used when getting posts from a subreddit.
type ListPostOptions struct {
ListOptions
// One of: hour, day, week, month, year, all.
Time string `url:"t,omitempty"`
}
// ListPostSearchOptions defines possible options used when searching for posts within a subreddit.
type ListPostSearchOptions struct {
ListPostOptions
// One of: relevance, hot, top, new, comments.
Sort string `url:"sort,omitempty"`
}
// ListUserOverviewOptions defines possible options used when getting a user's post and/or comments.
type ListUserOverviewOptions struct {
ListOptions
// One of: hot, new, top, controversial.
Sort string `url:"sort,omitempty"`
// One of: hour, day, week, month, year, all.
Time string `url:"t,omitempty"`
}
// ListDuplicatePostOptions defines possible options used when getting duplicates of a post, i.e.
// other submissions of the same URL.
type ListDuplicatePostOptions struct {
ListOptions
// If empty, it'll search for duplicates in all subreddits.
Subreddit string `url:"sr,omitempty"`
// One of: num_comments, new.
Sort string `url:"sort,omitempty"`
// If true, the search will only return duplicates that are
// crossposts of the original post.
CrosspostsOnly bool `url:"crossposts_only,omitempty"`
}
// ListModActionOptions defines possible options used when getting moderation actions in a subreddit.
type ListModActionOptions struct {
// The max for the limit parameter here is 500.
ListOptions
// If empty, the search will return all action types.
// One of: banuser, unbanuser, spamlink, removelink, approvelink, spamcomment, removecomment,
// approvecomment, addmoderator, showcomment, invitemoderator, uninvitemoderator, acceptmoderatorinvite,
// removemoderator, addcontributor, removecontributor, editsettings, editflair, distinguish, marknsfw,
// wikibanned, wikicontributor, wikiunbanned, wikipagelisted, removewikicontributor, wikirevise,
// wikipermlevel, ignorereports, unignorereports, setpermissions, setsuggestedsort, sticky, unsticky,
// setcontestmode, unsetcontestmode, lock, unlock, muteuser, unmuteuser, createrule, editrule,
// reorderrules, deleterule, spoiler, unspoiler, modmail_enrollment, community_styling, community_widgets,
// markoriginalcontent, collections, events, hidden_award, add_community_topics, remove_community_topics,
// create_scheduled_post, edit_scheduled_post, delete_scheduled_post, submit_scheduled_post,
// edit_post_requirements, invitesubscriber, submit_content_rating_survey.
Type string `url:"type,omitempty"`
// If provided, only return the actions of this moderator.
Moderator string `url:"mod,omitempty"`
}
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
}
// 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 int value
// to store v and returns a pointer to it.
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
}
+193
View File
@@ -0,0 +1,193 @@
package reddit
import (
"context"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"reflect"
"testing"
"github.com/stretchr/testify/require"
)
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,
WithCredentials("id1", "secret1", "user1", "password1"),
WithBaseURL(server.URL),
WithTokenURL(server.URL+"/api/v1/access_token"),
)
}
func teardown() {
server.Close()
}
func readFileContents(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
return "", err
}
return string(bytes), err
}
func testClientServices(t *testing.T, c *Client) {
services := []string{
"Account",
"Collection",
"Comment",
"Emoji",
"Flair",
"Listings",
"Message",
"Moderation",
"Multi",
"Post",
"Subreddit",
"User",
}
cp := reflect.ValueOf(c)
cv := reflect.Indirect(cp)
for _, s := range services {
require.Falsef(t, cv.FieldByName(s).IsNil(), "c.%s should not be nil", s)
}
}
func testClientDefaultUserAgent(t *testing.T, c *Client) {
expectedUserAgent := fmt.Sprintf("golang:%s:v%s (by /u/)", libraryName, libraryVersion)
require.Equal(t, expectedUserAgent, c.userAgent)
}
func testClientDefaults(t *testing.T, c *Client) {
testClientDefaultUserAgent(t, c)
testClientServices(t, c)
}
func TestNewClient(t *testing.T) {
c, err := NewClient(nil)
require.NoError(t, err)
testClientDefaults(t, c)
}
func TestNewClient_Error(t *testing.T) {
errorOpt := func(c *Client) error {
return errors.New("foo")
}
_, err := NewClient(nil, errorOpt)
require.EqualError(t, err, "foo")
}
func TestClient_OnRequestComplemented(t *testing.T) {
setup()
defer teardown()
var i int
cb := func(*http.Request, *http.Response) {
i++
}
client.OnRequestCompleted(cb)
mux.HandleFunc("/api/v1/test", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
})
req, err := client.NewRequest(http.MethodGet, "api/v1/test", nil)
require.NoError(t, err)
_, _ = client.Do(ctx, req, nil)
require.Equal(t, 1, i)
_, _ = client.Do(ctx, req, nil)
_, _ = client.Do(ctx, req, nil)
_, _ = client.Do(ctx, req, nil)
_, _ = client.Do(ctx, req, nil)
require.Equal(t, 5, i)
_, _ = client.Do(ctx, req, nil)
require.Equal(t, 6, i)
}
func TestClient_JSONErrorResponse(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/test", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, `{
"json": {
"errors": [
[
"TEST_ERROR",
"this is a test error",
"test field"
]
]
}
}`)
})
req, err := client.NewRequest(http.MethodGet, "api/v1/test", nil)
require.NoError(t, err)
resp, err := client.Do(ctx, req, nil)
require.IsType(t, &JSONErrorResponse{}, err)
require.EqualError(t, err, fmt.Sprintf(`GET %s/api/v1/test: 200 field "test field" caused TEST_ERROR: this is a test error`, server.URL))
require.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestClient_ErrorResponse(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/test", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{
"message": "error message"
}`)
})
req, err := client.NewRequest(http.MethodGet, "api/v1/test", nil)
require.NoError(t, err)
resp, err := client.Do(ctx, req, nil)
require.IsType(t, &ErrorResponse{}, err)
require.EqualError(t, err, fmt.Sprintf(`GET %s/api/v1/test: 403 error message`, server.URL))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
+679
View File
@@ -0,0 +1,679 @@
package reddit
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
// SubredditService handles communication with the subreddit
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_subreddits
type SubredditService struct {
client *Client
}
type rootSubreddit struct {
Kind string `json:"kind,omitempty"`
Data *Subreddit `json:"data,omitempty"`
}
type rootSubredditNames struct {
Names []string `json:"names,omitempty"`
}
// Relationship holds information about a relationship (friend/blocked).
// todo: there's also banned, wikibanned, etc.
type Relationship struct {
ID string `json:"rel_id,omitempty"`
User string `json:"name,omitempty"`
UserID string `json:"id,omitempty"`
Created *Timestamp `json:"date,omitempty"`
}
// Relationships is a listing of relationships.
type Relationships struct {
Relationships []*Relationship `json:"relationships"`
After string `json:"after"`
Before string `json:"before"`
}
// Moderator is a user who moderates a subreddit.
type Moderator struct {
*Relationship
Permissions []string `json:"mod_permissions"`
}
// Ban represents a banned relationship.
type Ban struct {
*Relationship
// nil means the ban is permanent
DaysLeft *int `json:"days_left"`
Note string `json:"note,omitempty"`
}
// Bans is a listing of bans.
type Bans struct {
Bans []*Ban `json:"bans"`
After string `json:"after"`
Before string `json:"before"`
}
func (s *SubredditService) getPosts(ctx context.Context, sort string, subreddit string, opts *ListPostOptions) (*Posts, *Response, error) {
path := sort
if subreddit != "" {
path = fmt.Sprintf("r/%s/%s", subreddit, 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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getPosts(), resp, nil
}
// HotPosts returns the hottest posts from the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If none are defined, it returns the ones from your subscribed subreddits.
// Note: when looking for hot posts in a subreddit, it will include the stickied
// posts (if any) PLUS posts from the limit parameter (25 by default).
func (s *SubredditService) HotPosts(ctx context.Context, subreddit string, opts *ListPostOptions) (*Posts, *Response, error) {
return s.getPosts(ctx, "hot", subreddit, opts)
}
// NewPosts returns the newest posts from the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If none are defined, it returns the ones from your subscribed subreddits.
func (s *SubredditService) NewPosts(ctx context.Context, subreddit string, opts *ListPostOptions) (*Posts, *Response, error) {
return s.getPosts(ctx, "new", subreddit, opts)
}
// RisingPosts returns the rising posts from the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If none are defined, it returns the ones from your subscribed subreddits.
func (s *SubredditService) RisingPosts(ctx context.Context, subreddit string, opts *ListPostOptions) (*Posts, *Response, error) {
return s.getPosts(ctx, "rising", subreddit, opts)
}
// ControversialPosts returns the most controversial posts from the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If none are defined, it returns the ones from your subscribed subreddits.
func (s *SubredditService) ControversialPosts(ctx context.Context, subreddit string, opts *ListPostOptions) (*Posts, *Response, error) {
return s.getPosts(ctx, "controversial", subreddit, opts)
}
// TopPosts returns the top posts from the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If none are defined, it returns the ones from your subscribed subreddits.
func (s *SubredditService) TopPosts(ctx context.Context, subreddit string, opts *ListPostOptions) (*Posts, *Response, error) {
return s.getPosts(ctx, "top", subreddit, opts)
}
// Get gets a subreddit by name.
func (s *SubredditService) Get(ctx context.Context, name string) (*Subreddit, *Response, error) {
if name == "" {
return nil, nil, errors.New("name: cannot be empty")
}
path := fmt.Sprintf("r/%s/about", name)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(rootSubreddit)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Data, resp, nil
}
// Popular returns popular subreddits.
func (s *SubredditService) Popular(ctx context.Context, opts *ListSubredditOptions) (*Subreddits, *Response, error) {
return s.getSubreddits(ctx, "subreddits/popular", opts)
}
// New returns new subreddits.
func (s *SubredditService) New(ctx context.Context, opts *ListSubredditOptions) (*Subreddits, *Response, error) {
return s.getSubreddits(ctx, "subreddits/new", opts)
}
// Gold returns gold subreddits (i.e. only accessible to users with gold).
// It seems like it returns an empty list if you don't have gold.
func (s *SubredditService) Gold(ctx context.Context, opts *ListSubredditOptions) (*Subreddits, *Response, error) {
return s.getSubreddits(ctx, "subreddits/gold", opts)
}
// Default returns default subreddits.
func (s *SubredditService) Default(ctx context.Context, opts *ListSubredditOptions) (*Subreddits, *Response, error) {
return s.getSubreddits(ctx, "subreddits/default", opts)
}
// Subscribed returns the list of subreddits you are subscribed to.
func (s *SubredditService) Subscribed(ctx context.Context, opts *ListSubredditOptions) (*Subreddits, *Response, error) {
return s.getSubreddits(ctx, "subreddits/mine/subscriber", opts)
}
// Approved returns the list of subreddits you are an approved user in.
func (s *SubredditService) Approved(ctx context.Context, opts *ListSubredditOptions) (*Subreddits, *Response, error) {
return s.getSubreddits(ctx, "subreddits/mine/contributor", opts)
}
// Moderated returns the list of subreddits you are a moderator of.
func (s *SubredditService) Moderated(ctx context.Context, opts *ListSubredditOptions) (*Subreddits, *Response, error) {
return s.getSubreddits(ctx, "subreddits/mine/moderator", opts)
}
// GetSticky1 returns the first stickied post on a subreddit (if it exists).
func (s *SubredditService) GetSticky1(ctx context.Context, subreddit string) (*PostAndComments, *Response, error) {
return s.getSticky(ctx, subreddit, 1)
}
// GetSticky2 returns the second stickied post on a subreddit (if it exists).
func (s *SubredditService) GetSticky2(ctx context.Context, subreddit string) (*PostAndComments, *Response, error) {
return s.getSticky(ctx, subreddit, 2)
}
func (s *SubredditService) handleSubscription(ctx context.Context, form url.Values) (*Response, error) {
path := "api/subscribe"
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Subscribe subscribes to subreddits based on their names.
func (s *SubredditService) Subscribe(ctx context.Context, subreddits ...string) (*Response, error) {
form := url.Values{}
form.Set("action", "sub")
form.Set("sr_name", strings.Join(subreddits, ","))
return s.handleSubscription(ctx, form)
}
// SubscribeByID subscribes to subreddits based on their id.
func (s *SubredditService) SubscribeByID(ctx context.Context, ids ...string) (*Response, error) {
form := url.Values{}
form.Set("action", "sub")
form.Set("sr", strings.Join(ids, ","))
return s.handleSubscription(ctx, form)
}
// Unsubscribe unsubscribes from subreddits based on their names.
func (s *SubredditService) Unsubscribe(ctx context.Context, subreddits ...string) (*Response, error) {
form := url.Values{}
form.Set("action", "unsub")
form.Set("sr_name", strings.Join(subreddits, ","))
return s.handleSubscription(ctx, form)
}
// UnsubscribeByID unsubscribes from subreddits based on their id.
func (s *SubredditService) UnsubscribeByID(ctx context.Context, ids ...string) (*Response, error) {
form := url.Values{}
form.Set("action", "unsub")
form.Set("sr", strings.Join(ids, ","))
return s.handleSubscription(ctx, form)
}
// Favorite favorites the subreddit.
func (s *SubredditService) Favorite(ctx context.Context, subreddit string) (*Response, error) {
path := "api/favorite"
form := url.Values{}
form.Set("sr_name", subreddit)
form.Set("make_favorite", "true")
form.Set("api_type", "json")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Unfavorite unfavorites the subreddit.
func (s *SubredditService) Unfavorite(ctx context.Context, subreddit string) (*Response, error) {
path := "api/favorite"
form := url.Values{}
form.Set("sr_name", subreddit)
form.Set("make_favorite", "false")
form.Set("api_type", "json")
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Search searches for subreddits.
func (s *SubredditService) Search(ctx context.Context, query string, opts *ListSubredditOptions) (*Subreddits, *Response, error) {
path := "subreddits/search"
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
type params struct {
Query string `url:"q"`
}
path, err = addOptions(path, params{query})
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getSubreddits(), resp, nil
}
// SearchNames searches for subreddits with names beginning with the query provided.
func (s *SubredditService) SearchNames(ctx context.Context, query string) ([]string, *Response, error) {
path := fmt.Sprintf("api/search_reddit_names?query=%s", query)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(rootSubredditNames)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Names, resp, nil
}
// SearchPosts searches for posts in the specified subreddit.
// To search through multiple, separate the names with a plus (+), e.g. "golang+test".
// If no subreddit is provided, the search is run against r/all.
func (s *SubredditService) SearchPosts(ctx context.Context, query string, subreddit string, opts *ListPostSearchOptions) (*Posts, *Response, error) {
if subreddit == "" {
subreddit = "all"
}
path := fmt.Sprintf("r/%s/search", subreddit)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
type params struct {
Query string `url:"q"`
RestrictSubreddits bool `url:"restrict_sr,omitempty"`
}
notAll := !strings.EqualFold(subreddit, "all")
path, err = addOptions(path, params{query, notAll})
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getPosts(), resp, nil
}
func (s *SubredditService) getSubreddits(ctx context.Context, path string, opts *ListSubredditOptions) (*Subreddits, *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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getSubreddits(), resp, nil
}
// 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.
func (s *SubredditService) getSticky(ctx context.Context, subreddit string, num int) (*PostAndComments, *Response, error) {
type params struct {
Num int `url:"num"`
}
path := fmt.Sprintf("r/%s/about/sticky", subreddit)
path, err := addOptions(path, params{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(PostAndComments)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// todo: sr_detail's NSFW indicator is over_18 instead of over18
func (s *SubredditService) random(ctx context.Context, nsfw bool) (*Subreddit, *Response, error) {
path := "r/random"
if nsfw {
path = "r/randnsfw"
}
type params struct {
ExpandSubreddit bool `url:"sr_detail"`
Limit int `url:"limit,omitempty"`
}
path, err := addOptions(path, params{true, 1})
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(struct {
Data struct {
Children []struct {
Data struct {
Subreddit *Subreddit `json:"sr_detail"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
var sr *Subreddit
if len(root.Data.Children) > 0 {
sr = root.Data.Children[0].Data.Subreddit
}
return sr, resp, nil
}
// Random returns a random SFW subreddit.
func (s *SubredditService) Random(ctx context.Context) (*Subreddit, *Response, error) {
return s.random(ctx, false)
}
// RandomNSFW returns a random NSFW subreddit.
func (s *SubredditService) RandomNSFW(ctx context.Context) (*Subreddit, *Response, error) {
return s.random(ctx, true)
}
// SubmissionText gets the submission text for the subreddit.
// This text is set by the subreddit moderators and intended to be displayed on the submission form.
func (s *SubredditService) SubmissionText(ctx context.Context, name string) (string, *Response, error) {
if name == "" {
return "", nil, errors.New("name: cannot be empty")
}
path := fmt.Sprintf("r/%s/api/submit_text", name)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return "", nil, err
}
root := new(struct {
Text string `json:"submit_text"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return "", resp, err
}
return root.Text, resp, err
}
// Banned gets banned users from the subreddit.
func (s *SubredditService) Banned(ctx context.Context, subreddit string, opts *ListOptions) (*Bans, *Response, error) {
path := fmt.Sprintf("r/%s/about/banned", subreddit)
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(struct {
Data struct {
Bans []*Ban `json:"children"`
After string `json:"after"`
Before string `json:"before"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
bans := &Bans{
Bans: root.Data.Bans,
After: root.Data.After,
Before: root.Data.Before,
}
return bans, resp, nil
}
// Muted gets muted users from the subreddit.
func (s *SubredditService) Muted(ctx context.Context, subreddit string, opts *ListOptions) (*Relationships, *Response, error) {
path := fmt.Sprintf("r/%s/about/muted", subreddit)
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(struct {
Data struct {
Relationships []*Relationship `json:"children"`
After string `json:"after"`
Before string `json:"before"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
relationships := &Relationships{
Relationships: root.Data.Relationships,
After: root.Data.After,
Before: root.Data.Before,
}
return relationships, resp, nil
}
// WikiBanned gets banned users from the subreddit.
func (s *SubredditService) WikiBanned(ctx context.Context, subreddit string, opts *ListOptions) (*Bans, *Response, error) {
path := fmt.Sprintf("r/%s/about/wikibanned", subreddit)
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
}
var root struct {
Data struct {
Bans []*Ban `json:"children"`
After string `json:"after"`
Before string `json:"before"`
} `json:"data"`
}
resp, err := s.client.Do(ctx, req, &root)
if err != nil {
return nil, resp, err
}
bans := &Bans{
Bans: root.Data.Bans,
After: root.Data.After,
Before: root.Data.Before,
}
return bans, resp, nil
}
// Contributors gets contributors (also known as approved users) from the subreddit.
func (s *SubredditService) Contributors(ctx context.Context, subreddit string, opts *ListOptions) (*Relationships, *Response, error) {
path := fmt.Sprintf("r/%s/about/contributors", subreddit)
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(struct {
Data struct {
Relationships []*Relationship `json:"children"`
After string `json:"after"`
Before string `json:"before"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
relationships := &Relationships{
Relationships: root.Data.Relationships,
After: root.Data.After,
Before: root.Data.Before,
}
return relationships, resp, nil
}
// WikiContributors gets contributors of the wiki from the subreddit.
func (s *SubredditService) WikiContributors(ctx context.Context, subreddit string, opts *ListOptions) (*Relationships, *Response, error) {
path := fmt.Sprintf("r/%s/about/wikicontributors", subreddit)
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(struct {
Data struct {
Relationships []*Relationship `json:"children"`
After string `json:"after"`
Before string `json:"before"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
relationships := &Relationships{
Relationships: root.Data.Relationships,
After: root.Data.After,
Before: root.Data.Before,
}
return relationships, resp, nil
}
// Moderators gets the moderators of the subreddit.
func (s *SubredditService) Moderators(ctx context.Context, subreddit string) ([]*Moderator, *Response, error) {
path := fmt.Sprintf("r/%s/about/moderators", subreddit)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(struct {
Data struct {
Moderators []*Moderator `json:"children"`
} `json:"data"`
})
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.Data.Moderators, resp, nil
}
File diff suppressed because it is too large Load Diff
+428
View File
@@ -0,0 +1,428 @@
package reddit
import (
"encoding/json"
)
const (
kindComment = "t1"
kindAccount = "t2"
kindPost = "t3"
kindMessage = "t4"
kindSubreddit = "t5"
kindAward = "t6"
kindListing = "Listing"
kindKarmaList = "KarmaList"
kindTrophyList = "TrophyList"
kindUserList = "UserList"
kindMore = "more"
kindModAction = "modaction"
)
// thing is an entity on Reddit.
// Its kind reprsents what it is and what is stored in the Data field
// e.g. t1 = comment, t2 = user, t3 = post, etc.
type thing struct {
Kind string `json:"kind"`
Data json.RawMessage `json:"data"`
}
type rootListing struct {
Kind string `json:"kind"`
Data listing `json:"data"`
}
// listing holds things coming from the Reddit API
// It also contains the after/before anchors useful for subsequent requests
type listing struct {
Things things `json:"children"`
After string `json:"after"`
Before string `json:"before"`
}
type things struct {
Comments []*Comment
Mores []*More
Users []*User
Posts []*Post
Subreddits []*Subreddit
ModActions []*ModAction
}
// init initializes or clears the listing.
func (t *things) init() {
t.Comments = make([]*Comment, 0)
t.Mores = make([]*More, 0)
t.Users = make([]*User, 0)
t.Posts = make([]*Post, 0)
t.Subreddits = make([]*Subreddit, 0)
t.ModActions = make([]*ModAction, 0)
}
// UnmarshalJSON implements the json.Unmarshaler interface.
func (t *things) UnmarshalJSON(b []byte) error {
t.init()
var things []thing
if err := json.Unmarshal(b, &things); err != nil {
return err
}
for _, thing := range things {
switch thing.Kind {
case kindComment:
v := new(Comment)
if err := json.Unmarshal(thing.Data, v); err == nil {
t.Comments = append(t.Comments, v)
}
case kindMore:
v := new(More)
if err := json.Unmarshal(thing.Data, v); err == nil {
t.Mores = append(t.Mores, v)
}
case kindAccount:
v := new(User)
if err := json.Unmarshal(thing.Data, v); err == nil {
t.Users = append(t.Users, v)
}
case kindPost:
v := new(Post)
if err := json.Unmarshal(thing.Data, v); err == nil {
t.Posts = append(t.Posts, v)
}
case kindSubreddit:
v := new(Subreddit)
if err := json.Unmarshal(thing.Data, v); err == nil {
t.Subreddits = append(t.Subreddits, v)
}
case kindModAction:
v := new(ModAction)
if err := json.Unmarshal(thing.Data, v); err == nil {
t.ModActions = append(t.ModActions, v)
}
}
}
return nil
}
// Comment is a comment posted by a user.
type Comment struct {
ID string `json:"id,omitempty"`
FullID string `json:"name,omitempty"`
Created *Timestamp `json:"created_utc,omitempty"`
Edited *Timestamp `json:"edited,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Permalink string `json:"permalink,omitempty"`
Body string `json:"body,omitempty"`
Author string `json:"author,omitempty"`
AuthorID string `json:"author_fullname,omitempty"`
AuthorFlairText string `json:"author_flair_text,omitempty"`
AuthorFlairID string `json:"author_flair_template_id,omitempty"`
SubredditName string `json:"subreddit,omitempty"`
SubredditNamePrefixed string `json:"subreddit_name_prefixed,omitempty"`
SubredditID string `json:"subreddit_id,omitempty"`
// Indicates if you've upvote/downvoted (true/false).
// If neither, it will be nil.
Likes *bool `json:"likes"`
Score int `json:"score"`
Controversiality int `json:"controversiality"`
PostID string `json:"link_id,omitempty"`
// This doesn't appear consistently.
PostTitle string `json:"link_title,omitempty"`
// This doesn't appear consistently.
PostPermalink string `json:"link_permalink,omitempty"`
// This doesn't appear consistently.
PostAuthor string `json:"link_author,omitempty"`
// This doesn't appear consistently.
PostNumComments *int `json:"num_comments,omitempty"`
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"`
Replies Replies `json:"replies"`
}
// HasMore determines whether the comment has more replies to load in its reply tree.
func (c *Comment) HasMore() bool {
return c.Replies.More != nil && len(c.Replies.More.Children) > 0
}
// addCommentToReplies traverses the comment tree to find the one
// that the 2nd comment is replying to. It then adds it to its replies.
func (c *Comment) addCommentToReplies(comment *Comment) {
if c.FullID == comment.ParentID {
c.Replies.Comments = append(c.Replies.Comments, comment)
return
}
for _, reply := range c.Replies.Comments {
reply.addCommentToReplies(comment)
}
}
func (c *Comment) addMoreToReplies(more *More) {
if c.FullID == more.ParentID {
c.Replies.More = more
return
}
for _, reply := range c.Replies.Comments {
reply.addMoreToReplies(more)
}
}
// Replies holds replies to a comment.
// It contains both comments and "more" comments, which are entrypoints to other
// comments that were left out.
type Replies struct {
Comments []*Comment `json:"comments,omitempty"`
More *More `json:"-"`
}
// 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) == `""` {
r = nil
return nil
}
root := new(rootListing)
err := json.Unmarshal(data, root)
if err != nil {
return err
}
r.Comments = root.Data.Things.Comments
r.More = root.getFirstMore()
return nil
}
// MarshalJSON implements the json.Marshaler interface.
func (r *Replies) MarshalJSON() ([]byte, error) {
if r == nil || len(r.Comments) == 0 {
return []byte(`null`), nil
}
return json.Marshal(r.Comments)
}
// More holds information used to retrieve additional comments omitted from a base comment tree.
type More struct {
ID string `json:"id"`
FullID string `json:"name"`
ParentID string `json:"parent_id"`
// Total number of replies to the parent + replies to those replies (recursively).
Count int `json:"count"`
// Number of comment nodes from the parent down to the furthest comment node.
Depth int `json:"depth"`
Children []string `json:"children"`
}
// Post is a submitted post on Reddit.
type Post struct {
ID string `json:"id,omitempty"`
FullID string `json:"name,omitempty"`
Created *Timestamp `json:"created_utc,omitempty"`
Edited *Timestamp `json:"edited,omitempty"`
Permalink string `json:"permalink,omitempty"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"selftext,omitempty"`
// Indicates if you've upvote/downvoted (true/false).
// If neither, it will be nil.
Likes *bool `json:"likes"`
Score int `json:"score"`
UpvoteRatio float32 `json:"upvote_ratio"`
NumberOfComments int `json:"num_comments"`
SubredditName string `json:"subreddit,omitempty"`
SubredditNamePrefixed string `json:"subreddit_name_prefixed,omitempty"`
SubredditID string `json:"subreddit_id,omitempty"`
Author string `json:"author,omitempty"`
AuthorID string `json:"author_fullname,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"`
}
// Subreddit holds information about a subreddit
type Subreddit struct {
ID string `json:"id,omitempty"`
FullID string `json:"name,omitempty"`
Created *Timestamp `json:"created_utc,omitempty"`
URL string `json:"url,omitempty"`
Name string `json:"display_name,omitempty"`
NamePrefixed string `json:"display_name_prefixed,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"public_description,omitempty"`
Type string `json:"subreddit_type,omitempty"`
SuggestedCommentSort string `json:"suggested_comment_sort,omitempty"`
Subscribers int `json:"subscribers"`
ActiveUserCount *int `json:"active_user_count,omitempty"`
NSFW bool `json:"over18"`
UserIsMod bool `json:"user_is_moderator"`
Subscribed bool `json:"user_is_subscriber"`
Favorite bool `json:"user_has_favorited"`
}
func (l *rootListing) getComments() *Comments {
return &Comments{
Comments: l.Data.Things.Comments,
After: l.Data.After,
Before: l.Data.Before,
}
}
func (l *rootListing) getFirstMore() *More {
if len(l.Data.Things.Mores) == 0 {
return nil
}
return l.Data.Things.Mores[0]
}
func (l *rootListing) getUsers() *Users {
return &Users{
Users: l.Data.Things.Users,
After: l.Data.After,
Before: l.Data.Before,
}
}
func (l *rootListing) getPosts() *Posts {
return &Posts{
Posts: l.Data.Things.Posts,
After: l.Data.After,
Before: l.Data.Before,
}
}
func (l *rootListing) getSubreddits() *Subreddits {
return &Subreddits{
Subreddits: l.Data.Things.Subreddits,
After: l.Data.After,
Before: l.Data.Before,
}
}
func (l *rootListing) getModActions() *ModActions {
return &ModActions{
ModActions: l.Data.Things.ModActions,
After: l.Data.After,
Before: l.Data.Before,
}
}
// Comments is a list of comments
type Comments struct {
Comments []*Comment `json:"comments"`
After string `json:"after"`
Before string `json:"before"`
}
// Users is a list of users
type Users struct {
Users []*User `json:"users"`
After string `json:"after"`
Before string `json:"before"`
}
// Subreddits is a list of subreddits
type Subreddits struct {
Subreddits []*Subreddit `json:"subreddits"`
After string `json:"after"`
Before string `json:"before"`
}
// Posts is a list of posts.
type Posts struct {
Posts []*Post `json:"posts"`
After string `json:"after"`
Before string `json:"before"`
}
// ModActions is a list of moderator actions.
type ModActions struct {
ModActions []*ModAction `json:"moderator_actions"`
After string `json:"after"`
Before string `json:"before"`
}
// PostAndComments is a post and its comments.
type PostAndComments struct {
Post *Post `json:"post"`
Comments []*Comment `json:"comments"`
More *More `json:"-"`
}
// 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 (pc *PostAndComments) UnmarshalJSON(data []byte) error {
var l [2]rootListing
err := json.Unmarshal(data, &l)
if err != nil {
return err
}
post := l[0].getPosts().Posts[0]
comments := l[1].getComments().Comments
moreComments := l[1].getFirstMore()
pc.Post = post
pc.Comments = comments
pc.More = moreComments
return nil
}
// HasMore determines whether the post has more replies to load in its reply tree.
func (pc *PostAndComments) HasMore() bool {
return pc.More != nil && len(pc.More.Children) > 0
}
func (pc *PostAndComments) addCommentToTree(comment *Comment) {
if pc.Post.FullID == comment.ParentID {
pc.Comments = append(pc.Comments, comment)
return
}
for _, reply := range pc.Comments {
reply.addCommentToReplies(comment)
}
}
func (pc *PostAndComments) addMoreToTree(more *More) {
if pc.Post.FullID == more.ParentID {
pc.More = more
}
for _, reply := range pc.Comments {
reply.addMoreToReplies(more)
}
}
+52
View File
@@ -0,0 +1,52 @@
package reddit
import (
"strconv"
"time"
)
// Timestamp represents a time that can be unmarshalled from a JSON string
// formatted as either an RFC3339 or Unix timestamp.
type Timestamp struct {
time.Time
}
// MarshalJSON implements the json.Marshaler interface.
func (t *Timestamp) MarshalJSON() ([]byte, error) {
if t == nil || t.Time.IsZero() {
return []byte(`false`), nil
}
parsed := t.Time.Format(time.RFC3339)
return []byte(`"` + parsed + `"`), nil
}
// UnmarshalJSON implements the json.Unmarshaler interface.
// Time is expected in RFC3339 or Unix format.
func (t *Timestamp) UnmarshalJSON(data []byte) (err error) {
str := string(data)
// "edited" for posts and comments is either false, or a timestamp.
if str == "false" {
return
}
f, err := strconv.ParseFloat(str, 64)
if err == nil {
t.Time = time.Unix(int64(f), 0).UTC()
} else {
t.Time, err = time.Parse(`"`+time.RFC3339+`"`, str)
}
return
}
// Equal reports whether t and u are equal based on time.Equal
func (t Timestamp) Equal(u Timestamp) bool {
return t.Time.Equal(u.Time)
}
// Before reports whether u is before t based on time.Before
func (t Timestamp) Before(u Timestamp) bool {
return t.Time.Before(u.Time)
}
+182
View File
@@ -0,0 +1,182 @@
package reddit
import (
"encoding/json"
"fmt"
"testing"
"time"
)
const (
emptyTimeStr = `"0001-01-01T00:00:00Z"`
referenceTimeStr = `"2006-01-02T15:04:05Z"`
referenceUnixTimeStr = `1136214245`
)
var (
referenceTime = time.Date(2006, time.January, 02, 15, 04, 05, 0, time.UTC)
unixOrigin = time.Unix(0, 0).In(time.UTC)
)
func TestTimestamp_Marshal(t *testing.T) {
testCases := []struct {
desc string
data Timestamp
want string
wantErr bool
equal bool
}{
{"Reference", Timestamp{referenceTime}, referenceTimeStr, false, true},
{"Empty", Timestamp{}, emptyTimeStr, false, true},
{"Mismatch", Timestamp{}, referenceTimeStr, false, false},
}
for _, tc := range testCases {
out, err := json.Marshal(tc.data)
if gotErr := err != nil; gotErr != tc.wantErr {
t.Fatalf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err)
}
got := string(out)
equal := got == tc.want
if (got == tc.want) != tc.equal {
t.Fatalf("%s: got=%s, want=%s, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal)
}
}
}
func TestTimestamp_Unmarshal(t *testing.T) {
testCases := []struct {
desc string
data string
want Timestamp
wantErr bool
equal bool
}{
{"Reference", referenceTimeStr, Timestamp{referenceTime}, false, true},
{"ReferenceUnix", referenceUnixTimeStr, Timestamp{referenceTime}, false, true},
{"Empty", emptyTimeStr, Timestamp{}, false, true},
{"UnixStart", `0`, Timestamp{unixOrigin}, false, true},
{"Mismatch", referenceTimeStr, Timestamp{}, false, false},
{"MismatchUnix", `0`, Timestamp{}, false, false},
{"Invalid", `"asdf"`, Timestamp{referenceTime}, true, false},
}
for _, tc := range testCases {
var got Timestamp
err := json.Unmarshal([]byte(tc.data), &got)
if gotErr := err != nil; gotErr != tc.wantErr {
t.Fatalf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err)
continue
}
equal := got.Equal(tc.want)
if equal != tc.equal {
t.Fatalf("%s: got=%#v, want=%#v, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal)
}
}
}
func TestTimstamp_MarshalReflexivity(t *testing.T) {
testCases := []struct {
desc string
data Timestamp
}{
{"Reference", Timestamp{referenceTime}},
{"Empty", Timestamp{}},
}
for _, tc := range testCases {
data, err := json.Marshal(tc.data)
if err != nil {
t.Fatalf("%s: Marshal err=%v", tc.desc, err)
}
var got Timestamp
err = json.Unmarshal(data, &got)
if err != nil {
t.Fatalf("%s: Unmarshal err=%v", tc.desc, err)
}
if !got.Equal(tc.data) {
t.Fatalf("%s: %+v != %+v", tc.desc, got, data)
}
}
}
type WrappedTimestamp struct {
A int
Time Timestamp
}
func TestWrappedTimstamp_Marshal(t *testing.T) {
testCases := []struct {
desc string
data WrappedTimestamp
want string
wantErr bool
equal bool
}{
{"Reference", WrappedTimestamp{0, Timestamp{referenceTime}}, fmt.Sprintf(`{"A":0,"Time":%s}`, referenceTimeStr), false, true},
{"Empty", WrappedTimestamp{}, fmt.Sprintf(`{"A":0,"Time":%s}`, emptyTimeStr), false, true},
{"Mismatch", WrappedTimestamp{}, fmt.Sprintf(`{"A":0,"Time":%s}`, referenceTimeStr), false, false},
}
for _, tc := range testCases {
out, err := json.Marshal(tc.data)
if gotErr := err != nil; gotErr != tc.wantErr {
t.Fatalf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err)
}
got := string(out)
equal := got == tc.want
if equal != tc.equal {
t.Fatalf("%s: got=%s, want=%s, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal)
}
}
}
func TestWrappedTimstamp_Unmarshal(t *testing.T) {
testCases := []struct {
desc string
data string
want WrappedTimestamp
wantErr bool
equal bool
}{
{"Reference", referenceTimeStr, WrappedTimestamp{0, Timestamp{referenceTime}}, false, true},
{"ReferenceUnix", referenceUnixTimeStr, WrappedTimestamp{0, Timestamp{referenceTime}}, false, true},
{"Empty", emptyTimeStr, WrappedTimestamp{0, Timestamp{}}, false, true},
{"UnixStart", `0`, WrappedTimestamp{0, Timestamp{unixOrigin}}, false, true},
{"Mismatch", referenceTimeStr, WrappedTimestamp{0, Timestamp{}}, false, false},
{"MismatchUnix", `0`, WrappedTimestamp{0, Timestamp{}}, false, false},
{"Invalid", `"asdf"`, WrappedTimestamp{0, Timestamp{referenceTime}}, true, false},
}
for _, tc := range testCases {
var got Timestamp
err := json.Unmarshal([]byte(tc.data), &got)
if gotErr := err != nil; gotErr != tc.wantErr {
t.Fatalf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err)
continue
}
equal := got.Time.Equal(tc.want.Time.Time)
if equal != tc.equal {
t.Fatalf("%s: got=%#v, want=%#v, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal)
}
}
}
func TestWrappedTimstamp_MarshalReflexivity(t *testing.T) {
testCases := []struct {
desc string
data WrappedTimestamp
}{
{"Reference", WrappedTimestamp{0, Timestamp{referenceTime}}},
{"Empty", WrappedTimestamp{0, Timestamp{}}},
}
for _, tc := range testCases {
bytes, err := json.Marshal(tc.data)
if err != nil {
t.Fatalf("%s: Marshal err=%v", tc.desc, err)
}
var got WrappedTimestamp
err = json.Unmarshal(bytes, &got)
if err != nil {
t.Fatalf("%s: Unmarshal err=%v", tc.desc, err)
}
if !got.Time.Equal(tc.data.Time) {
t.Fatalf("%s: %+v != %+v", tc.desc, got, tc.data)
}
}
}
+592
View File
@@ -0,0 +1,592 @@
package reddit
import (
"context"
"fmt"
"net/http"
"net/url"
)
// UserService handles communication with the user
// related methods of the Reddit API.
//
// Reddit API docs: https://www.reddit.com/dev/api/#section_users
type UserService struct {
client *Client
}
type rootUser struct {
Kind string `json:"kind,omitempty"`
Data *User `json:"data,omitempty"`
}
// User represents a Reddit user.
type User struct {
// this is not the full ID, watch out.
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Created *Timestamp `json:"created_utc,omitempty"`
PostKarma 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"`
IsSuspended bool `json:"is_suspended"`
}
// UserSummary represents a Reddit user, but
// contains fewer pieces of information.
type UserSummary struct {
Name string `json:"name,omitempty"`
Created *Timestamp `json:"created_utc,omitempty"`
PostKarma int `json:"link_karma"`
CommentKarma int `json:"comment_karma"`
NSFW bool `json:"profile_over_18"`
}
// Blocked represents a blocked relationship.
type Blocked struct {
Blocked string `json:"name,omitempty"`
BlockedID string `json:"id,omitempty"`
Created *Timestamp `json:"date,omitempty"`
}
type rootTrophyListing struct {
Kind string `json:"kind,omitempty"`
Data struct {
Trophies []rootTrophy `json:"trophies"`
} `json:"data"`
}
type rootTrophy struct {
Kind string `json:"kind,omitempty"`
Data *Trophy `json:"data,omitempty"`
}
// Trophy is a Reddit award.
type Trophy struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// Get returns information about the user.
func (s *UserService) 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(rootUser)
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 *UserService) GetMultipleByID(ctx context.Context, ids ...string) (map[string]*UserSummary, *Response, error) {
type params struct {
IDs []string `url:"ids,omitempty,comma"`
}
path := "api/user_data_by_account_ids"
path, err := addOptions(path, params{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 := make(map[string]*UserSummary)
resp, err := s.client.Do(ctx, req, &root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// UsernameAvailable checks whether a username is available for registration.
func (s *UserService) UsernameAvailable(ctx context.Context, username string) (bool, *Response, error) {
type params struct {
User string `url:"user"`
}
path := "api/username_available"
path, err := addOptions(path, params{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 err != nil {
return false, resp, err
}
return *root, resp, nil
}
// Overview returns a list of your posts and comments.
func (s *UserService) Overview(ctx context.Context, opts *ListUserOverviewOptions) (*Posts, *Comments, *Response, error) {
return s.OverviewOf(ctx, s.client.Username, opts)
}
// OverviewOf returns a list of the user's posts and comments.
func (s *UserService) OverviewOf(ctx context.Context, username string, opts *ListUserOverviewOptions) (*Posts, *Comments, *Response, error) {
path := fmt.Sprintf("user/%s/overview", username)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, nil, err
}
root := new(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, nil, resp, err
}
return root.getPosts(), root.getComments(), resp, nil
}
// Posts returns a list of your posts.
func (s *UserService) Posts(ctx context.Context, opts *ListUserOverviewOptions) (*Posts, *Response, error) {
return s.PostsOf(ctx, s.client.Username, opts)
}
// PostsOf returns a list of the user's posts.
func (s *UserService) PostsOf(ctx context.Context, username string, opts *ListUserOverviewOptions) (*Posts, *Response, error) {
path := fmt.Sprintf("user/%s/submitted", username)
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getPosts(), resp, nil
}
// Comments returns a list of your comments.
func (s *UserService) Comments(ctx context.Context, opts *ListUserOverviewOptions) (*Comments, *Response, error) {
return s.CommentsOf(ctx, s.client.Username, opts)
}
// CommentsOf returns a list of the user's comments.
func (s *UserService) CommentsOf(ctx context.Context, username string, opts *ListUserOverviewOptions) (*Comments, *Response, error) {
path := fmt.Sprintf("user/%s/comments", username)
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getComments(), resp, nil
}
// Saved returns a list of the user's saved posts and comments.
func (s *UserService) Saved(ctx context.Context, opts *ListUserOverviewOptions) (*Posts, *Comments, *Response, error) {
path := fmt.Sprintf("user/%s/saved", s.client.Username)
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, nil, err
}
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, nil, err
}
root := new(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, nil, resp, err
}
return root.getPosts(), root.getComments(), resp, nil
}
// Upvoted returns a list of your upvoted posts.
func (s *UserService) Upvoted(ctx context.Context, opts *ListUserOverviewOptions) (*Posts, *Response, error) {
return s.UpvotedOf(ctx, s.client.Username, opts)
}
// UpvotedOf returns a list of the user's upvoted posts.
// The user's votes must be public for this to work (unless the user is you).
func (s *UserService) UpvotedOf(ctx context.Context, username string, opts *ListUserOverviewOptions) (*Posts, *Response, error) {
path := fmt.Sprintf("user/%s/upvoted", username)
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getPosts(), resp, nil
}
// Downvoted returns a list of your downvoted posts.
func (s *UserService) Downvoted(ctx context.Context, opts *ListUserOverviewOptions) (*Posts, *Response, error) {
return s.DownvotedOf(ctx, s.client.Username, opts)
}
// DownvotedOf returns a list of the user's downvoted posts.
// The user's votes must be public for this to work (unless the user is you).
func (s *UserService) DownvotedOf(ctx context.Context, username string, opts *ListUserOverviewOptions) (*Posts, *Response, error) {
path := fmt.Sprintf("user/%s/downvoted", username)
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getPosts(), resp, nil
}
// Hidden returns a list of the user's hidden posts.
func (s *UserService) Hidden(ctx context.Context, opts *ListUserOverviewOptions) (*Posts, *Response, error) {
path := fmt.Sprintf("user/%s/hidden", s.client.Username)
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getPosts(), resp, nil
}
// Gilded returns a list of the user's gilded posts.
func (s *UserService) Gilded(ctx context.Context, opts *ListUserOverviewOptions) (*Posts, *Response, error) {
path := fmt.Sprintf("user/%s/gilded", s.client.Username)
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getPosts(), resp, nil
}
// GetFriendship returns relationship details with the specified user.
// If the user is not your friend, it will return an error.
func (s *UserService) GetFriendship(ctx context.Context, username string) (*Relationship, *Response, error) {
path := fmt.Sprintf("api/v1/me/friends/%s", username)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(Relationship)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// Friend friends a user.
func (s *UserService) Friend(ctx context.Context, username string) (*Relationship, *Response, error) {
type request struct {
Username string `json:"name"`
}
path := fmt.Sprintf("api/v1/me/friends/%s", username)
body := request{username}
req, err := s.client.NewRequest(http.MethodPut, path, body)
if err != nil {
return nil, nil, err
}
root := new(Relationship)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// Unfriend unfriends a user.
func (s *UserService) Unfriend(ctx context.Context, username string) (*Response, error) {
path := fmt.Sprintf("api/v1/me/friends/%s", username)
req, err := s.client.NewRequest(http.MethodDelete, path, nil)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Block blocks a user.
func (s *UserService) Block(ctx context.Context, username string) (*Blocked, *Response, error) {
path := "api/block_user"
form := url.Values{}
form.Set("name", username)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, nil, err
}
root := new(Blocked)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// BlockByID blocks a user via their full id.
func (s *UserService) BlockByID(ctx context.Context, id string) (*Blocked, *Response, error) {
path := "api/block_user"
form := url.Values{}
form.Set("account_id", id)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, nil, err
}
root := new(Blocked)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root, resp, nil
}
// Unblock unblocks a user.
func (s *UserService) Unblock(ctx context.Context, username string) (*Response, error) {
selfID, resp, err := s.client.id(ctx)
if err != nil {
return resp, err
}
path := "api/unfriend"
form := url.Values{}
form.Set("name", username)
form.Set("type", "enemy")
form.Set("container", selfID)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// UnblockByID unblocks a user via their full id.
func (s *UserService) UnblockByID(ctx context.Context, id string) (*Response, error) {
selfID, resp, err := s.client.id(ctx)
if err != nil {
return resp, err
}
path := "api/unfriend"
form := url.Values{}
form.Set("id", id)
form.Set("type", "enemy")
form.Set("container", selfID)
req, err := s.client.NewRequestWithForm(http.MethodPost, path, form)
if err != nil {
return nil, err
}
return s.client.Do(ctx, req, nil)
}
// Trophies returns a list of your trophies.
func (s *UserService) Trophies(ctx context.Context) ([]Trophy, *Response, error) {
return s.TrophiesOf(ctx, s.client.Username)
}
// TrophiesOf returns a list of the specified user's trophies.
func (s *UserService) TrophiesOf(ctx context.Context, username string) ([]Trophy, *Response, error) {
path := fmt.Sprintf("api/v1/user/%s/trophies", username)
req, err := s.client.NewRequest(http.MethodGet, path, nil)
if err != nil {
return nil, nil, err
}
root := new(rootTrophyListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
var trophies []Trophy
for _, trophy := range root.Data.Trophies {
if trophy.Data != nil {
trophies = append(trophies, *trophy.Data)
}
}
return trophies, resp, nil
}
// Popular gets the user subreddits with the most activity.
func (s *UserService) Popular(ctx context.Context, opts *ListOptions) (*Subreddits, *Response, error) {
path := "users/popular"
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getSubreddits(), resp, nil
}
// New gets the most recently created user subreddits.
func (s *UserService) New(ctx context.Context, opts *ListUserOverviewOptions) (*Subreddits, *Response, error) {
path := "users/new"
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getSubreddits(), resp, nil
}
// Search searches for users.
// todo: maybe include the sort option? (relevance, activity)
func (s *UserService) Search(ctx context.Context, query string, opts *ListOptions) (*Users, *Response, error) {
path := "users/search"
path, err := addOptions(path, opts)
if err != nil {
return nil, nil, err
}
type params struct {
Query string `url:"q"`
}
path, err = addOptions(path, params{query})
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(rootListing)
resp, err := s.client.Do(ctx, req, root)
if err != nil {
return nil, resp, err
}
return root.getUsers(), resp, nil
}
+978
View File
@@ -0,0 +1,978 @@
package reddit
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var expectedUser = &User{
ID: "test",
Name: "Test_User",
Created: &Timestamp{time.Date(2012, 10, 18, 10, 11, 11, 0, time.UTC)},
PostKarma: 8239,
CommentKarma: 130514,
HasVerifiedEmail: true,
}
var expectedUsers = map[string]*UserSummary{
"t2_1": {
Name: "test_user_1",
Created: &Timestamp{time.Date(2017, 3, 12, 2, 1, 47, 0, time.UTC)},
PostKarma: 488,
CommentKarma: 22223,
NSFW: false,
},
"t2_2": {
Name: "test_user_2",
Created: &Timestamp{time.Date(2015, 12, 20, 18, 12, 51, 0, time.UTC)},
PostKarma: 8277,
CommentKarma: 131948,
NSFW: false,
},
"t2_3": {
Name: "test_user_3",
Created: &Timestamp{time.Date(2013, 3, 4, 15, 46, 31, 0, time.UTC)},
PostKarma: 126887,
CommentKarma: 81918,
NSFW: true,
},
}
var expectedPost = &Post{
ID: "gczwql",
FullID: "t3_gczwql",
Created: &Timestamp{time.Date(2020, 5, 3, 22, 46, 25, 0, time.UTC)},
Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)},
Permalink: "/r/redditdev/comments/gczwql/get_userusernamegilded_does_it_return_other_users/",
URL: "https://www.reddit.com/r/redditdev/comments/gczwql/get_userusernamegilded_does_it_return_other_users/",
Title: "GET /user/{username}/gilded: does it return other user's things you've gilded, or your things that have been gilded? Does it return both comments and posts?",
Body: "Talking about [this](https://www.reddit.com/dev/api/#GET_user_{username}_{where}) endpoint specifically.\n\nI'm building a Reddit API client, but don't have gold.",
Likes: Bool(true),
Score: 9,
UpvoteRatio: 0.86,
NumberOfComments: 2,
SubredditName: "redditdev",
SubredditNamePrefixed: "r/redditdev",
SubredditID: "t5_2qizd",
Author: "v_95",
AuthorID: "t2_164ab8",
IsSelfPost: true,
}
var expectedComment = &Comment{
ID: "f0zsa37",
FullID: "t1_f0zsa37",
Created: &Timestamp{time.Date(2019, 9, 21, 21, 38, 16, 0, time.UTC)},
Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)},
ParentID: "t3_d7ejpn",
Permalink: "/r/apple/comments/d7ejpn/im_giving_away_an_iphone_11_pro_to_a_commenter_at/f0zsa37/",
Body: "Thank you!",
Author: "v_95",
AuthorID: "t2_164ab8",
SubredditName: "apple",
SubredditNamePrefixed: "r/apple",
SubredditID: "t5_2qh1f",
Likes: Bool(true),
Score: 1,
Controversiality: 0,
PostID: "t3_d7ejpn",
PostTitle: "I'm giving away an iPhone 11 Pro to a commenter at random to celebrate Apollo for Reddit's new iOS 13 update and as a thank you to the community! Just leave a comment on this post and the winner will be selected randomly and announced tomorrow at 8 PM GMT. Details inside, and good luck!",
PostPermalink: "https://www.reddit.com/r/apple/comments/d7ejpn/im_giving_away_an_iphone_11_pro_to_a_commenter_at/",
PostAuthor: "iamthatis",
PostNumComments: Int(89751),
}
var expectedRelationship = &Relationship{
ID: "r9_tqfqp8",
User: "test123",
UserID: "t2_7b8q1eob",
Created: &Timestamp{time.Date(2020, 6, 18, 20, 36, 34, 0, time.UTC)},
}
var expectedBlocked = &Blocked{
Blocked: "test123",
BlockedID: "t2_3v9o1yoi",
Created: &Timestamp{time.Date(2020, 6, 16, 16, 49, 50, 0, time.UTC)},
}
var expectedTrophies = []Trophy{
{
ID: "",
Name: "Three-Year Club",
Description: "",
},
{
ID: "1q1tez",
Name: "Verified Email",
Description: "",
},
}
var expectedUserSubreddits = &Subreddits{
Subreddits: []*Subreddit{
{
ID: "3kefx",
FullID: "t5_3kefx",
Created: &Timestamp{time.Date(2017, 5, 11, 16, 37, 16, 0, time.UTC)},
URL: "/user/nickofnight/",
Name: "u_nickofnight",
NamePrefixed: "u/nickofnight",
Title: "nickofnight",
Description: "Stories written for Writing Prompts, NoSleep, and originals. Current series: The Carnival of Night ",
Type: "user",
},
{
ID: "3knn1",
FullID: "t5_3knn1",
Created: &Timestamp{time.Date(2017, 5, 18, 2, 15, 55, 0, time.UTC)},
URL: "/user/shittymorph/",
Name: "u_shittymorph",
NamePrefixed: "u/shittymorph",
Title: "shittymorph",
Description: "In nineteen ninety eight the undertaker threw mankind off hеll in a cell, and plummeted sixteen feet through an announcer's table.",
Type: "user",
SuggestedCommentSort: "qa",
},
},
After: "t5_3knn1",
}
var expectedSearchUsers = &Users{
Users: []*User{
{
ID: "179965",
Name: "washingtonpost",
Created: &Timestamp{time.Date(2017, 4, 20, 21, 23, 58, 0, time.UTC)},
PostKarma: 1075227,
CommentKarma: 339569,
HasVerifiedEmail: true,
},
{
ID: "11kowl2w",
Name: "reuters",
Created: &Timestamp{time.Date(2018, 3, 15, 1, 50, 4, 0, time.UTC)},
PostKarma: 76744,
CommentKarma: 42717,
HasVerifiedEmail: true,
},
},
After: "t2_11kowl2w",
}
func TestUserService_Get(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/get.json")
require.NoError(t, err)
mux.HandleFunc("/user/Test_User/about", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
user, _, err := client.User.Get(ctx, "Test_User")
require.NoError(t, err)
require.Equal(t, expectedUser, user)
}
func TestUserService_GetMultipleByID(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/get-multiple-by-id.json")
require.NoError(t, err)
mux.HandleFunc("/api/user_data_by_account_ids", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, "t2_1,t2_2,t2_3", r.Form.Get("ids"))
fmt.Fprint(w, blob)
})
users, _, err := client.User.GetMultipleByID(ctx, "t2_1", "t2_2", "t2_3")
require.NoError(t, err)
require.Equal(t, expectedUsers, users)
}
func TestUserService_UsernameAvailable(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/username_available", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
err := r.ParseForm()
require.NoError(t, err)
user := r.Form.Get("user")
require.NotEmpty(t, user)
result := user == "test123"
fmt.Fprint(w, result)
})
ok, _, err := client.User.UsernameAvailable(ctx, "test123")
require.NoError(t, err)
require.True(t, ok)
ok, _, err = client.User.UsernameAvailable(ctx, "123test")
require.NoError(t, err)
require.False(t, ok)
}
func TestUserService_Overview(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/overview.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/overview", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, comments, _, err := client.User.Overview(ctx, nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t1_f0zsa37", posts.After)
require.Equal(t, "", posts.Before)
require.Len(t, comments.Comments, 1)
require.Equal(t, expectedComment, comments.Comments[0])
require.Equal(t, "t1_f0zsa37", comments.After)
require.Equal(t, "", comments.Before)
}
func TestUserService_OverviewOf(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/overview.json")
require.NoError(t, err)
mux.HandleFunc("/user/user2/overview", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, comments, _, err := client.User.OverviewOf(ctx, "user2", nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t1_f0zsa37", posts.After)
require.Equal(t, "", posts.Before)
require.Len(t, comments.Comments, 1)
require.Equal(t, expectedComment, comments.Comments[0])
require.Equal(t, "t1_f0zsa37", comments.After)
require.Equal(t, "", comments.Before)
}
func TestUserService_Overview_Options(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/overview.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/overview", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("limit", "5")
form.Set("after", "t3_after")
form.Set("sort", "top")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
_, _, _, err = client.User.Overview(ctx, &ListUserOverviewOptions{
ListOptions: ListOptions{
Limit: 5,
After: "t3_after",
},
Sort: "top",
})
require.NoError(t, err)
}
func TestUserService_Posts(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/submitted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, _, err := client.User.Posts(ctx, nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t3_gczwql", posts.After)
require.Equal(t, "", posts.Before)
}
func TestUserService_PostsOf(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user2/submitted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, _, err := client.User.PostsOf(ctx, "user2", nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t3_gczwql", posts.After)
require.Equal(t, "", posts.Before)
}
func TestUserService_Posts_Options(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/submitted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("limit", "10")
form.Set("sort", "new")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
_, _, err = client.User.Posts(ctx, &ListUserOverviewOptions{
ListOptions: ListOptions{
Limit: 10,
},
Sort: "new",
})
require.NoError(t, err)
}
func TestUserService_Comments(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/comments.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/comments", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
comments, _, err := client.User.Comments(ctx, nil)
require.NoError(t, err)
require.Len(t, comments.Comments, 1)
require.Equal(t, expectedComment, comments.Comments[0])
require.Equal(t, "t1_f0zsa37", comments.After)
require.Equal(t, "", comments.Before)
}
func TestUserService_CommentsOf(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/comments.json")
require.NoError(t, err)
mux.HandleFunc("/user/user2/comments", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
comments, _, err := client.User.CommentsOf(ctx, "user2", nil)
require.NoError(t, err)
require.Len(t, comments.Comments, 1)
require.Equal(t, expectedComment, comments.Comments[0])
require.Equal(t, "t1_f0zsa37", comments.After)
require.Equal(t, "", comments.Before)
}
func TestUserService_Comments_Options(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/comments.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/comments", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("limit", "100")
form.Set("before", "t1_before")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
_, _, err = client.User.Comments(ctx, &ListUserOverviewOptions{
ListOptions: ListOptions{
Limit: 100,
Before: "t1_before",
},
})
require.NoError(t, err)
}
func TestUserService_Saved(t *testing.T) {
setup()
defer teardown()
// we'll use this, similar payloads
blob, err := readFileContents("../testdata/user/overview.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/saved", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, comments, _, err := client.User.Saved(ctx, nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t1_f0zsa37", posts.After)
require.Equal(t, "", posts.Before)
require.Len(t, comments.Comments, 1)
require.Equal(t, expectedComment, comments.Comments[0])
require.Equal(t, "t1_f0zsa37", comments.After)
require.Equal(t, "", comments.Before)
}
func TestUserService_Saved_Options(t *testing.T) {
setup()
defer teardown()
// we'll use this, similar payloads
blob, err := readFileContents("../testdata/user/overview.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/saved", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("limit", "50")
form.Set("sort", "controversial")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
_, _, _, err = client.User.Saved(ctx, &ListUserOverviewOptions{
ListOptions: ListOptions{
Limit: 50,
},
Sort: "controversial",
})
require.NoError(t, err)
}
func TestUserService_Upvoted(t *testing.T) {
setup()
defer teardown()
// we'll use this, similar payloads
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/upvoted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, _, err := client.User.Upvoted(ctx, nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t3_gczwql", posts.After)
require.Equal(t, "", posts.Before)
}
func TestUserService_Upvoted_Options(t *testing.T) {
setup()
defer teardown()
// we'll use this, similar payloads
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/upvoted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("limit", "30")
form.Set("after", "t3_after")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
_, _, err = client.User.Upvoted(ctx, &ListUserOverviewOptions{
ListOptions: ListOptions{
Limit: 30,
After: "t3_after",
},
})
require.NoError(t, err)
}
func TestUserService_UpvotedOf(t *testing.T) {
setup()
defer teardown()
// we'll use this, similar payloads
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user2/upvoted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, _, err := client.User.UpvotedOf(ctx, "user2", nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t3_gczwql", posts.After)
require.Equal(t, "", posts.Before)
}
func TestUserService_Downvoted(t *testing.T) {
setup()
defer teardown()
// we'll use this, similar payloads
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/downvoted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, _, err := client.User.Downvoted(ctx, nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t3_gczwql", posts.After)
require.Equal(t, "", posts.Before)
}
func TestUserService_Downvoted_Options(t *testing.T) {
setup()
defer teardown()
// we'll use this, similar payloads
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/downvoted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("limit", "20")
form.Set("before", "t3_before")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
_, _, err = client.User.Downvoted(ctx, &ListUserOverviewOptions{
ListOptions: ListOptions{
Limit: 20,
Before: "t3_before",
},
})
require.NoError(t, err)
}
func TestUserService_DownvotedOf(t *testing.T) {
setup()
defer teardown()
// we'll use this, similar payloads
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user2/downvoted", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, _, err := client.User.DownvotedOf(ctx, "user2", nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t3_gczwql", posts.After)
require.Equal(t, "", posts.Before)
}
func TestUserService_Hidden(t *testing.T) {
setup()
defer teardown()
// we'll use this, similar payloads
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/hidden", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, _, err := client.User.Hidden(ctx, nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t3_gczwql", posts.After)
require.Equal(t, "", posts.Before)
}
func TestUserService_Gilded(t *testing.T) {
setup()
defer teardown()
// we'll use this, similar payloads
blob, err := readFileContents("../testdata/user/submitted.json")
require.NoError(t, err)
mux.HandleFunc("/user/user1/gilded", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
posts, _, err := client.User.Gilded(ctx, nil)
require.NoError(t, err)
require.Len(t, posts.Posts, 1)
require.Equal(t, expectedPost, posts.Posts[0])
require.Equal(t, "t3_gczwql", posts.After)
require.Equal(t, "", posts.Before)
}
func TestUserService_GetFriendship(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/friend.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/me/friends/test123", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
relationship, _, err := client.User.GetFriendship(ctx, "test123")
require.NoError(t, err)
require.Equal(t, expectedRelationship, relationship)
}
func TestUserService_Friend(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/friend.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/me/friends/test123", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
type request struct {
Username string `json:"name"`
}
var req request
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
require.Equal(t, "test123", req.Username)
fmt.Fprint(w, blob)
})
relationship, _, err := client.User.Friend(ctx, "test123")
require.NoError(t, err)
require.Equal(t, expectedRelationship, relationship)
}
func TestUserService_Unfriend(t *testing.T) {
setup()
defer teardown()
mux.HandleFunc("/api/v1/me/friends/test123", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodDelete, r.Method)
w.WriteHeader(http.StatusNoContent)
})
res, err := client.User.Unfriend(ctx, "test123")
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, res.StatusCode)
}
func TestUserService_Block(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/block.json")
require.NoError(t, err)
mux.HandleFunc("/api/block_user", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("name", "test123")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
blocked, _, err := client.User.Block(ctx, "test123")
require.NoError(t, err)
require.Equal(t, expectedBlocked, blocked)
}
func TestUserService_BlockByID(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/block.json")
require.NoError(t, err)
mux.HandleFunc("/api/block_user", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("account_id", "abc123")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
blocked, _, err := client.User.BlockByID(ctx, "abc123")
require.NoError(t, err)
require.Equal(t, expectedBlocked, blocked)
}
func TestUserService_Unblock(t *testing.T) {
setup()
defer teardown()
client.redditID = "self123"
mux.HandleFunc("/api/unfriend", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("name", "test123")
form.Set("type", "enemy")
form.Set("container", client.redditID)
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.User.Unblock(ctx, "test123")
require.NoError(t, err)
}
func TestUserService_UnblockByID(t *testing.T) {
setup()
defer teardown()
client.redditID = "self123"
mux.HandleFunc("/api/unfriend", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
form := url.Values{}
form.Set("id", "abc123")
form.Set("type", "enemy")
form.Set("container", client.redditID)
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
})
_, err := client.User.UnblockByID(ctx, "abc123")
require.NoError(t, err)
}
func TestUserService_Trophies(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/trophies.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/user/user1/trophies", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
trophies, _, err := client.User.Trophies(ctx)
require.NoError(t, err)
require.Equal(t, expectedTrophies, trophies)
}
func TestUserService_TrophiesOf(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/trophies.json")
require.NoError(t, err)
mux.HandleFunc("/api/v1/user/test123/trophies", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
trophies, _, err := client.User.TrophiesOf(ctx, "test123")
require.NoError(t, err)
require.Equal(t, expectedTrophies, trophies)
}
func TestUserService_Popular(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/user-subreddits.json")
require.NoError(t, err)
mux.HandleFunc("/users/popular", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
userSubreddits, _, err := client.User.Popular(ctx, nil)
require.NoError(t, err)
require.Equal(t, expectedUserSubreddits, userSubreddits)
}
func TestUserService_New(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/user-subreddits.json")
require.NoError(t, err)
mux.HandleFunc("/users/new", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
fmt.Fprint(w, blob)
})
userSubreddits, _, err := client.User.New(ctx, nil)
require.NoError(t, err)
require.Equal(t, expectedUserSubreddits, userSubreddits)
}
func TestUserService_Search(t *testing.T) {
setup()
defer teardown()
blob, err := readFileContents("../testdata/user/list.json")
require.NoError(t, err)
mux.HandleFunc("/users/search", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
form := url.Values{}
form.Set("q", "test")
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, form, r.Form)
fmt.Fprint(w, blob)
})
users, _, err := client.User.Search(ctx, "test", nil)
require.NoError(t, err)
require.Equal(t, expectedSearchUsers, users)
}