diff --git a/.travis.yml b/.travis.yml index bdcd0b6..a279372 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,12 @@ language: go go: - - 1.6 - - 1.7 - - 1.8 - - tip + - "1.10.x" + - "1.11.x" + - "1.12.x" install: - - go get github.com/golang/lint/golint + - go get golang.org/x/lint/golint - go get -v -t ./twitter script: - - ./test + - make +notifications: + email: change diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..4655805 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,16 @@ +# go-twitter Changelog + +Notable changes over time. Note, `go-twitter` does not follow a semver release cycle since it may change whenever the Twitter API changes (external). + +## 07/2019 + +* Add Go module support ([#143](https://github.com/dghubble/go-twitter/pull/143)) + +## 11/2018 + +* Add `DirectMessageService` support for the new Twitter Direct Message Events [API](https://developer.twitter.com/en/docs/direct-messages/api-features) ([#125](https://github.com/dghubble/go-twitter/pull/125)) + * Add `EventsNew` method for sending a Direct Message event + * Add `EventsShow` method for getting a single Direct Message event + * Add `EventsList` method for listing recent Direct Message events + * Add`EventsDestroy` method for destroying a Direct Message event + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..980fccf --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: all +all: test vet lint fmt + +.PHONY: test +test: + @go test ./twitter -cover + +.PHONY: vet +vet: + @go vet -all ./twitter + +.PHONY: lint +lint: + @golint -set_exit_status ./... + +.PHONY: fmt +fmt: + @test -z $$(go fmt ./...) + diff --git a/README.md b/README.md index e6f3754..acf303f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ - - -# go-twitter [![Build Status](https://travis-ci.org/dghubble/go-twitter.png)](https://travis-ci.org/dghubble/go-twitter) [![GoDoc](https://godoc.org/github.com/dghubble/go-twitter?status.png)](https://godoc.org/github.com/dghubble/go-twitter) +# go-twitter [![Build Status](https://travis-ci.org/dghubble/go-twitter.svg?branch=master)](https://travis-ci.org/dghubble/go-twitter) [![GoDoc](https://godoc.org/github.com/dghubble/go-twitter?status.svg)](https://godoc.org/github.com/dghubble/go-twitter) go-twitter is a Go client library for the [Twitter API](https://dev.twitter.com/rest/public). Check the [usage](#usage) section or try the [examples](/examples) to see how to access the Twitter API. @@ -9,11 +7,14 @@ go-twitter is a Go client library for the [Twitter API](https://dev.twitter.com/ * Twitter REST API: * Accounts - * Direct Messages + * DirectMessageEvents * Favorites * Friends * Friendships * Followers + * Lists + * PremiumSearch + * RateLimits * Search * Statuses * Timelines @@ -243,15 +244,20 @@ If no user auth context is needed, make requests as your application with applic import ( "github.com/dghubble/go-twitter/twitter" "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" ) -config := &oauth2.Config{} -token := &oauth2.Token{AccessToken: accessToken} +// oauth2 configures a client that uses app credentials to keep a fresh token +config := &clientcredentials.Config{ + ClientID: "consumerKey", + ClientSecret: "consumerSecret", + TokenURL: "https://api.twitter.com/oauth2/token", +} // http.Client will automatically authorize Requests -httpClient := config.Client(oauth2.NoContext, token) +httpClient := config.Client(oauth2.NoContext) // Twitter client -client := twitter.NewClient(httpClient) +client := twitter.NewClient(httpClient) ``` To implement Login with Twitter for web or mobile, see the gologin [package](https://github.com/dghubble/gologin) and [examples](https://github.com/dghubble/gologin/tree/master/examples/twitter). diff --git a/examples/README.md b/examples/README.md index 1e291e2..aeb3ab1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,32 +11,42 @@ Get the dependencies and examples A user access token (OAuth1) grants a consumer application access to a user's Twitter resources. Setup an OAuth1 `http.Client` with the consumer key and secret and oauth token and secret. - - export TWITTER_CONSUMER_KEY=xxx - export TWITTER_CONSUMER_SECRET=xxx - export TWITTER_ACCESS_TOKEN=xxx - export TWITTER_ACCESS_SECRET=xxx +``` +export TWITTER_CONSUMER_KEY=xxx +export TWITTER_CONSUMER_SECRET=xxx +export TWITTER_ACCESS_TOKEN=xxx +export TWITTER_ACCESS_SECRET=xxx +``` To make requests as an application, on behalf of a user, create a `twitter` `Client` to get the home timeline, mention timeline, and more (example will **not** post Tweets). - go run user-auth.go +``` +go run user-auth.go +``` ## App Auth (OAuth2) An application access token (OAuth2) allows an application to make Twitter API requests for public content, with rate limits counting against the app itself. App auth requests can be made to API endpoints which do not require a user context. -Setup an OAuth2 `http.Client` with the Twitter application access token. +Setup an OAuth2 `http.Client` with the Twitter consumer key and secret. - export TWITTER_APP_ACCESS_TOKEN=xxx +``` +export TWITTER_CONSUMER_KEY=xxx +export TWITTER_CONSUMER_SECRET=xxx +``` To make requests as an application, create a `twitter` `Client` and get public Tweets or timelines or other public content. - go run app-auth.go +``` +go run app-auth.go +``` ## Streaming API A user access token (OAuth1) is required for Streaming API requests. See above. - go run streaming.go +``` +go run streaming.go +``` -Hit CTRL-C to stop streaming. Uncomment different examples in code to try different streams. \ No newline at end of file +Hit CTRL-C to stop streaming. Uncomment different examples in code to try different streams. diff --git a/examples/app-auth.go b/examples/app-auth.go index a9223f1..95b08ae 100644 --- a/examples/app-auth.go +++ b/examples/app-auth.go @@ -4,27 +4,36 @@ import ( "flag" "fmt" "log" - "os" "github.com/coreos/pkg/flagutil" "github.com/dghubble/go-twitter/twitter" "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" ) func main() { - flags := flag.NewFlagSet("app-auth", flag.ExitOnError) - accessToken := flags.String("app-access-token", "", "Twitter Application Access Token") - flags.Parse(os.Args[1:]) - flagutil.SetFlagsFromEnv(flags, "TWITTER") + flags := struct { + consumerKey string + consumerSecret string + }{} - if *accessToken == "" { + flag.StringVar(&flags.consumerKey, "consumer-key", "", "Twitter Consumer Key") + flag.StringVar(&flags.consumerSecret, "consumer-secret", "", "Twitter Consumer Secret") + flag.Parse() + flagutil.SetFlagsFromEnv(flag.CommandLine, "TWITTER") + + if flags.consumerKey == "" || flags.consumerSecret == "" { log.Fatal("Application Access Token required") } - config := &oauth2.Config{} - token := &oauth2.Token{AccessToken: *accessToken} - // OAuth2 http.Client will automatically authorize Requests - httpClient := config.Client(oauth2.NoContext, token) + // oauth2 configures a client that uses app credentials to keep a fresh token + config := &clientcredentials.Config{ + ClientID: flags.consumerKey, + ClientSecret: flags.consumerSecret, + TokenURL: "https://api.twitter.com/oauth2/token", + } + // http.Client will automatically authorize Requests + httpClient := config.Client(oauth2.NoContext) // Twitter client client := twitter.NewClient(httpClient) diff --git a/examples/direct_messages.go b/examples/direct_messages.go new file mode 100644 index 0000000..b859f21 --- /dev/null +++ b/examples/direct_messages.go @@ -0,0 +1,74 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/coreos/pkg/flagutil" + "github.com/dghubble/go-twitter/twitter" + "github.com/dghubble/oauth1" +) + +func main() { + flags := flag.NewFlagSet("user-auth", flag.ExitOnError) + consumerKey := flags.String("consumer-key", "", "Twitter Consumer Key") + consumerSecret := flags.String("consumer-secret", "", "Twitter Consumer Secret") + accessToken := flags.String("access-token", "", "Twitter Access Token") + accessSecret := flags.String("access-secret", "", "Twitter Access Secret") + flags.Parse(os.Args[1:]) + flagutil.SetFlagsFromEnv(flags, "TWITTER") + + if *consumerKey == "" || *consumerSecret == "" || *accessToken == "" || *accessSecret == "" { + log.Fatal("Consumer key/secret and Access token/secret required") + } + + config := oauth1.NewConfig(*consumerKey, *consumerSecret) + token := oauth1.NewToken(*accessToken, *accessSecret) + // OAuth1 http.Client will automatically authorize Requests + httpClient := config.Client(oauth1.NoContext, token) + + // Twitter client + client := twitter.NewClient(httpClient) + + // List most recent 10 Direct Messages + messages, _, err := client.DirectMessages.EventsList( + &twitter.DirectMessageEventsListParams{Count: 10}, + ) + fmt.Println("User's DIRECT MESSAGES:") + if err != nil { + log.Fatal(err) + } + for _, event := range messages.Events { + fmt.Printf("%+v\n", event) + fmt.Printf(" %+v\n", event.Message) + fmt.Printf(" %+v\n", event.Message.Data) + } + + // Show Direct Message event + event, _, err := client.DirectMessages.EventsShow("1066903366071017476", nil) + fmt.Printf("DM Events Show:\n%+v, %v\n", event.Message.Data, err) + + // Create Direct Message event + /* + event, _, err = client.DirectMessages.EventsNew(&twitter.DirectMessageEventsNewParams{ + Event: &twitter.DirectMessageEvent{ + Type: "message_create", + Message: &twitter.DirectMessageEventMessage{ + Target: &twitter.DirectMessageTarget{ + RecipientID: "2856535627", + }, + Data: &twitter.DirectMessageData{ + Text: "testing", + }, + }, + }, + }) + fmt.Printf("DM Event New:\n%+v, %v\n", event, err) + */ + + // Destroy Direct Message event + //_, err = client.DirectMessages.EventsDestroy("1066904217049133060") + //fmt.Printf("DM Events Delete:\n err: %v\n", err) +} diff --git a/examples/go.mod b/examples/go.mod new file mode 100644 index 0000000..57d79a3 --- /dev/null +++ b/examples/go.mod @@ -0,0 +1,12 @@ +module github.com/dghubble/go-twitter/examples + +go 1.12 + +require ( + github.com/cenkalti/backoff v2.1.1+incompatible // indirect + github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect + github.com/dghubble/go-twitter v0.0.0-20190512073027-53f972dc4b06 // indirect + github.com/dghubble/oauth1 v0.6.0 // indirect + github.com/dghubble/sling v1.3.0 // indirect + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 // indirect +) diff --git a/examples/go.sum b/examples/go.sum new file mode 100644 index 0000000..7cda7f0 --- /dev/null +++ b/examples/go.sum @@ -0,0 +1,26 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= +github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dghubble/go-twitter v0.0.0-20190512073027-53f972dc4b06 h1:eg2cM+xR5Bgm4hgJ5xmtbOkgDAJSOXP4WGr2hEFdqe4= +github.com/dghubble/go-twitter v0.0.0-20190512073027-53f972dc4b06/go.mod h1:6beqTZaXeBPti9pDBcBEqxfJc7uCbSafqZPRDPQOKoM= +github.com/dghubble/oauth1 v0.6.0 h1:m1yC01Ohc/eF38jwZ8JUjL1a+XHHXtGQgK+MxQbmSx0= +github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk= +github.com/dghubble/sling v1.3.0 h1:pZHjCJq4zJvc6qVQ5wN1jo5oNZlNE0+8T/h0XeXBUKU= +github.com/dghubble/sling v1.3.0/go.mod h1:XXShWaBWKzNLhu2OxikSNFrlsvowtz4kyRuXUG7oQKY= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5a78d81 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/dghubble/go-twitter + +go 1.12 + +require ( + github.com/cenkalti/backoff v2.1.1+incompatible + github.com/dghubble/sling v1.3.0 + github.com/stretchr/testify v1.3.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6451250 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= +github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dghubble/sling v1.3.0 h1:pZHjCJq4zJvc6qVQ5wN1jo5oNZlNE0+8T/h0XeXBUKU= +github.com/dghubble/sling v1.3.0/go.mod h1:XXShWaBWKzNLhu2OxikSNFrlsvowtz4kyRuXUG7oQKY= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/test b/test deleted file mode 100755 index 6bc2687..0000000 --- a/test +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -e - -PKGS=$(go list ./... | grep -v /examples) -FORMATTABLE="$(ls -d */)" -LINTABLE=$(go list ./...) - -go test $PKGS -cover -go vet $PKGS - -echo "Checking gofmt..." -fmtRes=$(gofmt -l $FORMATTABLE) -if [ -n "${fmtRes}" ]; then - echo -e "gofmt checking failed:\n${fmtRes}" - exit 2 -fi - -echo "Checking golint..." -lintRes=$(echo $LINTABLE | xargs -n 1 golint) -if [ -n "${lintRes}" ]; then - echo -e "golint checking failed:\n${lintRes}" - exit 2 -fi \ No newline at end of file diff --git a/twitter/backoffs.go b/twitter/backoffs.go index d3a5321..dc6e376 100644 --- a/twitter/backoffs.go +++ b/twitter/backoffs.go @@ -23,3 +23,7 @@ func newAggressiveExponentialBackOff() *backoff.ExponentialBackOff { b.Reset() return b } + +func newBackoffWithMaxRetries(b backoff.BackOff, maxRetries uint64) backoff.BackOff { + return backoff.WithMaxRetries(b, maxRetries) +} diff --git a/twitter/backoffs_test.go b/twitter/backoffs_test.go index 49bfd03..bbacb8c 100644 --- a/twitter/backoffs_test.go +++ b/twitter/backoffs_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/cenkalti/backoff" "github.com/stretchr/testify/assert" ) @@ -24,11 +25,15 @@ func TestNewAggressiveExponentialBackOff(t *testing.T) { // BackoffRecorder is an implementation of backoff.BackOff that records // calls to NextBackOff and Reset for later inspection in tests. type BackOffRecorder struct { - Count int + Count int + MaxRetries int } func (b *BackOffRecorder) NextBackOff() time.Duration { b.Count++ + if b.Count == b.MaxRetries { + return backoff.Stop + } return 1 * time.Nanosecond } diff --git a/twitter/demux.go b/twitter/demux.go index 29e21f2..3fa206a 100644 --- a/twitter/demux.go +++ b/twitter/demux.go @@ -23,6 +23,7 @@ type SwitchDemux struct { Warning func(warning *StallWarning) FriendsList func(friendsList *FriendsList) Event func(event *Event) + APIError func(apiError *APIError) Other func(message interface{}) } @@ -41,6 +42,7 @@ func NewSwitchDemux() SwitchDemux { Warning: func(warning *StallWarning) {}, FriendsList: func(friendsList *FriendsList) {}, Event: func(event *Event) {}, + APIError: func(apiError *APIError) {}, Other: func(message interface{}) {}, } } @@ -73,6 +75,8 @@ func (d SwitchDemux) Handle(message interface{}) { d.FriendsList(msg) case *Event: d.Event(msg) + case *APIError: + d.APIError(msg) default: d.Other(msg) } diff --git a/twitter/demux_test.go b/twitter/demux_test.go index ccdce52..a78f6fc 100644 --- a/twitter/demux_test.go +++ b/twitter/demux_test.go @@ -47,6 +47,7 @@ type counter struct { stallWarning int friendsList int event int + apiError int other int } @@ -89,6 +90,9 @@ func newCounterDemux(counter *counter) Demux { demux.Event = func(*Event) { counter.event++ } + demux.APIError = func(*APIError) { + counter.apiError++ + } demux.Other = func(interface{}) { counter.other++ } @@ -110,12 +114,13 @@ func exampleMessages() (messages []interface{}, expectedCounts *counter) { stallWarning = &StallWarning{} friendsList = &FriendsList{} event = &Event{} + apiError = &APIError{} otherA = func() {} otherB = struct{}{} ) messages = []interface{}{tweet, dm, statusDeletion, locationDeletion, streamLimit, statusWithheld, userWithheld, streamDisconnect, - stallWarning, friendsList, event, otherA, otherB} + stallWarning, friendsList, event, apiError, otherA, otherB} expectedCounts = &counter{ all: len(messages), tweet: 1, @@ -129,6 +134,7 @@ func exampleMessages() (messages []interface{}, expectedCounts *counter) { stallWarning: 1, friendsList: 1, event: 1, + apiError: 1, other: 2, } return messages, expectedCounts diff --git a/twitter/direct_messages.go b/twitter/direct_messages.go index 3d91ec5..2d95cce 100644 --- a/twitter/direct_messages.go +++ b/twitter/direct_messages.go @@ -2,23 +2,74 @@ package twitter import ( "net/http" + "time" "github.com/dghubble/sling" ) -// DirectMessage is a direct message to a single recipient. -type DirectMessage struct { - CreatedAt string `json:"created_at"` - Entities *Entities `json:"entities"` - ID int64 `json:"id"` - IDStr string `json:"id_str"` - Recipient *User `json:"recipient"` - RecipientID int64 `json:"recipient_id"` - RecipientScreenName string `json:"recipient_screen_name"` - Sender *User `json:"sender"` - SenderID int64 `json:"sender_id"` - SenderScreenName string `json:"sender_screen_name"` - Text string `json:"text"` +// DirectMessageEvents lists Direct Message events. +type DirectMessageEvents struct { + Events []DirectMessageEvent `json:"events"` + NextCursor string `json:"next_cursor"` +} + +// DirectMessageEvent is a single Direct Message sent or received. +type DirectMessageEvent struct { + CreatedAt string `json:"created_timestamp,omitempty"` + ID string `json:"id,omitempty"` + Type string `json:"type"` + Message *DirectMessageEventMessage `json:"message_create"` +} + +// DirectMessageEventMessage contains message contents, along with sender and +// target recipient. +type DirectMessageEventMessage struct { + SenderID string `json:"sender_id,omitempty"` + Target *DirectMessageTarget `json:"target"` + Data *DirectMessageData `json:"message_data"` +} + +// DirectMessageTarget specifies the recipient of a Direct Message event. +type DirectMessageTarget struct { + RecipientID string `json:"recipient_id"` +} + +// DirectMessageData is the message data contained in a Direct Message event. +type DirectMessageData struct { + Text string `json:"text"` + Entities *Entities `json:"entitites,omitempty"` + Attachment *DirectMessageDataAttachment `json:"attachment,omitempty"` + QuickReply *DirectMessageQuickReply `json:"quick_reply,omitempty"` + CTAs []DirectMessageCTA `json:"ctas,omitempty"` +} + +// DirectMessageDataAttachment contains message data attachments for a Direct +// Message event. +type DirectMessageDataAttachment struct { + Type string `json:"type"` + Media MediaEntity `json:"media"` +} + +// DirectMessageQuickReply contains quick reply data for a Direct Message +// event. +type DirectMessageQuickReply struct { + Type string `json:"type"` + Options []DirectMessageQuickReplyOption `json:"options"` +} + +// DirectMessageQuickReplyOption represents Option object for +// a Direct Message's Quick Reply. +type DirectMessageQuickReplyOption struct { + Label string `json:"label"` + Description string `json:"description,omitempty"` + Metadata string `json:"metadata,omitempty"` +} + +// DirectMessageCTA contains CTA data for a Direct Message event. +type DirectMessageCTA struct { + Type string `json:"type"` + Label string `json:"label"` + URL string `json:"url"` } // DirectMessageService provides methods for accessing Twitter direct message @@ -36,12 +87,106 @@ func newDirectMessageService(sling *sling.Sling) *DirectMessageService { } } +// DirectMessageEventsNewParams are the parameters for +// DirectMessageService.EventsNew +type DirectMessageEventsNewParams struct { + Event *DirectMessageEvent `json:"event"` +} + +// EventsNew publishes a new Direct Message event and returns the event. +// Requires a user auth context with DM scope. +// https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event +func (s *DirectMessageService) EventsNew(params *DirectMessageEventsNewParams) (*DirectMessageEvent, *http.Response, error) { + // Twitter API wraps the event response + wrap := &struct { + Event *DirectMessageEvent `json:"event"` + }{} + apiError := new(APIError) + resp, err := s.sling.New().Post("events/new.json").BodyJSON(params).Receive(wrap, apiError) + return wrap.Event, resp, relevantError(err, *apiError) +} + +// DirectMessageEventsShowParams are the parameters for +// DirectMessageService.EventsShow +type DirectMessageEventsShowParams struct { + ID string `url:"id,omitempty"` +} + +// EventsShow returns a single Direct Message event by id. +// Requires a user auth context with DM scope. +// https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/get-event +func (s *DirectMessageService) EventsShow(id string, params *DirectMessageEventsShowParams) (*DirectMessageEvent, *http.Response, error) { + if params == nil { + params = &DirectMessageEventsShowParams{} + } + params.ID = id + // Twitter API wraps the event response + wrap := &struct { + Event *DirectMessageEvent `json:"event"` + }{} + apiError := new(APIError) + resp, err := s.sling.New().Get("events/show.json").QueryStruct(params).Receive(wrap, apiError) + return wrap.Event, resp, relevantError(err, *apiError) +} + +// DirectMessageEventsListParams are the parameters for +// DirectMessageService.EventsList +type DirectMessageEventsListParams struct { + Cursor string `url:"cursor,omitempty"` + Count int `url:"count,omitempty"` +} + +// EventsList returns Direct Message events (both sent and received) within +// the last 30 days in reverse chronological order. +// Requires a user auth context with DM scope. +// https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/list-events +func (s *DirectMessageService) EventsList(params *DirectMessageEventsListParams) (*DirectMessageEvents, *http.Response, error) { + events := new(DirectMessageEvents) + apiError := new(APIError) + resp, err := s.sling.New().Get("events/list.json").QueryStruct(params).Receive(events, apiError) + return events, resp, relevantError(err, *apiError) +} + +// EventsDestroy deletes the Direct Message event by id. +// Requires a user auth context with DM scope. +// https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/delete-message-event +func (s *DirectMessageService) EventsDestroy(id string) (*http.Response, error) { + params := struct { + ID string `url:"id,omitempty"` + }{id} + apiError := new(APIError) + resp, err := s.sling.New().Delete("events/destroy.json").QueryStruct(params).Receive(nil, apiError) + return resp, relevantError(err, *apiError) +} + +// DEPRECATED + +// DirectMessage is a direct message to a single recipient (DEPRECATED). +type DirectMessage struct { + CreatedAt string `json:"created_at"` + Entities *Entities `json:"entities"` + ID int64 `json:"id"` + IDStr string `json:"id_str"` + Recipient *User `json:"recipient"` + RecipientID int64 `json:"recipient_id"` + RecipientScreenName string `json:"recipient_screen_name"` + Sender *User `json:"sender"` + SenderID int64 `json:"sender_id"` + SenderScreenName string `json:"sender_screen_name"` + Text string `json:"text"` +} + +// CreatedAtTime returns the time a Direct Message was created (DEPRECATED). +func (d DirectMessage) CreatedAtTime() (time.Time, error) { + return time.Parse(time.RubyDate, d.CreatedAt) +} + // directMessageShowParams are the parameters for DirectMessageService.Show type directMessageShowParams struct { ID int64 `url:"id,omitempty"` } -// Show returns the requested Direct Message. +// Show returns the requested Direct Message (DEPRECATED). // Requires a user auth context with DM scope. // https://dev.twitter.com/rest/reference/get/direct_messages/show func (s *DirectMessageService) Show(id int64) (*DirectMessage, *http.Response, error) { @@ -53,6 +198,7 @@ func (s *DirectMessageService) Show(id int64) (*DirectMessage, *http.Response, e } // DirectMessageGetParams are the parameters for DirectMessageService.Get +// (DEPRECATED). type DirectMessageGetParams struct { SinceID int64 `url:"since_id,omitempty"` MaxID int64 `url:"max_id,omitempty"` @@ -61,7 +207,8 @@ type DirectMessageGetParams struct { SkipStatus *bool `url:"skip_status,omitempty"` } -// Get returns recent Direct Messages received by the authenticated user. +// Get returns recent Direct Messages received by the authenticated user +// (DEPRECATED). // Requires a user auth context with DM scope. // https://dev.twitter.com/rest/reference/get/direct_messages func (s *DirectMessageService) Get(params *DirectMessageGetParams) ([]DirectMessage, *http.Response, error) { @@ -72,6 +219,7 @@ func (s *DirectMessageService) Get(params *DirectMessageGetParams) ([]DirectMess } // DirectMessageSentParams are the parameters for DirectMessageService.Sent +// (DEPRECATED). type DirectMessageSentParams struct { SinceID int64 `url:"since_id,omitempty"` MaxID int64 `url:"max_id,omitempty"` @@ -80,7 +228,8 @@ type DirectMessageSentParams struct { IncludeEntities *bool `url:"include_entities,omitempty"` } -// Sent returns recent Direct Messages sent by the authenticated user. +// Sent returns recent Direct Messages sent by the authenticated user +// (DEPRECATED). // Requires a user auth context with DM scope. // https://dev.twitter.com/rest/reference/get/direct_messages/sent func (s *DirectMessageService) Sent(params *DirectMessageSentParams) ([]DirectMessage, *http.Response, error) { @@ -91,6 +240,7 @@ func (s *DirectMessageService) Sent(params *DirectMessageSentParams) ([]DirectMe } // DirectMessageNewParams are the parameters for DirectMessageService.New +// (DEPRECATED). type DirectMessageNewParams struct { UserID int64 `url:"user_id,omitempty"` ScreenName string `url:"screen_name,omitempty"` @@ -98,7 +248,7 @@ type DirectMessageNewParams struct { } // New sends a new Direct Message to a specified user as the authenticated -// user. +// user (DEPRECATED). // Requires a user auth context with DM scope. // https://dev.twitter.com/rest/reference/post/direct_messages/new func (s *DirectMessageService) New(params *DirectMessageNewParams) (*DirectMessage, *http.Response, error) { @@ -109,13 +259,14 @@ func (s *DirectMessageService) New(params *DirectMessageNewParams) (*DirectMessa } // DirectMessageDestroyParams are the parameters for DirectMessageService.Destroy +// (DEPRECATED). type DirectMessageDestroyParams struct { ID int64 `url:"id,omitempty"` IncludeEntities *bool `url:"include_entities,omitempty"` } // Destroy deletes the Direct Message with the given id and returns it if -// successful. +// successful (DEPRECATED). // Requires a user auth context with DM scope. // https://dev.twitter.com/rest/reference/post/direct_messages/destroy func (s *DirectMessageService) Destroy(id int64, params *DirectMessageDestroyParams) (*DirectMessage, *http.Response, error) { diff --git a/twitter/direct_messages_test.go b/twitter/direct_messages_test.go index c0ea242..e3aad8a 100644 --- a/twitter/direct_messages_test.go +++ b/twitter/direct_messages_test.go @@ -9,6 +9,47 @@ import ( ) var ( + testDMEvent = DirectMessageEvent{ + CreatedAt: "1542410751275", + ID: "1063573894173323269", + Type: "message_create", + Message: &DirectMessageEventMessage{ + SenderID: "623265148", + Target: &DirectMessageTarget{ + RecipientID: "3694959333", + }, + Data: &DirectMessageData{ + Text: "example", + }, + }, + } + testDMEventID = "1063573894173323269" + testDMEventJSON = ` +{ + "type": "message_create", + "id": "1063573894173323269", + "created_timestamp": "1542410751275", + "message_create": { + "target": { + "recipient_id": "3694959333" + }, + "sender_id": "623265148", + "message_data": { + "text": "example", + "entities": { + "hashtags": [], + "symbols": [], + "user_mentions": [], + "urls": [] + } + } + } +}` + testDMEventShowJSON = `{"event": ` + testDMEventJSON + `}` + testDMEventListJSON = `{"events": [` + testDMEventJSON + `], "next_cursor": "AB345dkfC"}` + testDMEventNewInputJSON = `{"event":{"type":"message_create","message_create":{"target":{"recipient_id":"3694959333"},"message_data":{"text":"example"}}}} +` + // DEPRECATED testDM = DirectMessage{ ID: 240136858829479936, Recipient: &User{ScreenName: "theSeanCook"}, @@ -19,6 +60,119 @@ var ( testDMJSON = `{"id": 240136858829479936,"recipient": {"screen_name": "theSeanCook"},"sender": {"screen_name": "s0c1alm3dia"},"text": "hello world"}` ) +func TestDirectMessageService_EventsNew(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/direct_messages/events/new.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostJSON(t, testDMEventNewInputJSON, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, testDMEventShowJSON) + }) + + client := NewClient(httpClient) + event, _, err := client.DirectMessages.EventsNew(&DirectMessageEventsNewParams{ + Event: &DirectMessageEvent{ + Type: "message_create", + Message: &DirectMessageEventMessage{ + Target: &DirectMessageTarget{ + RecipientID: "3694959333", + }, + Data: &DirectMessageData{ + Text: "example", + }, + }, + }, + }) + assert.Nil(t, err) + assert.Equal(t, &testDMEvent, event) +} + +func TestDirectMessageService_EventsShow(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/direct_messages/events/show.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"id": testDMEventID}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, testDMEventShowJSON) + }) + + client := NewClient(httpClient) + event, _, err := client.DirectMessages.EventsShow(testDMEventID, nil) + assert.Nil(t, err) + assert.Equal(t, &testDMEvent, event) +} + +func TestDirectMessageService_EventsList(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/direct_messages/events/list.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"count": "10"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, testDMEventListJSON) + }) + expected := &DirectMessageEvents{ + Events: []DirectMessageEvent{testDMEvent}, + NextCursor: "AB345dkfC", + } + + client := NewClient(httpClient) + events, _, err := client.DirectMessages.EventsList(&DirectMessageEventsListParams{Count: 10}) + assert.Equal(t, expected, events) + assert.Nil(t, err) +} + +func TestDirectMessageService_EventsDestroy(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/direct_messages/events/destroy.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "DELETE", r) + assertQuery(t, map[string]string{"id": testDMEventID}, r) + w.Header().Set("Content-Type", "application/json") + // successful delete returns 204 No Content + w.WriteHeader(204) + }) + + client := NewClient(httpClient) + resp, err := client.DirectMessages.EventsDestroy(testDMEventID) + assert.Nil(t, err) + assert.NotNil(t, resp) +} + +func TestDirectMessageService_EventsDestroyError(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/direct_messages/events/destroy.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "DELETE", r) + assertQuery(t, map[string]string{"id": testDMEventID}, r) + w.Header().Set("Content-Type", "application/json") + // failure to delete event that doesn't exist + w.WriteHeader(404) + fmt.Fprintf(w, `{"errors":[{"code": 34, "message": "Sorry, that page does not exist"}]}`) + }) + expected := APIError{ + Errors: []ErrorDetail{ + ErrorDetail{Code: 34, Message: "Sorry, that page does not exist"}, + }, + } + + client := NewClient(httpClient) + resp, err := client.DirectMessages.EventsDestroy(testDMEventID) + assert.NotNil(t, resp) + if assert.Error(t, err) { + assert.Equal(t, expected, err) + } +} + +// DEPRECATED + func TestDirectMessageService_Show(t *testing.T) { httpClient, mux, server := testServer() defer server.Close() diff --git a/twitter/doc.go b/twitter/doc.go index 47136c5..b79fbe0 100644 --- a/twitter/doc.go +++ b/twitter/doc.go @@ -54,14 +54,19 @@ application auth. import ( "github.com/dghubble/go-twitter/twitter" "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" ) - config := &oauth2.Config{} - token := &oauth2.Token{AccessToken: accessToken} + // oauth2 configures a client that uses app credentials to keep a fresh token + config := &clientcredentials.Config{ + ClientID: flags.consumerKey, + ClientSecret: flags.consumerSecret, + TokenURL: "https://api.twitter.com/oauth2/token", + } // http.Client will automatically authorize Requests - httpClient := config.Client(oauth2.NoContext, token) + httpClient := config.Client(oauth2.NoContext) - // twitter client + // Twitter client client := twitter.NewClient(httpClient) To implement Login with Twitter, see https://github.com/dghubble/gologin. diff --git a/twitter/entities.go b/twitter/entities.go index 85b617a..7167095 100644 --- a/twitter/entities.go +++ b/twitter/entities.go @@ -1,7 +1,7 @@ package twitter // Entities represent metadata and context info parsed from Twitter components. -// https://dev.twitter.com/overview/api/entities +// https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/entities-object // TODO: symbols type Entities struct { Hashtags []HashtagEntity `json:"hashtags"` @@ -48,14 +48,14 @@ type MentionEntity struct { } // UserEntities contain Entities parsed from User url and description fields. -// https://dev.twitter.com/overview/api/entities-in-twitter-objects#users +// https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/entities-object#mentions type UserEntities struct { URL Entities `json:"url"` Description Entities `json:"description"` } // ExtendedEntity contains media information. -// https://dev.twitter.com/overview/api/entities-in-twitter-objects#extended_entities +// https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/extended-entities-object type ExtendedEntity struct { Media []MediaEntity `json:"media"` } @@ -74,7 +74,7 @@ func (i Indices) End() int { } // MediaSizes contain the different size media that are available. -// https://dev.twitter.com/overview/api/entities#obj-sizes +// https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/entities-object#media-size type MediaSizes struct { Thumb MediaSize `json:"thumb"` Large MediaSize `json:"large"` diff --git a/twitter/lists.go b/twitter/lists.go new file mode 100644 index 0000000..f5d80f2 --- /dev/null +++ b/twitter/lists.go @@ -0,0 +1,434 @@ +package twitter + +import ( + "net/http" + + "github.com/dghubble/sling" +) + +// List represents a Twitter List. +type List struct { + Slug string `json:"slug"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + URI string `json:"uri"` + SubscriberCount int `json:"subscriber_count"` + IDStr string `json:"id_str"` + MemberCount int `json:"member_count"` + Mode string `json:"mode"` + ID int64 `json:"id"` + FullName string `json:"full_name"` + Description string `json:"description"` + User *User `json:"user"` + Following bool `json:"following"` +} + +// Members is a cursored collection of list members. +type Members struct { + Users []User `json:"users"` + NextCursor int64 `json:"next_cursor"` + NextCursorStr string `json:"next_cursor_str"` + PreviousCursor int64 `json:"previous_cursor"` + PreviousCursorStr string `json:"previous_cursor_str"` +} + +// Membership is a cursored collection of lists a user is on. +type Membership struct { + Lists []List `json:"lists"` + NextCursor int64 `json:"next_cursor"` + NextCursorStr string `json:"next_cursor_str"` + PreviousCursor int64 `json:"previous_cursor"` + PreviousCursorStr string `json:"previous_cursor_str"` +} + +// Ownership is a cursored collection of lists a user owns. +type Ownership struct { + Lists []List `json:"lists"` + NextCursor int64 `json:"next_cursor"` + NextCursorStr string `json:"next_cursor_str"` + PreviousCursor int64 `json:"previous_cursor"` + PreviousCursorStr string `json:"previous_cursor_str"` +} + +// Subscribers is a cursored collection of users that subscribe to a list. +type Subscribers struct { + Users []User `json:"users"` + NextCursor int64 `json:"next_cursor"` + NextCursorStr string `json:"next_cursor_str"` + PreviousCursor int64 `json:"previous_cursor"` + PreviousCursorStr string `json:"previous_cursor_str"` +} + +// Subscribed is a cursored collection of lists the user is subscribed to. +type Subscribed struct { + Lists []List `json:"lists"` + NextCursor int64 `json:"next_cursor"` + NextCursorStr string `json:"next_cursor_str"` + PreviousCursor int64 `json:"previous_cursor"` + PreviousCursorStr string `json:"previous_cursor_str"` +} + +// ListsService provides methods for accessing Twitter lists endpoints. +type ListsService struct { + sling *sling.Sling +} + +// newListService returns a new ListService. +func newListService(sling *sling.Sling) *ListsService { + return &ListsService{ + sling: sling.Path("lists/"), + } +} + +// ListsListParams are the parameters for ListsService.List +type ListsListParams struct { + UserID int64 `url:"user_id,omitempty"` + ScreenName string `url:"screen_name,omitempty"` + Reverse bool `url:"reverse,omitempty"` +} + +// List eturns all lists the authenticating or specified user subscribes to, including their own. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-list +func (s *ListsService) List(params *ListsListParams) ([]List, *http.Response, error) { + list := new([]List) + apiError := new(APIError) + resp, err := s.sling.New().Get("list.json").QueryStruct(params).Receive(list, apiError) + return *list, resp, relevantError(err, *apiError) +} + +// ListsMembersParams are the parameters for ListsService.Members +type ListsMembersParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` + Count int `url:"count,omitempty"` + Cursor int64 `url:"cursor,omitempty"` + IncludeEntities *bool `url:"include_entities,omitempty"` + SkipStatus *bool `url:"skip_status,omitempty"` +} + +// Members returns the members of the specified list +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-members +func (s *ListsService) Members(params *ListsMembersParams) (*Members, *http.Response, error) { + members := new(Members) + apiError := new(APIError) + resp, err := s.sling.New().Get("members.json").QueryStruct(params).Receive(members, apiError) + return members, resp, relevantError(err, *apiError) +} + +// ListsMembersShowParams are the parameters for ListsService.MembersShow +type ListsMembersShowParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + UserID int64 `url:"user_id,omitempty"` + ScreenName string `url:"screen_name,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` + IncludeEntities *bool `url:"include_entities,omitempty"` + SkipStatus *bool `url:"skip_status,omitempty"` +} + +// MembersShow checks if the specified user is a member of the specified list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-members-show +func (s *ListsService) MembersShow(params *ListsMembersShowParams) (*User, *http.Response, error) { + user := new(User) + apiError := new(APIError) + resp, err := s.sling.New().Get("members/show.json").QueryStruct(params).Receive(user, apiError) + return user, resp, relevantError(err, *apiError) +} + +// ListsMembershipsParams are the parameters for ListsService.Memberships +type ListsMembershipsParams struct { + UserID int64 `url:"user_id,omitempty"` + ScreenName string `url:"screen_name,omitempty"` + Count int `url:"count,omitempty"` + Cursor int64 `url:"cursor,omitempty"` + FilterToOwnedLists *bool `url:"filter_to_owned_lists,omitempty"` +} + +// Memberships returns the lists the specified user has been added to. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-memberships +func (s *ListsService) Memberships(params *ListsMembershipsParams) (*Membership, *http.Response, error) { + membership := new(Membership) + apiError := new(APIError) + resp, err := s.sling.New().Get("memberships.json").QueryStruct(params).Receive(membership, apiError) + return membership, resp, relevantError(err, *apiError) +} + +// ListsOwnershipsParams are the parameters for ListsService.Ownerships +type ListsOwnershipsParams struct { + UserID int64 `url:"user_id,omitempty"` + ScreenName string `url:"screen_name,omitempty"` + Count int `url:"count,omitempty"` + Cursor int64 `url:"cursor,omitempty"` +} + +// Ownerships returns the lists owned by the specified Twitter user. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-ownerships +func (s *ListsService) Ownerships(params *ListsOwnershipsParams) (*Ownership, *http.Response, error) { + ownership := new(Ownership) + apiError := new(APIError) + resp, err := s.sling.New().Get("ownerships.json").QueryStruct(params).Receive(ownership, apiError) + return ownership, resp, relevantError(err, *apiError) +} + +// ListsShowParams are the parameters for ListsService.Show +type ListsShowParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` +} + +// Show returns the specified list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-show +func (s *ListsService) Show(params *ListsShowParams) (*List, *http.Response, error) { + list := new(List) + apiError := new(APIError) + resp, err := s.sling.New().Get("show.json").QueryStruct(params).Receive(list, apiError) + return list, resp, relevantError(err, *apiError) +} + +// ListsStatusesParams are the parameters for ListsService.Statuses +type ListsStatusesParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` + SinceID int64 `url:"since_id,omitempty"` + MaxID int64 `url:"max_id,omitempty"` + Count int `url:"count,omitempty"` + IncludeEntities *bool `url:"include_entities,omitempty"` + IncludeRetweets *bool `url:"include_rts,omitempty"` +} + +// Statuses returns a timeline of tweets authored by members of the specified list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-statuses +func (s *ListsService) Statuses(params *ListsStatusesParams) ([]Tweet, *http.Response, error) { + tweets := new([]Tweet) + apiError := new(APIError) + resp, err := s.sling.New().Get("statuses.json").QueryStruct(params).Receive(tweets, apiError) + return *tweets, resp, relevantError(err, *apiError) +} + +// ListsSubscribersParams are the parameters for ListsService.Subscribers +type ListsSubscribersParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` + Count int `url:"count,omitempty"` + Cursor int64 `url:"cursor,omitempty"` + IncludeEntities *bool `url:"include_entities,omitempty"` + SkipStatus *bool `url:"skip_status,omitempty"` +} + +// Subscribers returns the subscribers of the specified list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-subscribers +func (s *ListsService) Subscribers(params *ListsSubscribersParams) (*Subscribers, *http.Response, error) { + subscribers := new(Subscribers) + apiError := new(APIError) + resp, err := s.sling.New().Get("subscribers.json").QueryStruct(params).Receive(subscribers, apiError) + return subscribers, resp, relevantError(err, *apiError) +} + +// ListsSubscribersShowParams are the parameters for ListsService.SubscribersShow +type ListsSubscribersShowParams struct { + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + UserID int64 `url:"user_id,omitempty"` + ScreenName string `url:"screen_name,omitempty"` + IncludeEntities *bool `url:"include_entities,omitempty"` + SkipStatus *bool `url:"skip_status,omitempty"` +} + +// SubscribersShow returns the user if they are a subscriber to the list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-subscribers-show +func (s *ListsService) SubscribersShow(params *ListsSubscribersShowParams) (*User, *http.Response, error) { + user := new(User) + apiError := new(APIError) + resp, err := s.sling.New().Get("subscribers/show.json").QueryStruct(params).Receive(user, apiError) + return user, resp, relevantError(err, *apiError) +} + +// ListsSubscriptionsParams are the parameters for ListsService.Subscriptions +type ListsSubscriptionsParams struct { + UserID int64 `url:"user_id,omitempty"` + ScreenName string `url:"screen_name,omitempty"` + Count int `url:"count,omitempty"` + Cursor int64 `url:"cursor,omitempty"` +} + +// Subscriptions returns a collection of the lists the specified user is subscribed to. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/get-lists-subscriptions +func (s *ListsService) Subscriptions(params *ListsSubscriptionsParams) (*Subscribed, *http.Response, error) { + subscribed := new(Subscribed) + apiError := new(APIError) + resp, err := s.sling.New().Get("subscriptions.json").QueryStruct(params).Receive(subscribed, apiError) + return subscribed, resp, relevantError(err, *apiError) +} + +// ListsCreateParams are the parameters for ListsService.Create +type ListsCreateParams struct { + Name string `url:"name,omitempty"` + Mode string `url:"mode,omitempty"` + Description string `url:"description,omitempty"` +} + +// Create creates a new list for the authenticated user. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-create +func (s *ListsService) Create(name string, params *ListsCreateParams) (*List, *http.Response, error) { + if params == nil { + params = &ListsCreateParams{} + } + params.Name = name + list := new(List) + apiError := new(APIError) + resp, err := s.sling.New().Post("create.json").BodyForm(params).Receive(list, apiError) + return list, resp, relevantError(err, *apiError) + +} + +// ListsDestroyParams are the parameters for ListsService.Destroy +type ListsDestroyParams struct { + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` +} + +// Destroy deletes the specified list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-destroy +func (s *ListsService) Destroy(params *ListsDestroyParams) (*List, *http.Response, error) { + list := new(List) + apiError := new(APIError) + resp, err := s.sling.New().Post("destroy.json").BodyForm(params).Receive(list, apiError) + return list, resp, relevantError(err, *apiError) +} + +// ListsMembersCreateParams are the parameters for ListsService.MembersCreate +type ListsMembersCreateParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + UserID int64 `url:"user_id,omitempty"` + ScreenName string `url:"screen_name,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` +} + +// MembersCreate adds a member to a list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-members-create +func (s *ListsService) MembersCreate(params *ListsMembersCreateParams) (*http.Response, error) { + apiError := new(APIError) + resp, err := s.sling.New().Post("members/create.json").BodyForm(params).Receive(nil, apiError) + return resp, err +} + +// ListsMembersCreateAllParams are the parameters for ListsService.MembersCreateAll +type ListsMembersCreateAllParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + UserID string `url:"user_id,omitempty"` + ScreenName string `url:"screen_name,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` +} + +// MembersCreateAll adds multiple members to a list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-members-create_all +func (s *ListsService) MembersCreateAll(params *ListsMembersCreateAllParams) (*http.Response, error) { + apiError := new(APIError) + resp, err := s.sling.New().Post("members/create_all.json").BodyForm(params).Receive(nil, apiError) + return resp, err +} + +// ListsMembersDestroyParams are the parameters for ListsService.MembersDestroy +type ListsMembersDestroyParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + UserID int64 `url:"user_id,omitempty"` + ScreenName string `url:"screen_name,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` +} + +// MembersDestroy removes the specified member from the list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-members-destroy +func (s *ListsService) MembersDestroy(params *ListsMembersDestroyParams) (*http.Response, error) { + apiError := new(APIError) + resp, err := s.sling.New().Post("members/destroy.json").BodyForm(params).Receive(nil, apiError) + return resp, err +} + +// ListsMembersDestroyAllParams are the parameters for ListsService.MembersDestroyAll +type ListsMembersDestroyAllParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + UserID string `url:"user_id,omitempty"` + ScreenName string `url:"screen_name,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` +} + +// MembersDestroyAll removes multiple members from a list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-members-destroy_all +func (s *ListsService) MembersDestroyAll(params *ListsMembersDestroyAllParams) (*http.Response, error) { + apiError := new(APIError) + resp, err := s.sling.New().Post("members/destroy_all.json").BodyForm(params).Receive(nil, apiError) + return resp, err +} + +// ListsSubscribersCreateParams are the parameters for ListsService.SubscribersCreate +type ListsSubscribersCreateParams struct { + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` +} + +// SubscribersCreate subscribes the authenticated user to the specified list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-subscribers-create +func (s *ListsService) SubscribersCreate(params *ListsSubscribersCreateParams) (*List, *http.Response, error) { + list := new(List) + apiError := new(APIError) + resp, err := s.sling.New().Post("subscribers/create.json").BodyForm(params).Receive(list, apiError) + return list, resp, err +} + +// ListsSubscribersDestroyParams are the parameters for ListsService.SubscribersDestroy +type ListsSubscribersDestroyParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` +} + +// SubscribersDestroy unsubscribes the authenticated user from the specified list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-subscribers-destroy +func (s *ListsService) SubscribersDestroy(params *ListsSubscribersDestroyParams) (*http.Response, error) { + apiError := new(APIError) + resp, err := s.sling.New().Post("subscribers/destroy.json").BodyForm(params).Receive(nil, apiError) + return resp, err +} + +// ListsUpdateParams are the parameters for ListsService.Update +type ListsUpdateParams struct { + ListID int64 `url:"list_id,omitempty"` + Slug string `url:"slug,omitempty"` + Name string `url:"name,omitempty"` + Mode string `url:"mode,omitempty"` + Description string `url:"description,omitempty"` + OwnerScreenName string `url:"owner_screen_name,omitempty"` + OwnerID int64 `url:"owner_id,omitempty"` +} + +// Update updates the specified list. +// https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-update +func (s *ListsService) Update(params *ListsUpdateParams) (*http.Response, error) { + apiError := new(APIError) + resp, err := s.sling.New().Post("update.json").BodyForm(params).Receive(nil, apiError) + return resp, err +} diff --git a/twitter/lists_test.go b/twitter/lists_test.go new file mode 100644 index 0000000..5f4aacb --- /dev/null +++ b/twitter/lists_test.go @@ -0,0 +1,411 @@ +package twitter + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestListsService_List(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/list.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"screen_name": "twitterapi"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[{"slug": "meetup-20100301", "uri": "/twitterapi/meetup-20100301"}]`) + }) + + client := NewClient(httpClient) + params := &ListsListParams{ScreenName: "twitterapi"} + lists, _, err := client.Lists.List(params) + expected := []List{List{Slug: "meetup-20100301", URI: "/twitterapi/meetup-20100301"}} + assert.Nil(t, err) + assert.Equal(t, expected, lists) +} + +func TestListsService_Members(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/members.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"slug": "team", "owner_screen_name": "twitterapi", "cursor": "-1"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"users":[{"id": 14895163}],"next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) + }) + + client := NewClient(httpClient) + params := &ListsMembersParams{Slug: "team", OwnerScreenName: "twitterapi", Cursor: -1} + members, _, err := client.Lists.Members(params) + expected := &Members{ + Users: []User{User{ID: 14895163}}, + NextCursor: 1516837838944119498, + NextCursorStr: "1516837838944119498", + PreviousCursor: -1516924983503961435, + PreviousCursorStr: "-1516924983503961435", + } + assert.Nil(t, err) + assert.Equal(t, expected, members) +} + +func TestListsService_MembersShow(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/members/show.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"slug": "team", "owner_screen_name": "twitterapi", "screen_name": "froginthevalley"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id": 657693, "screen_name": "froginthevalley"}`) + }) + + client := NewClient(httpClient) + params := &ListsMembersShowParams{Slug: "team", OwnerScreenName: "twitterapi", ScreenName: "froginthevalley"} + user, _, err := client.Lists.MembersShow(params) + expected := &User{ + ID: 657693, + ScreenName: "froginthevalley", + } + assert.Nil(t, err) + assert.Equal(t, expected, user) +} + +func TestListsService_Memberships(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/memberships.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"screen_name": "twitter", "cursor": "-1"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"lists": [{"slug": "digital-marketing", "name": "Digital Marketing"}], "next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) + }) + + client := NewClient(httpClient) + params := &ListsMembershipsParams{ScreenName: "twitter", Cursor: -1} + memberships, _, err := client.Lists.Memberships(params) + expected := &Membership{ + Lists: []List{List{Slug: "digital-marketing", Name: "Digital Marketing"}}, + NextCursor: 1516837838944119498, + NextCursorStr: "1516837838944119498", + PreviousCursor: -1516924983503961435, + PreviousCursorStr: "-1516924983503961435", + } + assert.Nil(t, err) + assert.Equal(t, expected, memberships) +} + +func TestListsService_Ownerships(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/ownerships.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"screen_name": "twitter", "count": "2"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"lists": [{"mode": "public", "name": "Official Twitter accts"}], "next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) + }) + + client := NewClient(httpClient) + params := &ListsOwnershipsParams{ScreenName: "twitter", Count: 2} + ownerships, _, err := client.Lists.Ownerships(params) + expected := &Ownership{ + Lists: []List{List{Mode: "public", Name: "Official Twitter accts"}}, + NextCursor: 1516837838944119498, + NextCursorStr: "1516837838944119498", + PreviousCursor: -1516924983503961435, + PreviousCursorStr: "-1516924983503961435", + } + assert.Nil(t, err) + assert.Equal(t, expected, ownerships) +} + +func TestListsService_Show(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/show.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"slug": "team", "owner_screen_name": "twitter"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"full_name": "@twitter/team", "following": false, "member_count": 643}`) + }) + + client := NewClient(httpClient) + params := &ListsShowParams{Slug: "team", OwnerScreenName: "twitter"} + list, _, err := client.Lists.Show(params) + expected := &List{ + FullName: "@twitter/team", + Following: false, + MemberCount: 643, + } + assert.Nil(t, err) + assert.Equal(t, expected, list) +} + +func TestListsService_Statuses(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/statuses.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"slug": "teams", "owner_screen_name": "MLS", "count": "1"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[{"user": {"screen_name": "torontofc"}, "id": 245160944223793152, "text": "Create your own TFC ESQ by Movado Watch: http://t.co/W2tON3OK in support of @TeamUpFdn #TorontoFC #MLS"}]`) + }) + + client := NewClient(httpClient) + params := &ListsStatusesParams{Slug: "teams", OwnerScreenName: "MLS", Count: 1} + tweet, _, err := client.Lists.Statuses(params) + expected := []Tweet{ + Tweet{ + ID: 245160944223793152, + Text: "Create your own TFC ESQ by Movado Watch: http://t.co/W2tON3OK in support of @TeamUpFdn #TorontoFC #MLS", + User: &User{ + ScreenName: "torontofc", + }, + }, + } + assert.Nil(t, err) + assert.Equal(t, expected, tweet) +} + +func TestListsService_Subscribers(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/subscribers.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"slug": "team", "owner_screen_name": "twitter", "skip_status": "true"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"users": [{"name": "Almissen665"}], "next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) + }) + + client := NewClient(httpClient) + params := &ListsSubscribersParams{Slug: "team", OwnerScreenName: "twitter", SkipStatus: Bool(true)} + subscribers, _, err := client.Lists.Subscribers(params) + expected := &Subscribers{ + Users: []User{User{Name: "Almissen665"}}, + NextCursor: 1516837838944119498, + NextCursorStr: "1516837838944119498", + PreviousCursor: -1516924983503961435, + PreviousCursorStr: "-1516924983503961435", + } + assert.Nil(t, err) + assert.Equal(t, expected, subscribers) +} + +func TestListsService_SubscribersShow(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/subscribers/show.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"slug": "team", "owner_screen_name": "twitter", "screen_name": "episod"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"name": "Taylor Singletary", "screen_name": "episod"}`) + }) + + client := NewClient(httpClient) + params := &ListsSubscribersShowParams{Slug: "team", OwnerScreenName: "twitter", ScreenName: "episod"} + user, _, err := client.Lists.SubscribersShow(params) + expected := &User{ + Name: "Taylor Singletary", + ScreenName: "episod", + } + assert.Nil(t, err) + assert.Equal(t, expected, user) +} + +func TestListsService_Subscriptions(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/subscriptions.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"cursor": "-1", "screen_name": "episod", "count": "5"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"lists": [{"slug": "team", "name": "team", "uri": "/TwitterEng/team"}], "next_cursor":1516837838944119498,"next_cursor_str":"1516837838944119498","previous_cursor":-1516924983503961435,"previous_cursor_str":"-1516924983503961435"}`) + }) + + client := NewClient(httpClient) + params := &ListsSubscriptionsParams{Cursor: -1, ScreenName: "episod", Count: 5} + subscriptions, _, err := client.Lists.Subscriptions(params) + expected := &Subscribed{ + Lists: []List{List{Slug: "team", Name: "team", URI: "/TwitterEng/team"}}, + NextCursor: 1516837838944119498, + NextCursorStr: "1516837838944119498", + PreviousCursor: -1516924983503961435, + PreviousCursorStr: "-1516924983503961435", + } + assert.Nil(t, err) + assert.Equal(t, expected, subscriptions) +} + +func TestListsService_Create(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/create.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostForm(t, map[string]string{"name": "Goonies", "mode": "public", "description": "For life"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"slug": "goonies", "name": "Goonies", "description": "For life"}`) + }) + + client := NewClient(httpClient) + params := &ListsCreateParams{Mode: "public", Description: "For life"} + list, _, err := client.Lists.Create("Goonies", params) + expected := &List{ + Slug: "goonies", + Name: "Goonies", + Description: "For life", + } + assert.Nil(t, err) + assert.Equal(t, expected, list) +} + +func TestListsService_Destroy(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/destroy.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostForm(t, map[string]string{"owner_screen_name": "kurrik", "slug": "goonies"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"slug": "goonies", "name": "Goonies", "full_name": "@kurrik/goonies"}`) + }) + + client := NewClient(httpClient) + params := &ListsDestroyParams{OwnerScreenName: "kurrik", Slug: "goonies"} + list, _, err := client.Lists.Destroy(params) + expected := &List{ + Slug: "goonies", + Name: "Goonies", + FullName: "@kurrik/goonies", + } + assert.Nil(t, err) + assert.Equal(t, expected, list) +} + +func TestListsService_MembersCreate(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/members/create.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostForm(t, map[string]string{"slug": "team", "owner_screen_name": "twitter", "screen_name": "kurrik"}, r) + w.Header().Set("Content-Type", "application/json") + }) + + client := NewClient(httpClient) + params := &ListsMembersCreateParams{Slug: "team", OwnerScreenName: "twitter", ScreenName: "kurrik"} + _, err := client.Lists.MembersCreate(params) + assert.Nil(t, err) +} + +func TestListsService_MembersCreateAll(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/members/create_all.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostForm(t, map[string]string{"list_id": "23", "screen_name": "rsarver,episod,jasoncosta"}, r) + w.Header().Set("Content-Type", "application/json") + }) + + client := NewClient(httpClient) + params := &ListsMembersCreateAllParams{ListID: 23, ScreenName: "rsarver,episod,jasoncosta"} + _, err := client.Lists.MembersCreateAll(params) + assert.Nil(t, err) +} + +func TestListsService_MembersDestroy(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/members/destroy.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostForm(t, map[string]string{"screen_name": "episod", "slug": "cool_people", "owner_screen_name": "twitter"}, r) + w.Header().Set("Content-Type", "application/json") + }) + + client := NewClient(httpClient) + params := &ListsMembersDestroyParams{ScreenName: "episod", Slug: "cool_people", OwnerScreenName: "twitter"} + _, err := client.Lists.MembersDestroy(params) + assert.Nil(t, err) +} + +func TestListsService_DestroyAll(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/members/destroy_all.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostForm(t, map[string]string{"screen_name": "rsarver,episod,jasoncosta,theseancook,kurrik,froginthevalley", "list_id": "23"}, r) + w.Header().Set("Content-Type", "application/json") + }) + + client := NewClient(httpClient) + params := &ListsMembersDestroyAllParams{ScreenName: "rsarver,episod,jasoncosta,theseancook,kurrik,froginthevalley", ListID: 23} + _, err := client.Lists.MembersDestroyAll(params) + assert.Nil(t, err) +} + +func TestListsService_SubscribersCreate(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/subscribers/create.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostForm(t, map[string]string{"slug": "team", "owner_screen_name": "twitter"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"following": false, "id_str": "574"}`) + }) + + client := NewClient(httpClient) + params := &ListsSubscribersCreateParams{Slug: "team", OwnerScreenName: "twitter"} + list, _, err := client.Lists.SubscribersCreate(params) + expected := &List{ + Following: false, + IDStr: "574", + } + assert.Nil(t, err) + assert.Equal(t, expected, list) +} + +func TestListsService_SubscribersDestroy(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/subscribers/destroy.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostForm(t, map[string]string{"slug": "team", "owner_screen_name": "twitterapi"}, r) + w.Header().Set("Content-Type", "application/json") + }) + + client := NewClient(httpClient) + params := &ListsSubscribersDestroyParams{Slug: "team", OwnerScreenName: "twitterapi"} + _, err := client.Lists.SubscribersDestroy(params) + assert.Nil(t, err) +} + +func TestListsService_Update(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/lists/update.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostForm(t, map[string]string{"list_id": "1234", "mode": "public", "name": "Party Time"}, r) + w.Header().Set("Content-Type", "application/json") + }) + + client := NewClient(httpClient) + params := &ListsUpdateParams{ListID: 1234, Mode: "public", Name: "Party Time"} + _, err := client.Lists.Update(params) + assert.Nil(t, err) +} diff --git a/twitter/premium_search.go b/twitter/premium_search.go new file mode 100644 index 0000000..32bc333 --- /dev/null +++ b/twitter/premium_search.go @@ -0,0 +1,116 @@ +package twitter + +import ( + "fmt" + "net/http" + + "github.com/dghubble/sling" +) + +// PremiumSearch represents the result of a Tweet search. +// https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search +type PremiumSearch struct { + Results []Tweet `json:"results"` + Next string `json:"next"` + RequestParameters *RequestParameters `json:"requestParameters"` +} + +// RequestParameters describes a request parameter that was passed to a Premium search API. +type RequestParameters struct { + MaxResults int `json:"maxResults"` + FromDate string `json:"fromDate"` + ToDate string `json:"toDate"` +} + +// PremiumSearchCount describes a response of Premium search API's count endpoint. +// https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search#CountsEndpoint +type PremiumSearchCount struct { + Results []TweetCount `json:"results"` + TotalCount int64 `json:"totalCount"` + RequestParameters *RequestCountParameters `json:"requestParameters"` +} + +// RequestCountParameters describes a request parameter that was passed to a Premium search API. +type RequestCountParameters struct { + Bucket string `json:"bucket"` + FromDate string `json:"fromDate"` + ToDate string `json:"toDate"` +} + +// TweetCount represents a count of Tweets in the TimePeriod matching a search query. +type TweetCount struct { + TimePeriod string `json:"timePeriod"` + Count int64 `json:"count"` +} + +// PremiumSearchService provides methods for accessing Twitter premium search API endpoints. +type PremiumSearchService struct { + sling *sling.Sling +} + +// newSearchService returns a new SearchService. +func newPremiumSearchService(sling *sling.Sling) *PremiumSearchService { + return &PremiumSearchService{ + sling: sling.Path("tweets/search/"), + } +} + +// PremiumSearchTweetParams are the parameters for PremiumSearchService.SearchFullArchive and Search30Days +type PremiumSearchTweetParams struct { + Query string `url:"query,omitempty"` + Tag string `url:"tag,omitempty"` + FromDate string `url:"fromDate,omitempty"` + ToDate string `url:"toDate,omitempty"` + MaxResults int `url:"maxResults,omitempty"` + Next string `url:"next,omitempty"` +} + +// PremiumSearchCountTweetParams are the parameters for PremiumSearchService.CountFullArchive and Count30Days +type PremiumSearchCountTweetParams struct { + Query string `url:"query,omitempty"` + Tag string `url:"tag,omitempty"` + FromDate string `url:"fromDate,omitempty"` + ToDate string `url:"toDate,omitempty"` + Bucket string `url:"bucket,omitempty"` + Next string `url:"next,omitempty"` +} + +// SearchFullArchive returns a collection of Tweets matching a search query from tweets back to the very first tweet. +// https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search +func (s *PremiumSearchService) SearchFullArchive(params *PremiumSearchTweetParams, label string) (*PremiumSearch, *http.Response, error) { + search := new(PremiumSearch) + apiError := new(APIError) + path := fmt.Sprintf("fullarchive/%s.json", label) + resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(search, apiError) + return search, resp, relevantError(err, *apiError) +} + +// Search30Days returns a collection of Tweets matching a search query from Tweets posted within the last 30 days. +// https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search +func (s *PremiumSearchService) Search30Days(params *PremiumSearchTweetParams, label string) (*PremiumSearch, *http.Response, error) { + search := new(PremiumSearch) + apiError := new(APIError) + path := fmt.Sprintf("30day/%s.json", label) + resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(search, apiError) + return search, resp, relevantError(err, *apiError) +} + +// CountFullArchive returns a counts of Tweets matching a search query from tweets back to the very first tweet. +// https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search#CountsEndpoint +func (s *PremiumSearchService) CountFullArchive(params *PremiumSearchCountTweetParams, label string) (*PremiumSearchCount, *http.Response, error) { + counts := new(PremiumSearchCount) + apiError := new(APIError) + path := fmt.Sprintf("fullarchive/%s/counts.json", label) + resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(counts, apiError) + return counts, resp, relevantError(err, *apiError) +} + +// Count30Days returns a counts of Tweets matching a search query from Tweets posted within the last 30 days. +// https://developer.twitter.com/en/docs/tweets/search/api-reference/premium-search#CountsEndpoint +func (s *PremiumSearchService) Count30Days(params *PremiumSearchCountTweetParams, label string) (*PremiumSearchCount, *http.Response, error) { + counts := new(PremiumSearchCount) + apiError := new(APIError) + path := fmt.Sprintf("30day/%s/counts.json", label) + resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(counts, apiError) + return counts, resp, relevantError(err, *apiError) +} diff --git a/twitter/premium_search_test.go b/twitter/premium_search_test.go new file mode 100644 index 0000000..6043fb2 --- /dev/null +++ b/twitter/premium_search_test.go @@ -0,0 +1,140 @@ +package twitter + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPremiumSearchService_Tweets(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + assertSearchBody := func(t *testing.T, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"query": "url:\"http://example.com\"", "tag": "8HYG54ZGTU", "fromDate": "201512220000", "toDate": "201712220000", "maxResults": "500", "next": "NTcxODIyMDMyODMwMjU1MTA0"}, r) + } + setResponse := func(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"results":[{"id":781760642139250689}],"next":"NTcxODIyMDMyODMwMjU1MTA0","requestParameters":{"maxResults":500,"fromDate":"201512200000","toDate":"201712200000"}}`) + } + + mux.HandleFunc("/1.1/tweets/search/fullarchive/test.json", func(w http.ResponseWriter, r *http.Request) { + assertSearchBody(t, r) + setResponse(w) + }) + mux.HandleFunc("/1.1/tweets/search/30day/test.json", func(w http.ResponseWriter, r *http.Request) { + assertSearchBody(t, r) + setResponse(w) + }) + + params := &PremiumSearchTweetParams{ + Query: "url:\"http://example.com\"", + Tag: "8HYG54ZGTU", + FromDate: "201512220000", + ToDate: "201712220000", + MaxResults: 500, + Next: "NTcxODIyMDMyODMwMjU1MTA0", + } + expected := &PremiumSearch{ + Results: []Tweet{ + Tweet{ID: 781760642139250689}, + }, + Next: "NTcxODIyMDMyODMwMjU1MTA0", + RequestParameters: &RequestParameters{ + MaxResults: 500, + FromDate: "201512200000", + ToDate: "201712200000", + }, + } + + { + client := NewClient(httpClient) + search, _, err := client.PremiumSearch.SearchFullArchive( + params, + "test", + ) + assert.Nil(t, err) + assert.Equal(t, expected, search) + } + { + client := NewClient(httpClient) + search, _, err := client.PremiumSearch.Search30Days( + params, + "test", + ) + assert.Nil(t, err) + assert.Equal(t, expected, search) + } +} + +func TestPremiumSearchService_Counts(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + assertCountBody := func(t *testing.T, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"query": "url:\"http://example.com\"", "tag": "8HYG54ZGTU", "fromDate": "201512220000", "toDate": "201712220000", "bucket": "day", "next": "NTcxODIyMDMyODMwMjU1MTA0"}, r) + } + setResponse := func(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"results":[{"timePeriod":"201701010000","count":32},{"timePeriod":"201701020000","count":45}],"totalCount":2027,"requestParameters":{"bucket":"day","fromDate":"201512200000","toDate":"201712200000"}}`) + } + + mux.HandleFunc("/1.1/tweets/search/fullarchive/test/counts.json", func(w http.ResponseWriter, r *http.Request) { + assertCountBody(t, r) + setResponse(w) + }) + mux.HandleFunc("/1.1/tweets/search/30day/test/counts.json", func(w http.ResponseWriter, r *http.Request) { + assertCountBody(t, r) + setResponse(w) + }) + + params := &PremiumSearchCountTweetParams{ + Query: "url:\"http://example.com\"", + Tag: "8HYG54ZGTU", + FromDate: "201512220000", + ToDate: "201712220000", + Bucket: "day", + Next: "NTcxODIyMDMyODMwMjU1MTA0", + } + expected := &PremiumSearchCount{ + Results: []TweetCount{ + TweetCount{ + TimePeriod: "201701010000", + Count: 32, + }, + TweetCount{ + TimePeriod: "201701020000", + Count: 45, + }, + }, + TotalCount: 2027, + RequestParameters: &RequestCountParameters{ + Bucket: "day", + FromDate: "201512200000", + ToDate: "201712200000", + }, + } + + { + client := NewClient(httpClient) + search, _, err := client.PremiumSearch.CountFullArchive( + params, + "test", + ) + assert.Nil(t, err) + assert.Equal(t, expected, search) + } + { + client := NewClient(httpClient) + search, _, err := client.PremiumSearch.Count30Days( + params, + "test", + ) + assert.Nil(t, err) + assert.Equal(t, expected, search) + } +} diff --git a/twitter/rate_limits.go b/twitter/rate_limits.go new file mode 100644 index 0000000..61ca4f9 --- /dev/null +++ b/twitter/rate_limits.go @@ -0,0 +1,68 @@ +package twitter + +import ( + "net/http" + + "github.com/dghubble/sling" +) + +// RateLimitService provides methods for accessing Twitter rate limits +// API endpoints. +type RateLimitService struct { + sling *sling.Sling +} + +// newRateLimitService returns a new RateLimitService. +func newRateLimitService(sling *sling.Sling) *RateLimitService { + return &RateLimitService{ + sling: sling.Path("application/"), + } +} + +// RateLimit summarizes current rate limits of resource families. +type RateLimit struct { + RateLimitContext *RateLimitContext `json:"rate_limit_context"` + Resources *RateLimitResources `json:"resources"` +} + +// RateLimitContext contains auth context +type RateLimitContext struct { + AccessToken string `json:"access_token"` +} + +// RateLimitResources contains all limit status data for endpoints group by resources +type RateLimitResources struct { + Application map[string]*RateLimitResource `json:"application"` + Favorites map[string]*RateLimitResource `json:"favorites"` + Followers map[string]*RateLimitResource `json:"followers"` + Friends map[string]*RateLimitResource `json:"friends"` + Friendships map[string]*RateLimitResource `json:"friendships"` + Geo map[string]*RateLimitResource `json:"geo"` + Help map[string]*RateLimitResource `json:"help"` + Lists map[string]*RateLimitResource `json:"lists"` + Search map[string]*RateLimitResource `json:"search"` + Statuses map[string]*RateLimitResource `json:"statuses"` + Trends map[string]*RateLimitResource `json:"trends"` + Users map[string]*RateLimitResource `json:"users"` +} + +// RateLimitResource contains limit status data for a single endpoint +type RateLimitResource struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + Reset int `json:"reset"` +} + +// RateLimitParams are the parameters for RateLimitService.Status. +type RateLimitParams struct { + Resources []string `url:"resources,omitempty,comma"` +} + +// Status summarizes the current rate limits of specified resource families. +// https://developer.twitter.com/en/docs/developer-utilities/rate-limit-status/api-reference/get-application-rate_limit_status +func (s *RateLimitService) Status(params *RateLimitParams) (*RateLimit, *http.Response, error) { + rateLimit := new(RateLimit) + apiError := new(APIError) + resp, err := s.sling.New().Get("rate_limit_status.json").QueryStruct(params).Receive(rateLimit, apiError) + return rateLimit, resp, relevantError(err, *apiError) +} diff --git a/twitter/rate_limits_test.go b/twitter/rate_limits_test.go new file mode 100644 index 0000000..856a993 --- /dev/null +++ b/twitter/rate_limits_test.go @@ -0,0 +1,39 @@ +package twitter + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRateLimitService_Status(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/1.1/application/rate_limit_status.json", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + assertQuery(t, map[string]string{"resources": "statuses,users"}, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"rate_limit_context":{"access_token":"a_fake_access_token"},"resources":{"statuses":{"/statuses/mentions_timeline":{"limit":75,"remaining":75,"reset":1403602426},"/statuses/lookup":{"limit":900,"remaining":900,"reset":1403602426}}}}`) + }) + + client := NewClient(httpClient) + rateLimits, _, err := client.RateLimits.Status(&RateLimitParams{Resources: []string{"statuses", "users"}}) + expected := &RateLimit{ + RateLimitContext: &RateLimitContext{AccessToken: "a_fake_access_token"}, + Resources: &RateLimitResources{ + Statuses: map[string]*RateLimitResource{ + "/statuses/mentions_timeline": &RateLimitResource{ + Limit: 75, + Remaining: 75, + Reset: 1403602426}, + "/statuses/lookup": &RateLimitResource{ + Limit: 900, + Remaining: 900, + Reset: 1403602426}}}} + + assert.Nil(t, err) + assert.Equal(t, expected, rateLimits) +} diff --git a/twitter/search.go b/twitter/search.go index 5bb5071..e177388 100644 --- a/twitter/search.go +++ b/twitter/search.go @@ -48,6 +48,8 @@ type SearchTweetParams struct { SinceID int64 `url:"since_id,omitempty"` MaxID int64 `url:"max_id,omitempty"` Until string `url:"until,omitempty"` + Since string `url:"since,omitempty"` + Filter string `url:"filter,omitempty"` IncludeEntities *bool `url:"include_entities,omitempty"` TweetMode string `url:"tweet_mode,omitempty"` } diff --git a/twitter/search_test.go b/twitter/search_test.go index 4c63574..5227fc5 100644 --- a/twitter/search_test.go +++ b/twitter/search_test.go @@ -14,9 +14,9 @@ func TestSearchService_Tweets(t *testing.T) { mux.HandleFunc("/1.1/search/tweets.json", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) - assertQuery(t, map[string]string{"q": "happy birthday", "result_type": "popular", "count": "1"}, r) + assertQuery(t, map[string]string{"q": "happy birthday", "result_type": "popular", "count": "1", "since": "2012-01-01", "filter": "safe"}, r) w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"statuses":[{"id":781760642139250689}],"search_metadata":{"completed_in":0.043,"max_id":781760642139250689,"max_id_str":"781760642139250689","next_results":"?max_id=781760640104828927&q=happy+birthday&count=1&include_entities=1","query":"happy birthday","refresh_url":"?since_id=781760642139250689&q=happy+birthday&include_entities=1","count":1,"since_id":0,"since_id_str":"0"}}`) + fmt.Fprintf(w, `{"statuses":[{"id":781760642139250689}],"search_metadata":{"completed_in":0.043,"max_id":781760642139250689,"max_id_str":"781760642139250689","next_results":"?max_id=781760640104828927&q=happy+birthday&count=1&include_entities=1","query":"happy birthday","refresh_url":"?since_id=781760642139250689&q=happy+birthday&include_entities=1","count":1,"since_id":0,"since_id_str":"0", "since":"2012-01-01", "filter":"safe"}}`) }) client := NewClient(httpClient) @@ -24,7 +24,8 @@ func TestSearchService_Tweets(t *testing.T) { Query: "happy birthday", Count: 1, ResultType: "popular", - }) + Since: "2012-01-01", + Filter: "safe"}) expected := &Search{ Statuses: []Tweet{ Tweet{ID: 781760642139250689}, diff --git a/twitter/statuses.go b/twitter/statuses.go index 6a9046b..2fb257b 100644 --- a/twitter/statuses.go +++ b/twitter/statuses.go @@ -27,6 +27,8 @@ type Tweet struct { InReplyToUserIDStr string `json:"in_reply_to_user_id_str"` Lang string `json:"lang"` PossiblySensitive bool `json:"possibly_sensitive"` + QuoteCount int `json:"quote_count"` + ReplyCount int `json:"reply_count"` RetweetCount int `json:"retweet_count"` Retweeted bool `json:"retweeted"` RetweetedStatus *Tweet `json:"retweeted_status"` @@ -48,7 +50,7 @@ type Tweet struct { QuotedStatus *Tweet `json:"quoted_status"` } -// CreatedAtTime is a convenience wrapper that returns the Created_at time, parsed as a time.Time struct +// CreatedAtTime returns the time a tweet was created. func (t Tweet) CreatedAtTime() (time.Time, error) { return time.Parse(time.RubyDate, t.CreatedAt) } diff --git a/twitter/streams.go b/twitter/streams.go index 3ff01ee..6a96ad6 100644 --- a/twitter/streams.go +++ b/twitter/streams.go @@ -2,7 +2,9 @@ package twitter import ( "encoding/json" + "fmt" "io" + "io/ioutil" "net/http" "sync" "time" @@ -20,20 +22,26 @@ const ( // StreamService provides methods for accessing the Twitter Streaming API. type StreamService struct { - client *http.Client - public *sling.Sling - user *sling.Sling - site *sling.Sling + client *http.Client + public *sling.Sling + user *sling.Sling + site *sling.Sling + exponentialBackoff backoff.BackOff + aggressiveExponentialBackoff backoff.BackOff } // newStreamService returns a new StreamService. func newStreamService(client *http.Client, sling *sling.Sling) *StreamService { sling.Set("User-Agent", userAgent) + expBackoff := newExponentialBackOff() + aggExpBackoff := newAggressiveExponentialBackOff() return &StreamService{ - client: client, - public: sling.New().Base(publicStream).Path("statuses/"), - user: sling.New().Base(userStream), - site: sling.New().Base(siteStream), + client: client, + public: sling.New().Base(publicStream).Path("statuses/"), + user: sling.New().Base(userStream), + site: sling.New().Base(siteStream), + exponentialBackoff: newBackoffWithMaxRetries(expBackoff, 0), + aggressiveExponentialBackoff: newBackoffWithMaxRetries(aggExpBackoff, 0), } } @@ -54,7 +62,7 @@ func (srv *StreamService) Filter(params *StreamFilterParams) (*Stream, error) { if err != nil { return nil, err } - return newStream(srv.client, req), nil + return newStream(srv.client, req, srv.exponentialBackoff, srv.aggressiveExponentialBackoff), nil } // StreamSampleParams are the parameters for StreamService.Sample. @@ -70,7 +78,7 @@ func (srv *StreamService) Sample(params *StreamSampleParams) (*Stream, error) { if err != nil { return nil, err } - return newStream(srv.client, req), nil + return newStream(srv.client, req, srv.exponentialBackoff, srv.aggressiveExponentialBackoff), nil } // StreamUserParams are the parameters for StreamService.User. @@ -91,7 +99,7 @@ func (srv *StreamService) User(params *StreamUserParams) (*Stream, error) { if err != nil { return nil, err } - return newStream(srv.client, req), nil + return newStream(srv.client, req, srv.exponentialBackoff, srv.aggressiveExponentialBackoff), nil } // StreamSiteParams are the parameters for StreamService.Site. @@ -112,7 +120,7 @@ func (srv *StreamService) Site(params *StreamSiteParams) (*Stream, error) { if err != nil { return nil, err } - return newStream(srv.client, req), nil + return newStream(srv.client, req, srv.exponentialBackoff, srv.aggressiveExponentialBackoff), nil } // StreamFirehoseParams are the parameters for StreamService.Firehose. @@ -131,7 +139,7 @@ func (srv *StreamService) Firehose(params *StreamFirehoseParams) (*Stream, error if err != nil { return nil, err } - return newStream(srv.client, req), nil + return newStream(srv.client, req, srv.exponentialBackoff, srv.aggressiveExponentialBackoff), nil } // Stream maintains a connection to the Twitter Streaming API, receives @@ -147,12 +155,14 @@ type Stream struct { done chan struct{} group *sync.WaitGroup body io.Closer + expected bool + mutex sync.Mutex } // newStream creates a Stream and starts a goroutine to retry connecting and // receive from a stream response. The goroutine may stop due to retry errors // or be stopped by calling Stop() on the stream. -func newStream(client *http.Client, req *http.Request) *Stream { +func newStream(client *http.Client, req *http.Request, expBackoff, aggExpBackoff backoff.BackOff) *Stream { s := &Stream{ client: client, Messages: make(chan interface{}), @@ -160,13 +170,15 @@ func newStream(client *http.Client, req *http.Request) *Stream { group: &sync.WaitGroup{}, } s.group.Add(1) - go s.retry(req, newExponentialBackOff(), newAggressiveExponentialBackOff()) + go s.retry(req, expBackoff, aggExpBackoff) return s } // Stop signals retry and receiver to stop, closes the Messages channel, and // blocks until done. func (s *Stream) Stop() { + s.mutex.Lock() + s.expected = true close(s.done) // Scanner does not have a Stop() or take a done channel, so for low volume // streams Scan() blocks until the next keep-alive. Close the resp.Body to @@ -174,10 +186,20 @@ func (s *Stream) Stop() { if s.body != nil { s.body.Close() } + s.mutex.Unlock() // block until the retry goroutine stops s.group.Wait() } +// ExpectedStop indicates whether Stream halting was due to an expected Stop() +// or some error condition. +func (s *Stream) ExpectedStop() bool { + s.mutex.Lock() + result := s.expected + s.mutex.Unlock() + return result +} + // retry retries making the given http.Request and receiving the response // according to the Twitter backoff policies. Callers should invoke in a // goroutine since backoffs sleep between retries. @@ -197,21 +219,26 @@ func (s *Stream) retry(req *http.Request, expBackOff backoff.BackOff, aggExpBack } // when err is nil, resp contains a non-nil Body which must be closed defer resp.Body.Close() + s.mutex.Lock() s.body = resp.Body + s.mutex.Unlock() switch resp.StatusCode { case 200: // receive stream response Body, handles closing - s.receive(resp.Body) + s.receiveStream(resp.Body) expBackOff.Reset() aggExpBackOff.Reset() case 503: // exponential backoff + s.receiveError(resp) wait = expBackOff.NextBackOff() case 420, 429: // aggressive exponential backoff + s.receiveError(resp) wait = aggExpBackOff.NextBackOff() default: // stop retrying for other response codes + s.receiveError(resp) resp.Body.Close() return } @@ -224,10 +251,10 @@ func (s *Stream) retry(req *http.Request, expBackOff backoff.BackOff, aggExpBack } } -// receive scans a stream response body, JSON decodes tokens to messages, and +// receiveStream scans a stream response body, JSON decodes tokens to messages, and // sends messages to the Messages channel. Receiving continues until an EOF, // scan error, or the done channel is closed. -func (s *Stream) receive(body io.Reader) { +func (s *Stream) receiveStream(body io.Reader) { reader := newStreamResponseBodyReader(body) for !stopped(s.done) { data, err := reader.readNext() @@ -249,6 +276,18 @@ func (s *Stream) receive(body io.Reader) { } } +// receiveError JSON decodes Twitter API errors when they are present and sends +// either an APIError or a an error message string to the Messages channel. +func (s *Stream) receiveError(resp *http.Response) { + body, _ := ioutil.ReadAll(resp.Body) + + if resp.ContentLength > 0 && body[0] == '{' { + s.Messages <- getMessage(body) + } else { + s.Messages <- fmt.Sprintf("Error connecting to Twitter: %d - %s", resp.StatusCode, body) + } +} + // getMessage unmarshals the token and returns a message struct, if the type // can be determined. Otherwise, returns the token unmarshalled into a data // map[string]interface{} or the unmarshal error. @@ -311,6 +350,10 @@ func decodeMessage(token []byte, data map[string]interface{}) interface{} { event := new(Event) json.Unmarshal(token, event) return event + } else if hasPath(data, "errors") { + apiError := new(APIError) + json.Unmarshal(token, apiError) + return apiError } // message type unknown, return the data map[string]interface{} return data diff --git a/twitter/streams_test.go b/twitter/streams_test.go index 633b3ee..47025f8 100644 --- a/twitter/streams_test.go +++ b/twitter/streams_test.go @@ -82,6 +82,12 @@ func TestStream_Event(t *testing.T) { assert.IsType(t, &Event{}, msg) } +func TestStream_APIError(t *testing.T) { + msgJSON := []byte(`{"errors":[{"code":215,"message":"Bad Authentication data."}]}`) + msg := getMessage(msgJSON) + assert.IsType(t, &APIError{}, msg) +} + func TestStream_Unknown(t *testing.T) { msgJSON := []byte(`{"unknown_data": {"new_twitter_type":"unexpected"}}`) msg := getMessage(msgJSON) @@ -105,8 +111,8 @@ func TestStream_Filter(t *testing.T) { `{"text": "Gophercon super talks!"}`+"\r\n", ) default: - // Only allow first request - http.Error(w, "Stream API not available!", 130) + // Simulate stream disconnect + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) } reqCount++ }) @@ -117,15 +123,21 @@ func TestStream_Filter(t *testing.T) { streamFilterParams := &StreamFilterParams{ Track: []string{"gophercon", "golang"}, } + client.Streams.exponentialBackoff = &BackOffRecorder{MaxRetries: 1} + client.Streams.aggressiveExponentialBackoff = &BackOffRecorder{MaxRetries: 1} stream, err := client.Streams.Filter(streamFilterParams) // assert that the expected messages are received assert.NoError(t, err) - defer stream.Stop() for message := range stream.Messages { demux.Handle(message) } - expectedCounts := &counter{all: 2, other: 2} + expectedCounts := &counter{all: 3, other: 3} assert.Equal(t, expectedCounts, counts) + + // test ExpectedStop + assert.False(t, stream.ExpectedStop()) + stream.Stop() + assert.True(t, stream.ExpectedStop()) } func TestStream_Sample(t *testing.T) { @@ -145,8 +157,8 @@ func TestStream_Sample(t *testing.T) { `{"text": "Gophercon super talks!"}`+"\r\n", ) default: - // Only allow first request - http.Error(w, "Stream API not available!", 130) + // Simulate stream disconnect + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) } reqCount++ }) @@ -157,6 +169,8 @@ func TestStream_Sample(t *testing.T) { streamSampleParams := &StreamSampleParams{ StallWarnings: Bool(true), } + client.Streams.exponentialBackoff = &BackOffRecorder{MaxRetries: 1} + client.Streams.aggressiveExponentialBackoff = &BackOffRecorder{MaxRetries: 1} stream, err := client.Streams.Sample(streamSampleParams) // assert that the expected messages are received assert.NoError(t, err) @@ -164,7 +178,7 @@ func TestStream_Sample(t *testing.T) { for message := range stream.Messages { demux.Handle(message) } - expectedCounts := &counter{all: 2, other: 2} + expectedCounts := &counter{all: 3, other: 3} assert.Equal(t, expectedCounts, counts) } @@ -182,8 +196,8 @@ func TestStream_User(t *testing.T) { w.Header().Set("Transfer-Encoding", "chunked") fmt.Fprintf(w, `{"friends": [666024290140217347, 666024290140217349, 666024290140217342]}`+"\r\n"+"\r\n") default: - // Only allow first request - http.Error(w, "Stream API not available!", 130) + // Simulate stream disconnect + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) } reqCount++ }) @@ -195,6 +209,8 @@ func TestStream_User(t *testing.T) { StallWarnings: Bool(true), With: "followings", } + client.Streams.exponentialBackoff = &BackOffRecorder{MaxRetries: 1} + client.Streams.aggressiveExponentialBackoff = &BackOffRecorder{MaxRetries: 1} stream, err := client.Streams.User(streamUserParams) // assert that the expected messages are received assert.NoError(t, err) @@ -202,7 +218,7 @@ func TestStream_User(t *testing.T) { for message := range stream.Messages { demux.Handle(message) } - expectedCounts := &counter{all: 1, friendsList: 1} + expectedCounts := &counter{all: 2, friendsList: 1, other: 1} assert.Equal(t, expectedCounts, counts) } @@ -222,8 +238,8 @@ func TestStream_User_TooManyFriends(t *testing.T) { friendsList := "[" + strings.Repeat("1234567890, ", 7000) + "1234567890]" fmt.Fprintf(w, `{"friends": %s}`+"\r\n"+"\r\n", friendsList) default: - // Only allow first request - http.Error(w, "Stream API not available!", 130) + // Simulate stream disconnect + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) } reqCount++ }) @@ -235,6 +251,8 @@ func TestStream_User_TooManyFriends(t *testing.T) { StallWarnings: Bool(true), With: "followings", } + client.Streams.exponentialBackoff = &BackOffRecorder{MaxRetries: 1} + client.Streams.aggressiveExponentialBackoff = &BackOffRecorder{MaxRetries: 1} stream, err := client.Streams.User(streamUserParams) // assert that the expected messages are received assert.NoError(t, err) @@ -242,7 +260,7 @@ func TestStream_User_TooManyFriends(t *testing.T) { for message := range stream.Messages { demux.Handle(message) } - expectedCounts := &counter{all: 1, friendsList: 1} + expectedCounts := &counter{all: 2, friendsList: 1, other: 1} assert.Equal(t, expectedCounts, counts) } @@ -263,8 +281,8 @@ func TestStream_Site(t *testing.T) { `{"text": "Gophercon super talks!"}`+"\r\n", ) default: - // Only allow first request - http.Error(w, "Stream API not available!", 130) + // Simulate stream disconnect + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) } reqCount++ }) @@ -275,14 +293,15 @@ func TestStream_Site(t *testing.T) { streamSiteParams := &StreamSiteParams{ Follow: []string{"666024290140217347", "666024290140217349"}, } + client.Streams.exponentialBackoff = &BackOffRecorder{MaxRetries: 1} + client.Streams.aggressiveExponentialBackoff = &BackOffRecorder{MaxRetries: 1} stream, err := client.Streams.Site(streamSiteParams) // assert that the expected messages are received assert.NoError(t, err) - defer stream.Stop() for message := range stream.Messages { demux.Handle(message) } - expectedCounts := &counter{all: 2, other: 2} + expectedCounts := &counter{all: 3, other: 3} assert.Equal(t, expectedCounts, counts) } @@ -303,8 +322,8 @@ func TestStream_PublicFirehose(t *testing.T) { `{"text": "Gophercon super talks!"}`+"\r\n", ) default: - // Only allow first request - http.Error(w, "Stream API not available!", 130) + // Simulate stream disconnect + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) } reqCount++ }) @@ -315,6 +334,8 @@ func TestStream_PublicFirehose(t *testing.T) { streamFirehoseParams := &StreamFirehoseParams{ Count: 100, } + client.Streams.exponentialBackoff = &BackOffRecorder{MaxRetries: 1} + client.Streams.aggressiveExponentialBackoff = &BackOffRecorder{MaxRetries: 1} stream, err := client.Streams.Firehose(streamFirehoseParams) // assert that the expected messages are received assert.NoError(t, err) @@ -322,7 +343,7 @@ func TestStream_PublicFirehose(t *testing.T) { for message := range stream.Messages { demux.Handle(message) } - expectedCounts := &counter{all: 2, other: 2} + expectedCounts := &counter{all: 3, other: 3} assert.Equal(t, expectedCounts, counts) } @@ -337,10 +358,11 @@ func TestStreamRetry_ExponentialBackoff(t *testing.T) { http.Error(w, "Service Unavailable", 503) default: // Only allow first request - http.Error(w, "Stream API not available!", 130) + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) } reqCount++ }) + stream := &Stream{ client: httpClient, Messages: make(chan interface{}), @@ -349,13 +371,21 @@ func TestStreamRetry_ExponentialBackoff(t *testing.T) { } stream.group.Add(1) req, _ := http.NewRequest("GET", "http://example.com/", nil) - expBackoff := &BackOffRecorder{} - // receive messages and throw them away - go NewSwitchDemux().HandleChan(stream.Messages) - stream.retry(req, expBackoff, nil) + expBackoff := &BackOffRecorder{MaxRetries: 1} + // receive messages and count types, stop receiving after max retries + counts := &counter{} + demux := newCounterDemux(counts) + + go stream.retry(req, expBackoff, nil) defer stream.Stop() + for message := range stream.Messages { + demux.Handle(message) + } + // assert exponential backoff in response to 503 assert.Equal(t, 1, expBackoff.Count) + expectedCounts := &counter{all: 1, other: 1} + assert.Equal(t, expectedCounts, counts) } func TestStreamRetry_AggressiveBackoff(t *testing.T) { @@ -366,12 +396,11 @@ func TestStreamRetry_AggressiveBackoff(t *testing.T) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { switch reqCount { case 0: - http.Error(w, "Enhance Your Calm", 420) - case 1: - http.Error(w, "Too Many Requests", 429) + http.Error(w, "Enhance your calm", 420) default: - // Only allow first request - http.Error(w, "Stream API not available!", 130) + w.WriteHeader(http.StatusTooManyRequests) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}`) } reqCount++ }) @@ -383,11 +412,21 @@ func TestStreamRetry_AggressiveBackoff(t *testing.T) { } stream.group.Add(1) req, _ := http.NewRequest("GET", "http://example.com/", nil) - aggExpBackoff := &BackOffRecorder{} - // receive messages and throw them away - go NewSwitchDemux().HandleChan(stream.Messages) - stream.retry(req, nil, aggExpBackoff) + aggExpBackoff := &BackOffRecorder{MaxRetries: 2} + + // receive messages and count types, stop receiving after max retries + counts := &counter{} + demux := newCounterDemux(counts) + + go stream.retry(req, nil, aggExpBackoff) defer stream.Stop() + + for message := range stream.Messages { + demux.Handle(message) + } + // assert aggressive exponential backoff in response to 420 and 429 assert.Equal(t, 2, aggExpBackoff.Count) + expectedCounts := &counter{all: 2, apiError: 1, other: 1} + assert.Equal(t, expectedCounts, counts) } diff --git a/twitter/test_utils.go b/twitter/test_utils.go new file mode 100644 index 0000000..8b869af --- /dev/null +++ b/twitter/test_utils.go @@ -0,0 +1,57 @@ +package twitter + +import ( + "net/http" + "net/http/httptest" + "net/url" +) + +// NewTestStream creates a Stream for testing with a provided input Messages channel. +// It is safe to call Stop() once on the provided *Stream, as with a normal Stream. +func NewTestStream(messages chan interface{}) *Stream { + return &Stream{ + Messages: messages, + done: make(chan struct{}), + } +} + +// NewTestServer exposes testServer for test scaffolding in libraries that use go-twitter +// it takes a map of path:functions to set the ServeMux. +func NewTestServer(handlers map[string]func(w http.ResponseWriter, r *http.Request)) (*http.Client, *httptest.Server) { + client, mux, server := testServer() + for path, handler := range handlers { + mux.HandleFunc(path, handler) + } + return client, server +} + +// testServer returns an http Client, ServeMux, and Server. The client proxies +// requests to the server and handlers can be registered on the mux to handle +// requests. The caller must close the test server. +func testServer() (*http.Client, *http.ServeMux, *httptest.Server) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + transport := &RewriteTransport{&http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + return url.Parse(server.URL) + }, + }} + client := &http.Client{Transport: transport} + return client, mux, server +} + +// RewriteTransport rewrites https requests to http to avoid TLS cert issues +// during testing. +type RewriteTransport struct { + Transport http.RoundTripper +} + +// RoundTrip rewrites the request scheme to http and calls through to the +// composed RoundTripper or if it is nil, to the http.DefaultTransport. +func (t *RewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + if t.Transport == nil { + return http.DefaultTransport.RoundTrip(req) + } + return t.Transport.RoundTrip(req) +} diff --git a/twitter/trends.go b/twitter/trends.go index b68a698..a2bf3ea 100644 --- a/twitter/trends.go +++ b/twitter/trends.go @@ -24,7 +24,7 @@ type PlaceType struct { Name string `json:"name"` } -// Location reporesents a twitter Location. +// Location represents a twitter Location. type Location struct { Country string `json:"country"` CountryCode string `json:"countryCode"` diff --git a/twitter/twitter.go b/twitter/twitter.go index 96fbf9f..d68731d 100644 --- a/twitter/twitter.go +++ b/twitter/twitter.go @@ -18,7 +18,10 @@ type Client struct { Followers *FollowerService Friends *FriendService Friendships *FriendshipService + Lists *ListsService + RateLimits *RateLimitService Search *SearchService + PremiumSearch *PremiumSearchService Statuses *StatusService Streams *StreamService Timelines *TimelineService @@ -37,7 +40,10 @@ func NewClient(httpClient *http.Client) *Client { Followers: newFollowerService(base.New()), Friends: newFriendService(base.New()), Friendships: newFriendshipService(base.New()), + Lists: newListService(base.New()), + RateLimits: newRateLimitService(base.New()), Search: newSearchService(base.New()), + PremiumSearch: newPremiumSearchService(base.New()), Statuses: newStatusService(base.New()), Streams: newStreamService(httpClient, base.New()), Timelines: newTimelineService(base.New()), diff --git a/twitter/twitter_test.go b/twitter/twitter_test.go index 4b7ca29..f5043db 100644 --- a/twitter/twitter_test.go +++ b/twitter/twitter_test.go @@ -1,8 +1,8 @@ package twitter import ( + "io/ioutil" "net/http" - "net/http/httptest" "net/url" "testing" "time" @@ -12,37 +12,6 @@ import ( var defaultTestTimeout = time.Second * 1 -// testServer returns an http Client, ServeMux, and Server. The client proxies -// requests to the server and handlers can be registered on the mux to handle -// requests. The caller must close the test server. -func testServer() (*http.Client, *http.ServeMux, *httptest.Server) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - transport := &RewriteTransport{&http.Transport{ - Proxy: func(req *http.Request) (*url.URL, error) { - return url.Parse(server.URL) - }, - }} - client := &http.Client{Transport: transport} - return client, mux, server -} - -// RewriteTransport rewrites https requests to http to avoid TLS cert issues -// during testing. -type RewriteTransport struct { - Transport http.RoundTripper -} - -// RoundTrip rewrites the request scheme to http and calls through to the -// composed RoundTripper or if it is nil, to the http.DefaultTransport. -func (t *RewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.URL.Scheme = "http" - if t.Transport == nil { - return http.DefaultTransport.RoundTrip(req) - } - return t.Transport.RoundTrip(req) -} - func assertMethod(t *testing.T, expectedMethod string, req *http.Request) { assert.Equal(t, expectedMethod, req.Method) } @@ -68,6 +37,13 @@ func assertPostForm(t *testing.T, expected map[string]string, req *http.Request) assert.Equal(t, expectedValues, req.Form) } +// assertPostJSON tests that the Request has the expected JSON body. +func assertPostJSON(t *testing.T, expected string, req *http.Request) { + data, err := ioutil.ReadAll(req.Body) + assert.Nil(t, err) + assert.Equal(t, expected, string(data)) +} + // assertDone asserts that the empty struct channel is closed before the given // timeout elapses. func assertDone(t *testing.T, ch <-chan struct{}, timeout time.Duration) {