From 6bdece7370451eda4ea374c437525f6834e3995f Mon Sep 17 00:00:00 2001 From: Vartan Benohanian Date: Wed, 23 Sep 2020 23:29:04 -0400 Subject: [PATCH] Create WidgetService Signed-off-by: Vartan Benohanian --- reddit/reddit.go | 2 + reddit/reddit_test.go | 1 + reddit/widget.go | 315 +++++++++++++++++++++++++++++++++++ reddit/widget_test.go | 130 +++++++++++++++ testdata/widget/widgets.json | 122 ++++++++++++++ 5 files changed, 570 insertions(+) create mode 100644 reddit/widget.go create mode 100644 reddit/widget_test.go create mode 100644 testdata/widget/widgets.json diff --git a/reddit/reddit.go b/reddit/reddit.go index a103686..3183630 100644 --- a/reddit/reddit.go +++ b/reddit/reddit.go @@ -79,6 +79,7 @@ type Client struct { Stream *StreamService Subreddit *SubredditService User *UserService + Widget *WidgetService Wiki *WikiService oauth2Transport *oauth2.Transport @@ -110,6 +111,7 @@ func newClient() *Client { client.Stream = &StreamService{client: client} client.Subreddit = &SubredditService{client: client} client.User = &UserService{client: client} + client.Widget = &WidgetService{client: client} client.Wiki = &WikiService{client: client} postAndCommentService := &postAndCommentService{client: client} diff --git a/reddit/reddit_test.go b/reddit/reddit_test.go index b7654d5..89c762f 100644 --- a/reddit/reddit_test.go +++ b/reddit/reddit_test.go @@ -72,6 +72,7 @@ func testClientServices(t *testing.T, c *Client) { "Stream", "Subreddit", "User", + "Widget", "Wiki", } diff --git a/reddit/widget.go b/reddit/widget.go new file mode 100644 index 0000000..c11ce34 --- /dev/null +++ b/reddit/widget.go @@ -0,0 +1,315 @@ +package reddit + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// WidgetService handles communication with the widget +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_widgets +type WidgetService struct { + client *Client +} + +// Widget is a section of useful content on a subreddit. +// They can feature information such as rules, links, the origins of the subreddit, etc. +// Read about them here: https://mods.reddithelp.com/hc/en-us/articles/360010364372-Sidebar-Widgets +type Widget interface { + // kind returns the widget kind. + // having un unexported method on an exported interface means it cannot be implemented by a client. + kind() string +} + +const ( + widgetKindMenu = "menu" + widgetKindCommunityDetails = "id-card" + widgetKindModerators = "moderators" + widgetKindSubredditRules = "subreddit-rules" + widgetKindCustom = "custom" +) + +// WidgetList is a list of widgets. +type WidgetList []Widget + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (l *WidgetList) UnmarshalJSON(data []byte) error { + var widgetMap map[string]json.RawMessage + err := json.Unmarshal(data, &widgetMap) + if err != nil { + return err + } + + type widgetKind struct { + Kind string `json:"kind"` + } + for _, w := range widgetMap { + root := new(widgetKind) + err = json.Unmarshal(w, root) + if err != nil { + return err + } + + var widget Widget + switch root.Kind { + case widgetKindMenu: + widget = new(MenuWidget) + case widgetKindCommunityDetails: + widget = new(CommunityDetailsWidget) + case widgetKindModerators: + widget = new(ModeratorsWidget) + case widgetKindSubredditRules: + widget = new(SubredditRulesWidget) + case widgetKindCustom: + widget = new(CustomWidget) + default: + continue + } + + err = json.Unmarshal(w, widget) + if err != nil { + return err + } + + *l = append(*l, widget) + } + + return nil +} + +// common widget fields +type widget struct { + ID string `json:"id,omitempty"` + Kind string `json:"kind,omitempty"` + Style *WidgetStyle `json:"styles,omitempty"` +} + +// MenuWidget displays tabs for your community's menu. These can be direct links or submenus that +// create a drop-down menu to multiple links. +type MenuWidget struct { + widget + + ShowWiki bool `json:"showWiki"` + Links WidgetLinkList `json:"data,omitempty"` +} + +func (w *MenuWidget) kind() string { + return widgetKindMenu +} + +// CommunityDetailsWidget displays your subscriber count, users online, and community description, +// as defined in your subreddit settings. You can customize the displayed text for subscribers and +// users currently viewing the community. +type CommunityDetailsWidget struct { + widget + + Name string `json:"shortName,omitempty"` + Description string `json:"description,omitempty"` + + Subscribers int `json:"subscribersCount"` + CurrentlyViewing int `json:"currentlyViewingCount"` + + SubscribersText string `json:"subscribersText,omitempty"` + CurrentlyViewingText string `json:"currentlyViewingText,omitempty"` +} + +func (*CommunityDetailsWidget) kind() string { + return widgetKindCommunityDetails +} + +// ModeratorsWidget displays the list of moderators of the subreddit. +type ModeratorsWidget struct { + widget + + Mods []string `json:"mods"` + Total int `json:"totalMods"` +} + +func (*ModeratorsWidget) kind() string { + return widgetKindModerators +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (w *ModeratorsWidget) UnmarshalJSON(data []byte) error { + root := new(struct { + widget + + Mods []struct { + Name string `json:"name"` + } `json:"mods"` + Total int `json:"totalMods"` + }) + + err := json.Unmarshal(data, root) + if err != nil { + return err + } + + w.widget = root.widget + w.Total = root.Total + for _, mod := range root.Mods { + w.Mods = append(w.Mods, mod.Name) + } + + return nil +} + +// SubredditRulesWidget displays your community rules. +type SubredditRulesWidget struct { + widget + + Name string `json:"shortName,omitempty"` + // One of: full (includes description), compact (rule is collapsed). + Display string `json:"display,omitempty"` + Rules []string `json:"rules,omitempty"` +} + +func (*SubredditRulesWidget) kind() string { + return widgetKindSubredditRules +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (w *SubredditRulesWidget) UnmarshalJSON(data []byte) error { + root := new(struct { + widget + + Name string `json:"shortName"` + Display string `json:"display"` + Rules []struct { + Description string `json:"description"` + } `json:"data"` + }) + + err := json.Unmarshal(data, root) + if err != nil { + return err + } + + w.widget = root.widget + w.Name = root.Name + w.Display = root.Display + for _, r := range root.Rules { + w.Rules = append(w.Rules, r.Description) + } + + return nil +} + +// CustomWidget is a custom widget. +type CustomWidget struct { + widget + + Name string `json:"shortName,omitempty"` + Text string `json:"text,omitempty"` + + StyleSheet string `json:"css,omitempty"` + StyleSheetURL string `json:"stylesheetUrl,omitempty"` + Images []*WidgetImage `json:"imageData,omitempty"` +} + +func (*CustomWidget) kind() string { + return widgetKindCustom +} + +// WidgetStyle contains style information for the widget. +type WidgetStyle struct { + HeaderColor string `json:"headerColor"` + BackgroundColor string `json:"backgroundColor"` +} + +// WidgetImage is an image in a widget. +type WidgetImage struct { + Name string `json:"name"` + URL string `json:"url"` +} + +// WidgetLink is a link or a group of links that's part of a widget. +type WidgetLink interface { + // single returns whether or not the widget holds just one single link. + // having un unexported method on an exported interface means it cannot be implemented by a client. + single() bool +} + +// WidgetLinkSingle is a link that's part of a widget. +type WidgetLinkSingle struct { + Text string `json:"text,omitempty"` + URL string `json:"url,omitempty"` +} + +func (l *WidgetLinkSingle) single() bool { return true } + +// WidgetLinkMultiple is a dropdown of multiple links that's part of a widget. +type WidgetLinkMultiple struct { + Text string `json:"text,omitempty"` + URLs []*WidgetLinkSingle `json:"children,omitempty"` +} + +func (l *WidgetLinkMultiple) single() bool { return false } + +// WidgetLinkList is a list of widgets links. +type WidgetLinkList []WidgetLink + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (l *WidgetLinkList) UnmarshalJSON(data []byte) error { + var dataMap []json.RawMessage + err := json.Unmarshal(data, &dataMap) + if err != nil { + return err + } + + for _, d := range dataMap { + var widgetLinkDataMap map[string]json.RawMessage + err = json.Unmarshal(d, &widgetLinkDataMap) + if err != nil { + return err + } + + var wl WidgetLink + if _, ok := widgetLinkDataMap["children"]; ok { + wl = new(WidgetLinkMultiple) + } else { + wl = new(WidgetLinkSingle) + } + + err = json.Unmarshal(d, wl) + if err != nil { + return err + } + + *l = append(*l, wl) + } + + return nil +} + +// Get the subreddit's widgets. +func (s *WidgetService) Get(ctx context.Context, subreddit string) ([]Widget, *Response, error) { + path := fmt.Sprintf("r/%s/api/widgets?progressive_images=true", subreddit) + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + Widgets WidgetList `json:"items"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Widgets, resp, nil +} + +// Delete a widget via its id. +func (s *WidgetService) Delete(ctx context.Context, subreddit, id string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/widget/%s", subreddit, id) + req, err := s.client.NewRequest(http.MethodDelete, path, nil) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} diff --git a/reddit/widget_test.go b/reddit/widget_test.go new file mode 100644 index 0000000..897ea68 --- /dev/null +++ b/reddit/widget_test.go @@ -0,0 +1,130 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +var expectedWidgets = []Widget{ + &SubredditRulesWidget{ + widget: widget{ + ID: "widget_rules-2uquw1", + Kind: "subreddit-rules", + Style: &WidgetStyle{}, + }, + Name: "Subreddit Rules", + Display: "compact", + Rules: []string{"be nice"}, + }, + + &CommunityDetailsWidget{ + widget: widget{ + ID: "widget_id-card-2uquw1", + Kind: "id-card", + Style: &WidgetStyle{}, + }, + Name: "Community Details", + Description: "Community Description", + Subscribers: 2, + CurrentlyViewing: 3, + SubscribersText: "subscriberz", + CurrentlyViewingText: "viewerz", + }, + + &MenuWidget{ + widget: widget{ + ID: "widget_15owrhqvgfhke", + Kind: "menu", + Style: &WidgetStyle{}, + }, + ShowWiki: true, + Links: []WidgetLink{ + &WidgetLinkSingle{ + Text: "link1", + URL: "https://example.com", + }, + &WidgetLinkMultiple{ + Text: "test", + URLs: []*WidgetLinkSingle{ + { + Text: "link2", + URL: "https://example.com", + }, + { + Text: "link3", + URL: "https://example.com", + }, + }, + }, + }, + }, + + &ModeratorsWidget{ + widget: widget{ + ID: "widget_moderators-2uquw1", + Kind: "moderators", + Style: &WidgetStyle{}, + }, + Mods: []string{"testuser"}, + Total: 1, + }, + + &CustomWidget{ + widget: widget{ + ID: "widget_15osq4jms4tdo", + Kind: "custom", + Style: &WidgetStyle{}, + }, + Name: "custom image widget", + Text: "some image", + StyleSheet: "* {}", + StyleSheetURL: "https://styles.redditmedia.com/t5_2uquw1/styles/customWidget-stylesheet-n2q86gjf04o51.css", + Images: []*WidgetImage{ + { + Name: "test", + URL: "https://www.redditstatic.com/image-processing.png", + }, + }, + }, +} + +func TestWidgetService_Get(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + blob, err := readFileContents("../testdata/widget/widgets.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/widgets", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("progressive_images", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + widgets, _, err := client.Widget.Get(ctx, "testsubreddit") + require.NoError(t, err) + require.ElementsMatch(t, expectedWidgets, widgets) +} + +func TestWidgetService_Delete(t *testing.T) { + client, mux, teardown := setup() + defer teardown() + + mux.HandleFunc("/r/testsubreddit/api/widget/abc123", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + }) + + _, err := client.Widget.Delete(ctx, "testsubreddit", "abc123") + require.NoError(t, err) +} diff --git a/testdata/widget/widgets.json b/testdata/widget/widgets.json new file mode 100644 index 0000000..b4596dc --- /dev/null +++ b/testdata/widget/widgets.json @@ -0,0 +1,122 @@ +{ + "items": { + "widget_15osq4jms4tdo": { + "styles": { + "headerColor": null, + "backgroundColor": null + }, + "kind": "custom", + "imageData": [ + { + "url": "https://www.redditstatic.com/image-processing.png", + "width": 640, + "name": "test", + "height": 192 + } + ], + "text": "some image", + "stylesheetUrl": "https://styles.redditmedia.com/t5_2uquw1/styles/customWidget-stylesheet-n2q86gjf04o51.css", + "height": 500, + "textHtml": "<!-- SC_OFF --><div class=\"md\"><p>some image</p>\n</div><!-- SC_ON -->", + "shortName": "custom image widget", + "id": "widget_15osq4jms4tdo", + "css": "* {}" + }, + "widget_rules-2uquw1": { + "styles": { + "headerColor": "", + "backgroundColor": "" + }, + "kind": "subreddit-rules", + "display": "compact", + "shortName": "Subreddit Rules", + "data": [ + { + "violationReason": "post violation", + "description": "be nice", + "createdUtc": 1600057179.0, + "priority": 1, + "descriptionHtml": "<!-- SC_OFF --><div class=\"md\"><p>be nice</p>\n</div><!-- SC_ON -->", + "shortName": "post" + } + ], + "id": "widget_rules-2uquw1" + }, + "widget_id-card-2uquw1": { + "styles": { + "headerColor": "", + "backgroundColor": "" + }, + "kind": "id-card", + "description": "Community Description", + "subscribersText": "subscriberz", + "currentlyViewingCount": 3, + "subscribersCount": 2, + "currentlyViewingText": "viewerz", + "shortName": "Community Details", + "id": "widget_id-card-2uquw1" + }, + "widget_15owrhqvgfhke": { + "styles": { + "headerColor": null, + "backgroundColor": null + }, + "kind": "menu", + "data": [ + { + "url": "https://example.com", + "text": "link1" + }, + { + "text": "test", + "children": [ + { + "url": "https://example.com", + "text": "link2" + }, + { + "url": "https://example.com", + "text": "link3" + } + ] + } + ], + "id": "widget_15owrhqvgfhke", + "showWiki": true + }, + "widget_moderators-2uquw1": { + "styles": { + "headerColor": null, + "backgroundColor": null + }, + "kind": "moderators", + "mods": [ + { + "name": "testuser", + "authorFlairType": "richtext", + "authorFlairTextColor": "dark", + "authorFlairBackgroundColor": "", + "authorFlairRichText": [ + { + "e": "text", + "t": "test" + } + ], + "authorFlairText": "test" + } + ], + "totalMods": 1, + "id": "widget_moderators-2uquw1" + } + }, + "layout": { + "idCardWidget": "widget_id-card-2uquw1", + "topbar": { + "order": ["widget_15owrhqvgfhke"] + }, + "sidebar": { + "order": ["widget_rules-2uquw1", "widget_15osq4jms4tdo"] + }, + "moderatorWidget": "widget_moderators-2uquw1" + } +}