From 5f56273e2ee1cea3e94492ae520dc26f35e47530 Mon Sep 17 00:00:00 2001 From: Vartan Benohanian Date: Thu, 18 Jun 2020 21:41:17 -0400 Subject: [PATCH] User service done Signed-off-by: Vartan Benohanian --- geddit.go | 46 +++++-- testdata/user/block.json | 6 + testdata/user/friend.json | 6 + testdata/user/trophies.json | 31 +++++ things.go | 19 +-- user.go | 252 ++++++++++++++++++++++++++++++------ user_test.go | 201 ++++++++++++++++++++++++++++ vote.go | 7 +- 8 files changed, 499 insertions(+), 69 deletions(-) create mode 100644 testdata/user/block.json create mode 100644 testdata/user/friend.json create mode 100644 testdata/user/trophies.json diff --git a/geddit.go b/geddit.go index 4392631..e8db67c 100644 --- a/geddit.go +++ b/geddit.go @@ -46,7 +46,7 @@ func cloneRequest(r *http.Request) *http.Request { return r2 } -// sets the User-Agent header for requests +// Sets the User-Agent header for requests. type userAgentTransport struct { ua string Base http.RoundTripper @@ -70,12 +70,12 @@ func (t *userAgentTransport) base() http.RoundTripper { return http.DefaultTransport } -// RequestCompletionCallback defines the type of the request callback function +// RequestCompletionCallback defines the type of the request callback function. type RequestCompletionCallback func(*http.Request, *http.Response) -// Client manages communication with the Reddit API +// Client manages communication with the Reddit API. type Client struct { - // HTTP client used to communicate with the Reddit API + // HTTP client used to communicate with the Reddit API. client *http.Client BaseURL *url.URL @@ -88,6 +88,9 @@ type Client struct { Username string Password string + // This is the client's user ID in Reddit's database. + redditID string + Comment CommentService Flair FlairService Link LinkService @@ -102,7 +105,7 @@ type Client struct { onRequestCompleted RequestCompletionCallback } -// OnRequestCompleted sets the client's request completion callback +// OnRequestCompleted sets the client's request completion callback. func (c *Client) OnRequestCompleted(rc RequestCompletionCallback) { c.onRequestCompleted = rc } @@ -129,7 +132,7 @@ func newClient(httpClient *http.Client) *Client { return c } -// NewClient returns a client that can make requests to the Reddit API +// 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) @@ -158,9 +161,9 @@ func NewClient(httpClient *http.Client, opts ...Opt) (c *Client, err error) { return } -// 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 +// 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, path string, body interface{}) (*http.Request, error) { u, err := c.BaseURL.Parse(path) if err != nil { @@ -187,9 +190,9 @@ func (c *Client) NewRequest(method, path string, body interface{}) (*http.Reques return req, nil } -// NewPostForm creates an API request with a POST form -// The path is the relative URL which will be resolves to the BaseURL of the Client -// It should always be specified without a preceding slash +// NewPostForm creates an API request with a POST form. +// The path is the relative URL which will be resolves to the BaseURL of the Client. +// It should always be specified without a preceding slash. func (c *Client) NewPostForm(path string, form url.Values) (*http.Request, error) { u, err := c.BaseURL.Parse(path) if err != nil { @@ -212,7 +215,7 @@ type Response struct { *http.Response } -// newResponse creates a new Response for the provided http.Response +// newResponse creates a new Response for the provided http.Response. func newResponse(r *http.Response) *Response { response := Response{Response: r} return &response @@ -260,6 +263,21 @@ func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Res return response, err } +// GetRedditID returns the client's Reddit ID. +func (c *Client) GetRedditID(ctx context.Context) (string, error) { + if c.redditID != "" { + return c.redditID, nil + } + + self, _, err := c.User.Get(ctx, c.Username) + if err != nil { + return "", err + } + + c.redditID = fmt.Sprintf("%s_%s", kindAccount, self.ID) + return c.redditID, nil +} + // DoRequest submits an HTTP request. func DoRequest(ctx context.Context, req *http.Request) (*http.Response, error) { return DoRequestWithClient(ctx, http.DefaultClient, req) @@ -304,7 +322,7 @@ func CheckResponse(r *http.Response) error { return errorResponse } -// ListOptions are the optional parameters to the various endpoints that return lists +// ListOptions are the optional parameters to the various endpoints that return lists. type ListOptions struct { // For getting submissions // all, year, month, week, day, hour diff --git a/testdata/user/block.json b/testdata/user/block.json new file mode 100644 index 0000000..f4283bc --- /dev/null +++ b/testdata/user/block.json @@ -0,0 +1,6 @@ +{ + "date": 1592326190.0, + "icon_img": "https://www.redditstatic.com/avatars/avatar_default_08_C18D42.png", + "id": "t2_3v9o1yoi", + "name": "test123" +} diff --git a/testdata/user/friend.json b/testdata/user/friend.json new file mode 100644 index 0000000..792e98d --- /dev/null +++ b/testdata/user/friend.json @@ -0,0 +1,6 @@ +{ + "date": 1592512594.0, + "rel_id": "r9_tqfqp8", + "name": "test123", + "id": "t2_7b8q1eob" +} diff --git a/testdata/user/trophies.json b/testdata/user/trophies.json new file mode 100644 index 0000000..88ad1ed --- /dev/null +++ b/testdata/user/trophies.json @@ -0,0 +1,31 @@ +{ + "kind": "TrophyList", + "data": { + "trophies": [ + { + "kind": "t6", + "data": { + "icon_70": "https://www.redditstatic.com/awards2/3_year_club-70.png", + "name": "Three-Year Club", + "url": null, + "icon_40": "https://www.redditstatic.com/awards2/3_year_club-40.png", + "award_id": null, + "id": null, + "description": null + } + }, + { + "kind": "t6", + "data": { + "icon_70": "https://www.redditstatic.com/awards2/verified_email-70.png", + "name": "Verified Email", + "url": null, + "icon_40": "https://www.redditstatic.com/awards2/verified_email-40.png", + "award_id": "o", + "id": "1q1tez", + "description": null + } + } + ] + } +} diff --git a/things.go b/things.go index 376b712..7001acf 100644 --- a/things.go +++ b/things.go @@ -6,15 +6,16 @@ import ( ) const ( - kindComment = "t1" - kindAccount = "t2" - kindLink = "t3" - kindMessage = "t4" - kindSubreddit = "t5" - kindAward = "t6" - kindListing = "Listing" - kindUserList = "UserList" - kindMode = "more" + kindComment = "t1" + kindAccount = "t2" + kindLink = "t3" + kindMessage = "t4" + kindSubreddit = "t5" + kindAward = "t6" + kindListing = "Listing" + kingTrophyList = "TrophyList" + kindUserList = "UserList" + kindMode = "more" ) // Sort is a sorting option. diff --git a/user.go b/user.go index 60d8ac0..5932833 100644 --- a/user.go +++ b/user.go @@ -2,6 +2,8 @@ package geddit import ( "context" + "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -29,9 +31,17 @@ type UserService interface { Hidden(ctx context.Context, opts ...SearchOptionSetter) (*Links, *Response, error) Gilded(ctx context.Context, opts ...SearchOptionSetter) (*Links, *Response, error) - Friend(ctx context.Context, username string, note string) (interface{}, *Response, error) - Unblock(ctx context.Context, username string) (*Response, error) + GetFriendship(ctx context.Context, username string) (*Friendship, *Response, error) + Friend(ctx context.Context, username string) (*Friendship, *Response, error) Unfriend(ctx context.Context, username string) (*Response, error) + + Block(ctx context.Context, username string) (*Blocked, *Response, error) + // BlockByID(ctx context.Context, id string) (*Blocked, *Response, error) + Unblock(ctx context.Context, username string) (*Response, error) + // UnblockByID(ctx context.Context, id string) (*Response, error) + + GetTrophies(ctx context.Context) (Trophies, *Response, error) + GetTrophiesOf(ctx context.Context, username string) (Trophies, *Response, error) } // UserServiceOp implements the UserService interface @@ -70,6 +80,79 @@ type UserShort struct { NSFW bool `json:"profile_over_18"` } +// Friendship represents a friend relationship. +type Friendship struct { + ID string `json:"rel_id,omitempty"` + Friend string `json:"name,omitempty"` + FriendID string `json:"id,omitempty"` + Created *Timestamp `json:"date,omitempty"` +} + +// 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"` + Trophies Trophies `json:"data"` +} + +// Trophy is a Reddit award. +type Trophy struct { + Name string `json:"name"` +} + +// Trophies is a list of trophies. +type Trophies []Trophy + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (t *Trophies) UnmarshalJSON(b []byte) error { + var data map[string]interface{} + if err := json.Unmarshal(b, &data); err != nil { + return err + } + + trophies, ok := data["trophies"] + if !ok { + return errors.New("data does not contain trophies") + } + + trophyList, ok := trophies.([]interface{}) + if !ok { + return errors.New("unexpected type for list of trophies") + } + + for _, trophyData := range trophyList { + trophyInfo, ok := trophyData.(map[string]interface{}) + if !ok { + continue + } + + info, ok := trophyInfo["data"] + if !ok { + continue + } + + infoBytes, err := json.Marshal(info) + if err != nil { + continue + } + + var trophy Trophy + err = json.Unmarshal(infoBytes, &trophy) + if err != nil { + continue + } + + *t = append(*t, trophy) + } + + return nil +} + // Get returns information about the user func (s *UserServiceOp) Get(ctx context.Context, username string) (*User, *Response, error) { path := fmt.Sprintf("user/%s/about", username) @@ -324,58 +407,113 @@ func (s *UserServiceOp) Gilded(ctx context.Context, opts ...SearchOptionSetter) return root.getLinks(), resp, nil } -// // Friend creates or updates a "friend" relationship -// // Request body contains JSON data with: -// // name: existing Reddit username -// // note: a string no longer than 300 characters -// func (s *UserServiceOp) Friend(ctx context.Context, username string, note string) (interface{}, *Response, error) { -// type request struct { -// Username string `url:"name"` -// Note string `url:"note"` -// } +// GetFriendship returns friendship details with the specified user. +// If the user is not your friend, it will return an error. +func (s *UserServiceOp) GetFriendship(ctx context.Context, username string) (*Friendship, *Response, error) { + path := fmt.Sprintf("api/v1/me/friends/%s", username) -// path := fmt.Sprintf("api/v1/me/friends/%s", username) -// body := request{Username: username, Note: note} + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } -// _, err := s.client.NewRequest(http.MethodPut, path, body) -// if err != nil { -// return false, nil, err -// } + root := new(Friendship) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } -// // todo: requires gold -// return nil, nil, nil -// } + return root, resp, nil +} -// Friend creates or updates a "friend" relationship -// Request body contains JSON data with: -// name: existing Reddit username -// note: a string no longer than 300 characters -func (s *UserServiceOp) Friend(ctx context.Context, username string, note string) (interface{}, *Response, error) { +// Friend friends a user. +func (s *UserServiceOp) Friend(ctx context.Context, username string) (*Friendship, *Response, error) { type request struct { - Username string `url:"name"` - Note string `url:"note"` + Username string `json:"name"` } path := fmt.Sprintf("api/v1/me/friends/%s", username) - body := request{Username: username, Note: note} + body := request{username} - _, err := s.client.NewRequest(http.MethodPut, path, body) + req, err := s.client.NewRequest(http.MethodPut, path, body) if err != nil { - return false, nil, err + return nil, nil, err } - // todo: requires gold - return nil, nil, nil + root := new(Friendship) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil } -// Unblock unblocks a user +// Unfriend unfriends a user. +func (s *UserServiceOp) 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 *UserServiceOp) Block(ctx context.Context, username string) (*Blocked, *Response, error) { + path := "api/block_user" + + form := url.Values{} + form.Set("name", username) + + req, err := s.client.NewPostForm(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 *UserServiceOp) 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.NewPostForm(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 *UserServiceOp) Unblock(ctx context.Context, username string) (*Response, error) { + selfID, err := s.client.GetRedditID(ctx) + if err != nil { + return nil, err + } + path := "api/unfriend" form := url.Values{} form.Set("name", username) form.Set("type", "enemy") - form.Set("container", "todo: this should be the current user's full id") + form.Set("container", selfID) req, err := s.client.NewPostForm(path, form) if err != nil { @@ -385,13 +523,47 @@ func (s *UserServiceOp) Unblock(ctx context.Context, username string) (*Response return s.client.Do(ctx, req, nil) } -// Unfriend unfriends a user -func (s *UserServiceOp) 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) +// // UnblockByID unblocks a user via their full id. +// func (s *UserServiceOp) UnblockByID(ctx context.Context, id string) (*Response, error) { +// selfID, err := s.client.GetRedditID(ctx) +// if err != nil { +// return nil, err +// } + +// path := "api/unfriend" + +// form := url.Values{} +// form.Set("id", id) +// form.Set("type", "enemy") +// form.Set("container", selfID) + +// req, err := s.client.NewPostForm(path, form) +// if err != nil { +// return nil, err +// } + +// return s.client.Do(ctx, req, nil) +// } + +// GetTrophies returns a list of your trophies. +func (s *UserServiceOp) GetTrophies(ctx context.Context) (Trophies, *Response, error) { + return s.GetTrophiesOf(ctx, s.client.Username) +} + +// GetTrophiesOf returns a list of the specified user's trophies. +func (s *UserServiceOp) GetTrophiesOf(ctx context.Context, username string) (Trophies, *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, err + return nil, nil, err } - return s.client.Do(ctx, req, nil) + root := new(rootTrophyListing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Trophies, resp, nil } diff --git a/user_test.go b/user_test.go index 8004210..5d31aea 100644 --- a/user_test.go +++ b/user_test.go @@ -1,6 +1,7 @@ package geddit import ( + "encoding/json" "fmt" "net/http" "net/url" @@ -102,6 +103,24 @@ var expectedComment = Comment{ LinkNumComments: 89751, } +var expectedFriendship = &Friendship{ + ID: "r9_tqfqp8", + Friend: "test123", + FriendID: "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 = Trophies{ + {Name: "Three-Year Club"}, + {Name: "Verified Email"}, +} + func TestUserService_Get(t *testing.T) { setup() defer teardown() @@ -564,3 +583,185 @@ func TestUserService_Gilded(t *testing.T) { assert.Equal(t, "t3_gczwql", posts.After) assert.Equal(t, "", posts.Before) } + +func TestUserService_GetFriendship(t *testing.T) { + setup() + defer teardown() + + blob := readFileContents(t, "testdata/user/friend.json") + + mux.HandleFunc("/api/v1/me/friends/test123", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + friendship, _, err := client.User.GetFriendship(ctx, "test123") + assert.NoError(t, err) + assert.Equal(t, expectedFriendship, friendship) +} + +func TestUserService_Friend(t *testing.T) { + setup() + defer teardown() + + blob := readFileContents(t, "testdata/user/friend.json") + + mux.HandleFunc("/api/v1/me/friends/test123", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + + type request struct { + Username string `json:"name"` + } + + var req request + err := json.NewDecoder(r.Body).Decode(&req) + assert.NoError(t, err) + assert.Equal(t, "test123", req.Username) + + fmt.Fprint(w, blob) + }) + + friendship, _, err := client.User.Friend(ctx, "test123") + assert.NoError(t, err) + assert.Equal(t, expectedFriendship, friendship) +} + +func TestUserService_Unfriend(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/api/v1/me/friends/test123", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + }) + + res, err := client.User.Unfriend(ctx, "test123") + assert.NoError(t, err) + assert.Equal(t, http.StatusNoContent, res.StatusCode) +} + +func TestUserService_Block(t *testing.T) { + setup() + defer teardown() + + blob := readFileContents(t, "testdata/user/block.json") + + mux.HandleFunc("/api/block_user", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("name", "test123") + + err := r.ParseForm() + assert.NoError(t, err) + assert.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + blocked, _, err := client.User.Block(ctx, "test123") + assert.NoError(t, err) + assert.Equal(t, expectedBlocked, blocked) +} + +// func TestUserService_BlockByID(t *testing.T) { +// setup() +// defer teardown() + +// blob := readFileContents(t, "testdata/user/block.json") + +// mux.HandleFunc("/api/block_user", func(w http.ResponseWriter, r *http.Request) { +// assert.Equal(t, http.MethodPost, r.Method) + +// form := url.Values{} +// form.Set("account_id", "abc123") + +// err := r.ParseForm() +// assert.NoError(t, err) +// assert.Equal(t, form, r.Form) + +// fmt.Fprint(w, blob) +// }) + +// blocked, _, err := client.User.BlockByID(ctx, "abc123") +// assert.NoError(t, err) +// assert.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) { + assert.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() + assert.NoError(t, err) + assert.Equal(t, form, r.Form) + }) + + _, err := client.User.Unblock(ctx, "test123") + assert.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) { +// assert.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() +// assert.NoError(t, err) +// assert.Equal(t, form, r.Form) +// }) + +// _, err := client.User.UnblockByID(ctx, "abc123") +// assert.NoError(t, err) +// } + +func TestUserService_GetTrophies(t *testing.T) { + setup() + defer teardown() + + blob := readFileContents(t, "testdata/user/trophies.json") + + mux.HandleFunc("/api/v1/user/user1/trophies", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + trophies, _, err := client.User.GetTrophies(ctx) + assert.NoError(t, err) + assert.Equal(t, expectedTrophies, trophies) +} + +func TestUserService_GetTrophiesOf(t *testing.T) { + setup() + defer teardown() + + blob := readFileContents(t, "testdata/user/trophies.json") + + mux.HandleFunc("/api/v1/user/test123/trophies", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + trophies, _, err := client.User.GetTrophiesOf(ctx, "test123") + assert.NoError(t, err) + assert.Equal(t, expectedTrophies, trophies) +} diff --git a/vote.go b/vote.go index a0cf8c8..538f397 100644 --- a/vote.go +++ b/vote.go @@ -43,12 +43,7 @@ func (s *VoteServiceOp) vote(ctx context.Context, id string, vote vote) (*Respon return nil, err } - resp, err := s.client.Do(ctx, req, nil) - if err != nil { - return resp, err - } - - return resp, nil + return s.client.Do(ctx, req, nil) } // Up upvotes a link or a comment