diff --git a/reddit/message.go b/reddit/message.go index 5c67701..173211b 100644 --- a/reddit/message.go +++ b/reddit/message.go @@ -81,16 +81,8 @@ type inboxThings struct { Messages []*Message } -// init initializes or clears the listing. -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 []inboxThing if err := json.Unmarshal(b, &things); err != nil { return err diff --git a/reddit/reddit.go b/reddit/reddit.go index 17202f2..af698bd 100644 --- a/reddit/reddit.go +++ b/reddit/reddit.go @@ -78,6 +78,7 @@ type Client struct { Stream *StreamService Subreddit *SubredditService User *UserService + Wiki *WikiService oauth2Transport *oauth2.Transport @@ -107,6 +108,7 @@ func newClient() *Client { client.Stream = &StreamService{client: client} client.Subreddit = &SubredditService{client: client} client.User = &UserService{client: client} + client.Wiki = &WikiService{client: client} postAndCommentService := &postAndCommentService{client: client} client.Comment = &CommentService{client: client, postAndCommentService: postAndCommentService} diff --git a/reddit/reddit_test.go b/reddit/reddit_test.go index 746df69..13db3db 100644 --- a/reddit/reddit_test.go +++ b/reddit/reddit_test.go @@ -71,6 +71,7 @@ func testClientServices(t *testing.T, c *Client) { "Stream", "Subreddit", "User", + "Wiki", } cp := reflect.ValueOf(c) diff --git a/reddit/things.go b/reddit/things.go index 2d1248c..213d695 100644 --- a/reddit/things.go +++ b/reddit/things.go @@ -6,19 +6,21 @@ import ( ) const ( - kindComment = "t1" - kindUser = "t2" - kindPost = "t3" - kindMessage = "t4" - kindSubreddit = "t5" - kindTrophy = "t6" - kindListing = "Listing" - kindKarmaList = "KarmaList" - kindTrophyList = "TrophyList" - kindUserList = "UserList" - kindMore = "more" - kindModAction = "modaction" - kindMulti = "LabeledMulti" + kindComment = "t1" + kindUser = "t2" + kindPost = "t3" + kindMessage = "t4" + kindSubreddit = "t5" + kindTrophy = "t6" + kindListing = "Listing" + kindKarmaList = "KarmaList" + kindTrophyList = "TrophyList" + kindUserList = "UserList" + kindMore = "more" + kindModAction = "modaction" + kindMulti = "LabeledMulti" + kindWikiPageListing = "wikipagelisting" + kindWikiPageSettings = "wikipagesettings" ) type anchor interface { @@ -94,6 +96,10 @@ func (t *thing) UnmarshalJSON(b []byte) error { v = new(trophyList) case kindKarmaList: v = new([]*SubredditKarma) + case kindWikiPageListing: + v = new([]string) + case kindWikiPageSettings: + v = new(WikiPageSettings) default: return fmt.Errorf("unrecognized kind: %q", t.Kind) } @@ -168,6 +174,19 @@ func (t *thing) Karma() ([]*SubredditKarma, bool) { return *v, ok } +func (t *thing) WikiPages() ([]string, bool) { + v, ok := t.Data.(*[]string) + if !ok { + return nil, ok + } + return *v, ok +} + +func (t *thing) WikiPageSettings() (v *WikiPageSettings, ok bool) { + v, ok = t.Data.(*WikiPageSettings) + return +} + // listing is a list of things coming from the Reddit API. // It also contains the after/before anchors useful for subsequent requests. type listing struct { @@ -263,21 +282,8 @@ type things struct { Multis []*Multi } -// 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) - t.Multis = make([]*Multi, 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 diff --git a/reddit/wiki.go b/reddit/wiki.go new file mode 100644 index 0000000..68fe76a --- /dev/null +++ b/reddit/wiki.go @@ -0,0 +1,170 @@ +package reddit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/google/go-querystring/query" +) + +// WikiService handles communication with the wiki +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_wiki +type WikiService struct { + client *Client +} + +// WikiPagePermissionLevel defines who can edit a specific wiki page in a subreddit. +type WikiPagePermissionLevel int + +const ( + // PermissionSubredditWikiPermissions uses subreddit wiki permissions. + PermissionSubredditWikiPermissions WikiPagePermissionLevel = iota + // PermissionApprovedContributorsOnly is only for approved wiki contributors. + PermissionApprovedContributorsOnly + // PermissionModeratorsOnly is only for moderators. + PermissionModeratorsOnly +) + +// WikiPageSettings holds the settings for a specific wiki page. +type WikiPageSettings struct { + PermissionLevel WikiPagePermissionLevel `json:"permlevel"` + Listed bool `json:"listed"` + Editors []*User `json:"editors"` +} + +// WikiPageSettingsUpdateRequest represents a request to update the visibility and +// permissions of a wiki page. +type WikiPageSettingsUpdateRequest struct { + // This HAS to be provided no matter what, or else we get a 500 response. + PermissionLevel WikiPagePermissionLevel `url:"permlevel"` + Listed *bool `url:"listed,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (s *WikiPageSettings) UnmarshalJSON(b []byte) error { + root := new(struct { + PermissionLevel WikiPagePermissionLevel `json:"permlevel"` + Listed bool `json:"listed"` + Things []thing `json:"editors"` + }) + + err := json.Unmarshal(b, root) + if err != nil { + return err + } + + s.PermissionLevel = root.PermissionLevel + s.Listed = root.Listed + + for _, thing := range root.Things { + if user, ok := thing.User(); ok { + s.Editors = append(s.Editors, user) + } + } + + return nil +} + +// Pages retrieves a list of wiki pages in the subreddit. +// Returns 403 Forbidden if the wiki is disabled. +func (s *WikiService) Pages(ctx context.Context, subreddit string) ([]string, *Response, error) { + path := fmt.Sprintf("r/%s/wiki/pages", subreddit) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(thing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + pages, _ := root.WikiPages() + return pages, resp, nil +} + +// Settings gets the subreddit's wiki page's settings. +func (s *WikiService) Settings(ctx context.Context, subreddit, page string) (*WikiPageSettings, *Response, error) { + path := fmt.Sprintf("r/%s/wiki/settings/%s", subreddit, page) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(thing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + settings, _ := root.WikiPageSettings() + return settings, resp, nil +} + +// UpdateSettings updates the subreddit's wiki page's settings. +func (s *WikiService) UpdateSettings(ctx context.Context, subreddit, page string, updateRequest *WikiPageSettingsUpdateRequest) (*WikiPageSettings, *Response, error) { + if updateRequest == nil { + return nil, nil, errors.New("updateRequest: cannot be nil") + } + + form, err := query.Values(updateRequest) + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("r/%s/wiki/settings/%s", subreddit, page) + req, err := s.client.NewRequestWithForm(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(thing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + settings, _ := root.WikiPageSettings() + return settings, resp, nil +} + +// Allow the user to edit the specified wiki page in the subreddit. +func (s *WikiService) Allow(ctx context.Context, subreddit, page, username string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/wiki/alloweditor/add", subreddit) + + form := url.Values{} + form.Set("page", page) + form.Set("username", username) + + req, err := s.client.NewRequestWithForm(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Deny the user the ability to edit the specified wiki page in the subreddit. +func (s *WikiService) Deny(ctx context.Context, subreddit, page, username string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/wiki/alloweditor/del", subreddit) + + form := url.Values{} + form.Set("page", page) + form.Set("username", username) + + req, err := s.client.NewRequestWithForm(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/reddit/wiki_test.go b/reddit/wiki_test.go new file mode 100644 index 0000000..0edfb00 --- /dev/null +++ b/reddit/wiki_test.go @@ -0,0 +1,138 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedWikiPageSettings = &WikiPageSettings{ + PermissionLevel: PermissionSubredditWikiPermissions, + Listed: true, + Editors: []*User{ + { + ID: "164ab8", + Name: "v_95", + Created: &Timestamp{time.Date(2017, 3, 12, 4, 56, 47, 0, time.UTC)}, + + PostKarma: 691, + CommentKarma: 22235, + + HasVerifiedEmail: true, + NSFW: true, + }, + }, +} + +func TestWikiService_Pages(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/r/testsubreddit/wiki/pages", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, `{ + "kind": "wikipagelisting", + "data": [ + "faq", + "index" + ] + }`) + }) + + pages, _, err := client.Wiki.Pages(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, []string{"faq", "index"}, pages) +} + +func TestWikiService_Settings(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + blob, err := readFileContents("../testdata/wiki/page-settings.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/wiki/settings/testpage", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + settings, _, err := client.Wiki.Settings(ctx, "testsubreddit", "testpage") + require.NoError(t, err) + require.Equal(t, expectedWikiPageSettings, settings) +} + +func TestWikiService_UpdateSettings(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + blob, err := readFileContents("../testdata/wiki/page-settings.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/wiki/settings/testpage", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("permlevel", "1") + form.Set("listed", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.Wiki.UpdateSettings(ctx, "testsubreddit", "testpage", nil) + require.EqualError(t, err, "updateRequest: cannot be nil") + + settings, _, err := client.Wiki.UpdateSettings(ctx, "testsubreddit", "testpage", &WikiPageSettingsUpdateRequest{ + Listed: Bool(false), + PermissionLevel: PermissionApprovedContributorsOnly, + }) + require.NoError(t, err) + require.Equal(t, expectedWikiPageSettings, settings) +} + +func TestWikiService_Allow(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/r/testsubreddit/api/wiki/alloweditor/add", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("page", "testpage") + form.Set("username", "testusername") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Wiki.Allow(ctx, "testsubreddit", "testpage", "testusername") + require.NoError(t, err) +} + +func TestWikiService_Deny(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/r/testsubreddit/api/wiki/alloweditor/del", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("page", "testpage") + form.Set("username", "testusername") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Wiki.Deny(ctx, "testsubreddit", "testpage", "testusername") + require.NoError(t, err) +} diff --git a/testdata/wiki/page-settings.json b/testdata/wiki/page-settings.json new file mode 100644 index 0000000..ef94444 --- /dev/null +++ b/testdata/wiki/page-settings.json @@ -0,0 +1,180 @@ +{ + "kind": "wikipagesettings", + "data": { + "permlevel": 0, + "editors": [ + { + "kind": "t2", + "data": { + "is_employee": false, + "has_visited_new_profile": false, + "is_friend": false, + "pref_no_profanity": false, + "has_external_account": false, + "pref_geopopular": "GLOBAL", + "pref_show_trending": true, + "subreddit": { + "default_set": true, + "user_is_contributor": false, + "banner_img": "", + "restrict_posting": true, + "user_is_banned": false, + "free_form_reports": true, + "community_icon": null, + "show_media": true, + "icon_color": "#94E044", + "user_is_muted": false, + "display_name": "u_v_95", + "header_img": null, + "title": "", + "coins": 0, + "previous_names": [], + "over_18": false, + "icon_size": [256, 256], + "primary_color": "", + "icon_img": "https://www.redditstatic.com/avatars/avatar_default_01_94E044.png", + "description": "", + "submit_link_label": "", + "header_size": null, + "restrict_commenting": false, + "subscribers": 1, + "submit_text_label": "", + "is_default_icon": true, + "link_flair_position": "", + "display_name_prefixed": "u/v_95", + "key_color": "", + "name": "t5_17a8op", + "is_default_banner": true, + "url": "/user/v_95/", + "quarantine": false, + "banner_size": null, + "user_is_moderator": true, + "public_description": "", + "link_flair_enabled": false, + "disable_contributor_requests": false, + "subreddit_type": "user", + "user_is_subscriber": false + }, + "is_sponsor": false, + "gold_expiration": null, + "has_gold_subscription": false, + "num_friends": 29, + "features": { + "mod_service_mute_writes": true, + "promoted_trend_blanks": true, + "show_amp_link": true, + "report_service_handles_report_writes_to_db_for_helpdesk_reports": true, + "report_service_handles_self_harm_reports": true, + "report_service_handles_report_writes_to_db_for_modmail_reports": true, + "chat": true, + "mweb_link_tab": { + "owner": "growth", + "variant": "treatment_1", + "experiment_id": 404 + }, + "reports_double_write_to_report_service_for_spam": true, + "is_email_permission_required": true, + "reports_double_write_to_report_service_for_modmail_reports": true, + "mod_awards": true, + "econ_wallet_service": true, + "mweb_xpromo_revamp_v2": { + "owner": "growth", + "variant": "treatment_6", + "experiment_id": 457 + }, + "awards_on_streams": true, + "report_service_handles_accept_report": true, + "mweb_xpromo_modal_listing_click_daily_dismissible_ios": true, + "reports_double_write_to_report_service_for_som": true, + "chat_subreddit": true, + "reports_double_write_to_report_service_for_users": true, + "modlog_copyright_removal": true, + "report_service_handles_report_writes_to_db_for_users": true, + "do_not_track": true, + "reports_double_write_to_report_service_for_helpdesk_reports": true, + "report_service_handles_report_writes_to_db_for_spam": true, + "reports_double_write_to_report_service_for_sendbird_chats": true, + "mod_service_mute_reads": true, + "mweb_xpromo_interstitial_comments_ios": true, + "mweb_xpromo_modal_listing_click_daily_dismissible_android": true, + "chat_user_settings": true, + "premium_subscriptions_table": true, + "reports_double_write_to_report_service": true, + "mweb_xpromo_interstitial_comments_android": true, + "report_service_handles_report_writes_to_db_for_awards": true, + "mweb_nsfw_xpromo": { + "owner": "growth", + "variant": "control_2", + "experiment_id": 361 + }, + "noreferrer_to_noopener": true, + "reports_double_write_to_report_service_for_awards": true, + "mweb_sharing_web_share_api": { + "owner": "growth", + "variant": "control_1", + "experiment_id": 314 + }, + "mweb_xpromo_revamp_v3": { + "owner": "growth", + "variant": "treatment_2", + "experiment_id": 480 + }, + "chat_group_rollout": true, + "resized_styles_images": true, + "spez_modal": true, + "mweb_sharing_clipboard": { + "owner": "growth", + "variant": "control_2", + "experiment_id": 315 + }, + "expensive_coins_package": true, + "report_service_handles_report_writes_to_db_for_som": true + }, + "can_edit_name": false, + "verified": true, + "new_modmail_exists": true, + "pref_autoplay": true, + "coins": 0, + "has_paypal_subscription": false, + "has_subscribed_to_premium": false, + "id": "164ab8", + "has_stripe_subscription": false, + "can_create_subreddit": true, + "over_18": true, + "is_gold": false, + "is_mod": true, + "suspension_expiration_utc": null, + "has_verified_email": true, + "is_suspended": false, + "pref_video_autoplay": true, + "in_chat": true, + "has_android_subscription": false, + "in_redesign_beta": false, + "icon_img": "https://www.redditstatic.com/avatars/avatar_default_01_94E044.png", + "has_mod_mail": false, + "pref_nightmode": true, + "hide_from_robots": false, + "password_set": true, + "modhash": null, + "link_karma": 691, + "force_password_reset": false, + "inbox_count": 0, + "pref_top_karma_subreddits": false, + "has_mail": false, + "pref_show_snoovatar": false, + "name": "v_95", + "pref_clickgadget": 5, + "created": 1489323407.0, + "gold_creddits": 0, + "created_utc": 1489294607.0, + "has_ios_subscription": false, + "pref_show_twitter": false, + "in_beta": false, + "comment_karma": 22235, + "has_subscribed": true + } + } + ], + "listed": true + } +}