├── .github └── workflows │ └── go.yml ├── .gitignore ├── .travis.yml ├── CONTRIBUTORS ├── LICENSE ├── README.md ├── conversions.go ├── conversions_test.go ├── go.mod ├── go.sum ├── models.go ├── models_test.go └── twittergo.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Set up Go 1.x 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: ^1.13 20 | 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v2 23 | 24 | - name: Get dependencies 25 | run: | 26 | go get -v -t -d ./... 27 | if [ -f Gopkg.toml ]; then 28 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 29 | dep ensure 30 | fi 31 | 32 | - name: Build 33 | run: go build -v . 34 | 35 | - name: Test 36 | run: go test -v . 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | CREDENTIALS 2 | CREDENTIALS* 3 | *.json 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | # This is the official list of people who have contributed to this repository. 2 | # No Contributor License Agreement is enforced, although the license of the 3 | # repository has been and always will be Apache 2.0 4 | # 5 | # Please keep the list sorted. 6 | 7 | Abram 8 | Alban Dericbourg 9 | Arne Roomann-Kurrik 10 | Jeff Hodges 11 | Jehiah Czebotar 12 | Kane York 13 | Sagiv Ofek 14 | Srinivasan Venkatachary 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | twittergo 2 | ========= 3 | This project implements a Go client library for the Twitter APIs. This 4 | library supports version 1.1 of Twitter's API and application-only auth. 5 | 6 | The goal of this project is to provide a thin veneer over my `oauth1a` library 7 | in order to simplify access to Twitter's APIs from Go. Where possible, I've 8 | tried to defer to native objects (use of http requests for example). 9 | Additionally, responses could be parsed directly as JSON, but some wrapper 10 | types have been defined in order to provide some convenience methods for 11 | accessing data. 12 | 13 | [![Build Status](https://travis-ci.org/kurrik/twittergo.svg?branch=master)](https://travis-ci.org/kurrik/twittergo) 14 | 15 | Installing 16 | ---------- 17 | Run 18 | 19 | go get github.com/kurrik/twittergo 20 | 21 | Include in your source: 22 | 23 | import "github.com/kurrik/twittergo" 24 | 25 | Godoc 26 | ----- 27 | See http://godoc.org/github.com/kurrik/twittergo 28 | 29 | Using 30 | ----- 31 | I have moved all of the examples to the 32 | https://github.com/kurrik/twittergo-examples project in order to make this 33 | library easier to import. Please reference that project for ways to address 34 | specific use cases. 35 | 36 | The simplest example in the `twittergo-examples` project 37 | is probably `verify_credentials`. This calls an 38 | endpoint which will return the current user if the request is signed 39 | correctly. 40 | 41 | The example starts by loading credentials, which can be done in 42 | many ways. The example implements a `LoadCredentials` which looks for 43 | the `CREDENTIALS` file mentioned above: 44 | 45 | ```go 46 | var ( 47 | err error 48 | client *twittergo.Client 49 | req *http.Request 50 | resp *twittergo.APIResponse 51 | user *twittergo.User 52 | ) 53 | client, err = LoadCredentials() 54 | if err != nil { 55 | fmt.Printf("Could not parse CREDENTIALS file: %v\n", err) 56 | os.Exit(1) 57 | } 58 | ``` 59 | 60 | Then, a standard `http` request is created to a `/1.1/` endpoint: 61 | 62 | ```go 63 | req, err = http.NewRequest("GET", "/1.1/account/verify_credentials.json", nil) 64 | if err != nil { 65 | fmt.Printf("Could not parse request: %v\n", err) 66 | os.Exit(1) 67 | } 68 | ``` 69 | 70 | The client object handles sending the request: 71 | 72 | ```go 73 | resp, err = client.SendRequest(req) 74 | if err != nil { 75 | fmt.Printf("Could not send request: %v\n", err) 76 | os.Exit(1) 77 | } 78 | ``` 79 | 80 | The response object has some convenience methods for checking rate limits, etc: 81 | 82 | ```go 83 | if resp.HasRateLimit() { 84 | fmt.Printf("Rate limit: %v\n", resp.RateLimit()) 85 | fmt.Printf("Rate limit remaining: %v\n", resp.RateLimitRemaining()) 86 | fmt.Printf("Rate limit reset: %v\n", resp.RateLimitReset()) 87 | } else { 88 | fmt.Printf("Could not parse rate limit from response.\n") 89 | } 90 | ``` 91 | 92 | Finally, if the response format is known, the library provides some standard 93 | objects which make parsing response data easier: 94 | 95 | ```go 96 | user = &twittergo.User{} 97 | err = resp.Parse(user) 98 | if err != nil { 99 | fmt.Printf("Problem parsing response: %v\n", err) 100 | os.Exit(1) 101 | } 102 | fmt.Printf("ID: %v\n", user.Id()) 103 | fmt.Printf("Name: %v\n", user.Name()) 104 | ``` 105 | 106 | Error handling 107 | -------------- 108 | Errors are returned by most methods as is Golang convention. However, these 109 | errors may sometimes be cast into `twittergo.ResponseError`, `twittergo.Errors` 110 | or `twittergo.RateLimitError` structs which will provide additional information. 111 | 112 | To check for rate limiting or other types of server errors, attempt to cast 113 | any errors returned by the `APIResponse.Parse` method. 114 | 115 | ```go 116 | resp, err = client.SendRequest(req) 117 | if err != nil { 118 | fmt.Printf("Could not send request: %v\n", err) 119 | os.Exit(1) 120 | } 121 | tweet = &twittergo.Tweet{} 122 | err = resp.Parse(tweet) 123 | if err != nil { 124 | if rle, ok := err.(twittergo.RateLimitError); ok { 125 | fmt.Printf("Rate limited, reset at %v\n", rle.Reset) 126 | } else if errs, ok := err.(twittergo.Errors); ok { 127 | for i, val := range errs.Errors() { 128 | fmt.Printf("Error #%v - ", i + 1) 129 | fmt.Printf("Code: %v ", val.Code()) 130 | fmt.Printf("Msg: %v\n", val.Message()) 131 | } 132 | } else { 133 | fmt.Printf("Problem parsing response: %v\n", err) 134 | } 135 | os.Exit(1) 136 | } 137 | ``` 138 | 139 | The previous snippet would print the following if a user attempted to Tweet 140 | the same text twice in a row: 141 | 142 | Error #1 - Code: 187 Msg: Status is a duplicate 143 | 144 | Rate limit errors are pretty easy to use. They're a simple struct containing 145 | what the limit for the request was, how many were remaining (should be 0) 146 | and when the limiting resets: 147 | 148 | ```go 149 | type RateLimitError struct { 150 | Limit uint32 151 | Remaining uint32 152 | Reset time.Time 153 | } 154 | ``` 155 | 156 | The Errors type is a little more complicated, as it may return one or more 157 | server side errors. It is possible to cast one to a string using the standard 158 | `Error` method, but if you need to handle individual errors, iterate over 159 | the slice returned by `Errors` (plural) instead: 160 | 161 | ```go 162 | for i, val := range errs.Errors() { 163 | fmt.Printf("Error #%v - ", i + 1) 164 | fmt.Printf("Code: %v ", val.Code()) 165 | fmt.Printf("Msg: %v\n", val.Message()) 166 | } 167 | ``` 168 | 169 | Each of *those* errors has a `Code` and a `Message` method, which return 170 | values and strings corresponding to those listed in the "Error codes" section 171 | of this page: https://dev.twitter.com/docs/error-codes-responses 172 | 173 | Application-only auth 174 | --------------------- 175 | If no user credentials are set, then the library falls back to attempting 176 | to authenticate with application-only auth, as described here: 177 | https://dev.twitter.com/docs/auth/application-only-auth 178 | 179 | If you want to obtain an access token for later use, create a client with 180 | no user credentials. 181 | 182 | ```go 183 | config := &oauth1a.ClientConfig{ 184 | ConsumerKey: "consumer_key", 185 | ConsumerSecret: "consumer_secret", 186 | } 187 | client = twittergo.NewClient(config, nil) 188 | if err := c.FetchAppToken(); err != nil { 189 | // Handle error ... 190 | } 191 | token := c.GetAppToken() 192 | // ... Save token in data store 193 | ``` 194 | 195 | To restore a previously obtained token, just call SetAppToken(): 196 | 197 | ```go 198 | // Get token from data store ... 199 | c.SetAppToken(token) 200 | ``` 201 | 202 | Saving and restoring the token isn't necessary if you keep the client in 203 | memory, though. If you just create a client without any user credentials, 204 | calls to `SendRequest` will automatically fetch and persist the app token 205 | in memory. See 206 | [search_app_auth/main.go](https://github.com/kurrik/twittergo-examples/blob/master/search_app_auth/main.go) 207 | for an example of this. 208 | 209 | Google App Engine 210 | ----------------- 211 | This library works with Google App Engine's Go runtime but requires slight 212 | modification to fall back on the `urlfetch` package for http transport. 213 | 214 | After creating a `Client`, replace its `HttpClient` with an instance of 215 | `urlfetch.Client`: 216 | 217 | ```go 218 | var ( 219 | r *http.Request 220 | config *oauth1a.ClientConfig 221 | user *oauth1a.UserConfig 222 | ) 223 | ... 224 | ctx = appengine.NewContext(r) 225 | c = twittergo.NewClient(config, user) 226 | c.HttpClient = urlfetch.Client(ctx) 227 | ``` 228 | 229 | For a comprehensive example, see 230 | [user_timeline_appengine](https://github.com/kurrik/twittergo-examples/blob/master/user_timeline_appengine/src/app/app.go#L138) 231 | 232 | Custom models 233 | ------------- 234 | The `twittergo` library comes with some standard models for structures like 235 | Tweets and Timelines, but you are not required to use them. Pass any 236 | struct which will deserialize from a Twitter API response to `APIResponse.Parse`: 237 | 238 | ```go 239 | type CustomTweet struct { 240 | CustomID string `json:"id_str"` 241 | CustomText string `json:"text"` 242 | } 243 | ... 244 | 245 | req, err = http.NewRequest("GET", url, nil) 246 | ... 247 | resp, err = client.SendRequest(req) 248 | customTweet = &CustomTweet{} 249 | err = resp.Parse(customTweet) 250 | ... 251 | ``` 252 | 253 | For complete code, see the 254 | [custom_struct](https://github.com/kurrik/twittergo-examples/blob/master/custom_struct/main.go) 255 | example. 256 | 257 | Debugging 258 | --------- 259 | To see what requests are being issued by the library, set up an HTTP proxy 260 | such as Charles Proxy and then set the following environment variable: 261 | 262 | export HTTP_PROXY=http://localhost:8888 263 | 264 | Because Go will reject HTTPS requests through a proxy, you'll need to set 265 | the following for any HTTPS endpoints: 266 | 267 | export TLS_INSECURE=1 268 | 269 | Make sure not to use this in production! 270 | -------------------------------------------------------------------------------- /conversions.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Arne Roomann-Kurrik 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package twittergo 16 | 17 | import "strconv" 18 | 19 | func arrayValue(m map[string]interface{}, key string) []interface{} { 20 | v, exists := m[key] 21 | if exists { 22 | return v.([]interface{}) 23 | } else { 24 | return []interface{}{} 25 | } 26 | } 27 | 28 | func boolValue(m map[string]interface{}, key string) bool { 29 | v, exists := m[key] 30 | if exists { 31 | switch value := v.(type) { 32 | case bool: 33 | return value 34 | default: 35 | return false 36 | } 37 | } else { 38 | return false 39 | } 40 | } 41 | 42 | func int32Value(m map[string]interface{}, key string) int32 { 43 | v, exists := m[key] 44 | if exists { 45 | switch value := v.(type) { 46 | case int32: 47 | return value 48 | case int64: 49 | return -1 // TODO: Should allow lib to control preference here. 50 | case float64: 51 | return -1 // TODO: Should allow lib to control preference here. 52 | default: 53 | return 0 54 | } 55 | } else { 56 | return 0 57 | } 58 | } 59 | 60 | func int64Value(m map[string]interface{}, key string) int64 { 61 | v, exists := m[key] 62 | if exists { 63 | switch value := v.(type) { 64 | case int64: 65 | return value 66 | case float64: 67 | return int64(value) // TODO: Should allow lib to control preference here. 68 | default: 69 | return 0 70 | } 71 | } else { 72 | return 0 73 | } 74 | } 75 | 76 | func float64Value(m map[string]interface{}, key string) float64 { 77 | v, exists := m[key] 78 | if exists { 79 | switch value := v.(type) { 80 | case float64: 81 | return value 82 | case int64: 83 | return float64(value) // TODO: Should allow lib to control preference here. 84 | default: 85 | return 0.0 86 | } 87 | } else { 88 | return 0.0 89 | } 90 | } 91 | 92 | func mapValue(m map[string]interface{}, key string) map[string]interface{} { 93 | v, exists := m[key] 94 | if exists { 95 | return v.(map[string]interface{}) 96 | } else { 97 | return map[string]interface{}{} 98 | } 99 | } 100 | 101 | func stringValue(m map[string]interface{}, key string) string { 102 | v, exists := m[key] 103 | if exists { 104 | switch value := v.(type) { 105 | case string: 106 | return value 107 | case int64: 108 | return strconv.FormatInt(value, 10) 109 | case float64: 110 | return strconv.FormatFloat(value, 'G', -1, 64) 111 | case bool: 112 | return strconv.FormatBool(value) 113 | default: 114 | return "" 115 | } 116 | } else { 117 | return "" 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /conversions_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Arne Roomann-Kurrik 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package twittergo 16 | 17 | import ( 18 | "testing" 19 | ) 20 | 21 | func TestConversions(t *testing.T) { 22 | m := map[string]interface{}{ 23 | "arrayKey": []interface{}{"foo", "bar", "baz"}, 24 | "boolKey": true, 25 | "int32Key": int32(1234), 26 | "int64Key": int64(1002011200236892166), 27 | "float64Key": float64(1002011200236892166.1234), 28 | "mapKey": map[string]interface{}{"foo": "bar"}, 29 | } 30 | if len(arrayValue(m, "arrayKey")) != 3 { 31 | t.Errorf("arrayValue did not produce correct result for valid key") 32 | } 33 | if len(arrayValue(m, "badKey")) != 0 { 34 | t.Errorf("arrayValue did not product correct result for invalid key") 35 | } 36 | if boolValue(m, "boolKey") != true { 37 | t.Errorf("boolValue did not produce correct result for valid key") 38 | } 39 | if boolValue(m, "badKey") != false { 40 | t.Errorf("boolValue did not product correct result for invalid key") 41 | } 42 | if int32Value(m, "int32Key") != 1234 { 43 | t.Errorf("int32Value did not produce correct result for valid key") 44 | } 45 | if int32Value(m, "badKey") != 0 { 46 | t.Errorf("int32Value did not product correct result for invalid key") 47 | } 48 | if int64Value(m, "int64Key") != 1002011200236892166 { 49 | t.Errorf("int64Value did not produce correct result for valid key") 50 | } 51 | if int64Value(m, "badKey") != 0 { 52 | t.Errorf("int64Value did not product correct result for invalid key") 53 | } 54 | if float64Value(m, "float64Key") != 1002011200236892166.1234 { 55 | t.Errorf("float64Value did not produce correct result for valid key") 56 | } 57 | if float64Value(m, "badKey") != 0 { 58 | t.Errorf("float64Value did not product correct result for invalid key") 59 | } 60 | if len(mapValue(m, "mapKey")) != 1 { 61 | t.Errorf("mapValue did not produce correct result for valid key") 62 | } 63 | if len(mapValue(m, "badKey")) != 0 { 64 | t.Errorf("mapValue did not product correct result for invalid key") 65 | } 66 | 67 | // Test conversions 68 | if int32Value(m, "int64Key") != -1 { 69 | t.Errorf("int32Value did not product correct result for an int64 key") 70 | } 71 | if int32Value(m, "float64Key") != -1 { 72 | t.Errorf("int32Value did not product correct result for a float64 key") 73 | } 74 | // This is dangerous - note the discrepancy in returned value: 1002011200236892160 vs 1002011200236892166.1234 75 | if int64Value(m, "float64Key") != 1002011200236892160 { 76 | t.Errorf("int64Value did not product correct result for a float64 key") 77 | } 78 | if float64Value(m, "int64Key") != 1002011200236892166.0 { 79 | t.Errorf("float64Value did not product correct result for an int64 key") 80 | } 81 | if stringValue(m, "int64Key") != "1002011200236892166" { 82 | t.Errorf("stringValue did not product correct result for an int64 key") 83 | } 84 | // This is dangerous - note the discrepancy in returned value: 1002011200236892166.1234 vs 1.0020112002368922E+18 85 | if stringValue(m, "float64Key") != "1.0020112002368922E+18" { 86 | t.Errorf("stringValue did not product correct result for a float64 key %v", stringValue(m, "float64Key")) 87 | } 88 | if stringValue(m, "boolKey") != "true" { 89 | t.Errorf("stringValue did not product correct result for a bool key") 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kurrik/twittergo 2 | 3 | go 1.16 4 | 5 | require github.com/kurrik/oauth1a v0.1.1 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/kurrik/oauth1a v0.1.1 h1:3myAVza5bCMnyW/0gcVtQUeYaqcMKmniNxOIm0ESjek= 2 | github.com/kurrik/oauth1a v0.1.1/go.mod h1:2lmEMbW1BVM6RfQ6aN+b7kQSegGdXU4XeVfHKm4qxM0= 3 | -------------------------------------------------------------------------------- /models.go: -------------------------------------------------------------------------------- 1 | // Copyright 2011 Arne Roomann-Kurrik 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package twittergo 16 | 17 | import ( 18 | "compress/gzip" 19 | "encoding/json" 20 | "fmt" 21 | "io" 22 | "io/ioutil" 23 | "net/http" 24 | "net/url" 25 | "strconv" 26 | "strings" 27 | "time" 28 | ) 29 | 30 | const ( 31 | H_LIMIT = "X-Rate-Limit-Limit" 32 | H_LIMIT_REMAIN = "X-Rate-Limit-Remaining" 33 | H_LIMIT_RESET = "X-Rate-Limit-Reset" 34 | H_MEDIA_LIMIT = "X-MediaRateLimit-Limit" 35 | H_MEDIA_LIMIT_REMAIN = "X-MediaRateLimit-Remaining" 36 | H_MEDIA_LIMIT_RESET = "X-MediaRateLimit-Reset" 37 | ) 38 | 39 | const ( 40 | STATUS_OK = 200 41 | STATUS_CREATED = 201 42 | STATUS_ACCEPTED = 202 43 | STATUS_NO_CONTENT = 204 44 | STATUS_INVALID = 400 45 | STATUS_UNAUTHORIZED = 401 46 | STATUS_FORBIDDEN = 403 47 | STATUS_NOTFOUND = 404 48 | STATUS_LIMIT = 429 49 | STATUS_GATEWAY = 502 50 | ) 51 | 52 | // Error returned if there was an issue parsing the response body. 53 | type ResponseError struct { 54 | Body string 55 | Code int 56 | } 57 | 58 | func NewResponseError(code int, body string) ResponseError { 59 | return ResponseError{Code: code, Body: body} 60 | } 61 | 62 | func (e ResponseError) Error() string { 63 | return fmt.Sprintf( 64 | "Unable to handle response (status code %d): `%v`", 65 | e.Code, 66 | e.Body) 67 | } 68 | 69 | type Error map[string]interface{} 70 | 71 | func (e Error) Code() int64 { 72 | return int64(float64Value(e, "code")) 73 | } 74 | 75 | func (e Error) Message() string { 76 | return stringValue(e, "message") 77 | } 78 | 79 | func (e Error) Error() string { 80 | return fmt.Sprintf("Error %v: %v", e.Code(), e.Message()) 81 | } 82 | 83 | type Errors map[string]interface{} 84 | 85 | func (e Errors) Error() string { 86 | var ( 87 | msg string = "" 88 | err Error 89 | ok bool 90 | errs []interface{} 91 | ) 92 | errs = arrayValue(e, "errors") 93 | if len(errs) == 0 { 94 | return msg 95 | } 96 | for _, val := range errs { 97 | if err, ok = val.(map[string]interface{}); ok { 98 | msg += err.Error() + ". " 99 | } 100 | } 101 | return msg 102 | } 103 | 104 | func (e Errors) String() string { 105 | return e.Error() 106 | } 107 | 108 | func (e Errors) Errors() []Error { 109 | var errs = arrayValue(e, "errors") 110 | var out = make([]Error, len(errs)) 111 | for i, val := range errs { 112 | out[i] = Error(val.(map[string]interface{})) 113 | } 114 | return out 115 | } 116 | 117 | // RateLimitResponse is implemented by both RateLimitError and APIResponse. 118 | type RateLimitResponse interface { 119 | // HasRateLimit returns false if the ratelimiting information is 120 | // optional and missing. 121 | HasRateLimit() bool 122 | // RateLimit returns the requests per time period capacity of the 123 | // limit. 124 | RateLimit() uint32 125 | // RateLimitRemaining returns how many requests are still available 126 | // in the current time period. 127 | RateLimitRemaining() uint32 128 | // RateLimitReset returns when the rate limit will reset. 129 | RateLimitReset() time.Time 130 | } 131 | 132 | // RateLimitError is returned from SendRequest when a rate limit is encountered. 133 | type RateLimitError struct { 134 | Limit uint32 135 | Remaining uint32 136 | Reset time.Time 137 | } 138 | 139 | func (e RateLimitError) Error() string { 140 | msg := "Rate limit: %v, Remaining: %v, Reset: %v" 141 | return fmt.Sprintf(msg, e.Limit, e.Remaining, e.Reset) 142 | } 143 | 144 | func (e RateLimitError) HasRateLimit() bool { 145 | return true 146 | } 147 | 148 | func (e RateLimitError) RateLimit() uint32 { 149 | return e.Limit 150 | } 151 | 152 | func (e RateLimitError) RateLimitRemaining() uint32 { 153 | return e.Remaining 154 | } 155 | 156 | func (e RateLimitError) RateLimitReset() time.Time { 157 | return e.Reset 158 | } 159 | 160 | // APIResponse provides methods for retrieving information from the HTTP 161 | // headers in a Twitter API response. 162 | type APIResponse http.Response 163 | 164 | func (r APIResponse) HasRateLimit() bool { 165 | return r.Header.Get(H_LIMIT) != "" 166 | } 167 | 168 | func (r APIResponse) RateLimit() uint32 { 169 | h := r.Header.Get(H_LIMIT) 170 | i, _ := strconv.ParseUint(h, 10, 32) 171 | return uint32(i) 172 | } 173 | 174 | func (r APIResponse) RateLimitRemaining() uint32 { 175 | h := r.Header.Get(H_LIMIT_REMAIN) 176 | i, _ := strconv.ParseUint(h, 10, 32) 177 | return uint32(i) 178 | } 179 | 180 | func (r APIResponse) RateLimitReset() time.Time { 181 | h := r.Header.Get(H_LIMIT_RESET) 182 | i, _ := strconv.ParseUint(h, 10, 32) 183 | t := time.Unix(int64(i), 0) 184 | return t 185 | } 186 | 187 | func (r APIResponse) HasMediaRateLimit() bool { 188 | return r.Header.Get(H_MEDIA_LIMIT) != "" 189 | } 190 | 191 | func (r APIResponse) MediaRateLimit() uint32 { 192 | h := r.Header.Get(H_MEDIA_LIMIT) 193 | i, _ := strconv.ParseUint(h, 10, 32) 194 | return uint32(i) 195 | } 196 | 197 | func (r APIResponse) MediaRateLimitRemaining() uint32 { 198 | h := r.Header.Get(H_MEDIA_LIMIT_REMAIN) 199 | i, _ := strconv.ParseUint(h, 10, 32) 200 | return uint32(i) 201 | } 202 | 203 | func (r APIResponse) MediaRateLimitReset() time.Time { 204 | h := r.Header.Get(H_MEDIA_LIMIT_RESET) 205 | i, _ := strconv.ParseUint(h, 10, 32) 206 | t := time.Unix(int64(i), 0) 207 | return t 208 | } 209 | 210 | func (r APIResponse) readBody() (b []byte, err error) { 211 | var ( 212 | header string 213 | reader io.Reader 214 | ) 215 | defer r.Body.Close() 216 | header = strings.ToLower(r.Header.Get("Content-Encoding")) 217 | if header == "" || strings.Index(header, "gzip") == -1 { 218 | reader = r.Body 219 | } else { 220 | if reader, err = gzip.NewReader(r.Body); err != nil { 221 | return 222 | } 223 | } 224 | b, err = ioutil.ReadAll(reader) 225 | return 226 | } 227 | 228 | // ReadBody returns the body of the response as a string. 229 | // Only one of ReadBody and Parse may be called on a given APIResponse. 230 | func (r APIResponse) ReadBody() string { 231 | var ( 232 | b []byte 233 | err error 234 | ) 235 | if b, err = r.readBody(); err != nil { 236 | return "" 237 | } 238 | return string(b) 239 | } 240 | 241 | // Parse unmarshals a JSON encoded HTTP response into the supplied interface, 242 | // with handling for the various kinds of errors the Twitter API can return. 243 | // 244 | // The returned error may be of the type Errors, RateLimitError, 245 | // ResponseError, or an error returned from io.Reader.Read(). 246 | func (r APIResponse) Parse(out interface{}) (err error) { 247 | var b []byte 248 | switch r.StatusCode { 249 | case STATUS_UNAUTHORIZED: 250 | fallthrough 251 | case STATUS_NOTFOUND: 252 | fallthrough 253 | case STATUS_GATEWAY: 254 | fallthrough 255 | case STATUS_FORBIDDEN: 256 | fallthrough 257 | case STATUS_INVALID: 258 | e := &Errors{} 259 | if b, err = r.readBody(); err != nil { 260 | return 261 | } 262 | if err = json.Unmarshal(b, e); err != nil { 263 | err = NewResponseError(r.StatusCode, string(b)) 264 | } else { 265 | err = *e 266 | } 267 | return 268 | case STATUS_LIMIT: 269 | err = RateLimitError{ 270 | Limit: r.RateLimit(), 271 | Remaining: r.RateLimitRemaining(), 272 | Reset: r.RateLimitReset(), 273 | } 274 | // consume the request body even if we don't need it 275 | r.readBody() 276 | return 277 | case STATUS_NO_CONTENT: 278 | return 279 | case STATUS_CREATED: 280 | fallthrough 281 | case STATUS_ACCEPTED: 282 | fallthrough 283 | case STATUS_OK: 284 | if b, err = r.readBody(); err != nil { 285 | return 286 | } 287 | err = json.Unmarshal(b, out) 288 | if err == io.EOF { 289 | err = nil 290 | } 291 | default: 292 | if b, err = r.readBody(); err != nil { 293 | return 294 | } 295 | err = NewResponseError(r.StatusCode, string(b)) 296 | } 297 | return 298 | } 299 | 300 | // It's a user! 301 | type User map[string]interface{} 302 | 303 | func (u User) Id() uint64 { 304 | id, _ := strconv.ParseUint(stringValue(u, "id_str"), 10, 64) 305 | return id 306 | } 307 | 308 | func (u User) IdStr() string { 309 | return stringValue(u, "id_str") 310 | } 311 | 312 | func (u User) Name() string { 313 | return stringValue(u, "name") 314 | } 315 | 316 | func (u User) ScreenName() string { 317 | return stringValue(u, "screen_name") 318 | } 319 | 320 | // It's a Tweet! (Adorably referred to by the API as a "status"). 321 | // https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/intro-to-tweet-json 322 | // https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/tweet-object 323 | // https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/extended-entities-object 324 | type Tweet map[string]interface{} 325 | 326 | func (t Tweet) CreatedAt() (out time.Time) { 327 | var ( 328 | err error 329 | src = stringValue(t, "created_at") 330 | ) 331 | if out, err = time.Parse(time.RubyDate, src); err != nil { 332 | out = time.Time{} // Could not parse time 333 | } 334 | return 335 | } 336 | 337 | func (t Tweet) Entities() Entities { 338 | return Entities(mapValue(t, "entities")) 339 | } 340 | 341 | func (t Tweet) ExtendedEntities() Entities { 342 | return Entities(mapValue(t, "extended_entities")) 343 | } 344 | 345 | func (t Tweet) ExtendedTweet() Tweet { 346 | return Tweet(mapValue(t, "extended_tweet")) 347 | } 348 | 349 | func (t Tweet) FullText() string { 350 | return stringValue(t, "full_text") 351 | } 352 | 353 | func (t Tweet) Id() (id uint64) { 354 | var ( 355 | err error 356 | src = stringValue(t, "id_str") 357 | ) 358 | if id, err = strconv.ParseUint(src, 10, 64); err != nil { 359 | return 0 360 | } 361 | return 362 | } 363 | 364 | func (t Tweet) IdStr() string { 365 | return stringValue(t, "id_str") 366 | } 367 | 368 | func (t Tweet) Language() string { 369 | return stringValue(t, "lang") 370 | } 371 | 372 | func (t Tweet) Text() string { 373 | return stringValue(t, "text") 374 | } 375 | 376 | func (t Tweet) Truncated() bool { 377 | return boolValue(t, "truncated") 378 | } 379 | 380 | func (t Tweet) User() User { 381 | return User(mapValue(t, "user")) 382 | } 383 | 384 | // Entities such as hashtags present in the Tweet. 385 | // https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/entities-object 386 | type Entities map[string]interface{} 387 | 388 | func (e Entities) Hashtags() []Hashtag { 389 | values := arrayValue(e, "hashtags") 390 | out := make([]Hashtag, len(values)) 391 | for i, val := range values { 392 | out[i] = Hashtag(val.(map[string]interface{})) 393 | } 394 | return out 395 | } 396 | 397 | func (e Entities) Media() []Media { 398 | values := arrayValue(e, "media") 399 | out := make([]Media, len(values)) 400 | for i, val := range values { 401 | out[i] = Media(val.(map[string]interface{})) 402 | } 403 | return out 404 | } 405 | 406 | func (e Entities) Polls() []Poll { 407 | values := arrayValue(e, "polls") 408 | out := make([]Poll, len(values)) 409 | for i, val := range values { 410 | out[i] = Poll(val.(map[string]interface{})) 411 | } 412 | return out 413 | } 414 | 415 | func (e Entities) Symbols() []Symbol { 416 | values := arrayValue(e, "symbols") 417 | out := make([]Symbol, len(values)) 418 | for i, val := range values { 419 | out[i] = Symbol(val.(map[string]interface{})) 420 | } 421 | return out 422 | } 423 | 424 | func (e Entities) URLs() []URL { 425 | values := arrayValue(e, "urls") 426 | out := make([]URL, len(values)) 427 | for i, val := range values { 428 | out[i] = URL(val.(map[string]interface{})) 429 | } 430 | return out 431 | } 432 | 433 | func (e Entities) UserMentions() []UserMention { 434 | values := arrayValue(e, "user_mentions") 435 | out := make([]UserMention, len(values)) 436 | for i, val := range values { 437 | out[i] = UserMention(val.(map[string]interface{})) 438 | } 439 | return out 440 | } 441 | 442 | // Hashtag reference in text. 443 | type Hashtag map[string]interface{} 444 | 445 | // Media object reference in text. 446 | type Media map[string]interface{} 447 | 448 | // Poll object associated with a Tweet. 449 | type Poll map[string]interface{} 450 | 451 | // Symbol (e.g. cashtag) reference in text. 452 | type Symbol map[string]interface{} 453 | 454 | // Url reference in text. 455 | type URL map[string]interface{} 456 | 457 | // User mention in text. 458 | type UserMention map[string]interface{} 459 | 460 | // A range, typically representing text ranges. 461 | type Range []int 462 | 463 | // It's a less structured list of Tweets! 464 | type Timeline []Tweet 465 | 466 | // It's a structured list of Tweets! 467 | type SearchResults map[string]interface{} 468 | 469 | func (sr SearchResults) Statuses() []Tweet { 470 | var a []interface{} = arrayValue(sr, "statuses") 471 | b := make([]Tweet, len(a)) 472 | for i, v := range a { 473 | b[i] = v.(map[string]interface{}) 474 | } 475 | return b 476 | } 477 | 478 | func (sr SearchResults) SearchMetadata() map[string]interface{} { 479 | a := mapValue(sr, "search_metadata") 480 | return a 481 | } 482 | 483 | func (sr SearchResults) NextQuery() (val url.Values, err error) { 484 | var ( 485 | sm map[string]interface{} 486 | n interface{} 487 | next string 488 | ok bool 489 | ) 490 | sm = sr.SearchMetadata() 491 | if n, ok = sm["next_results"]; !ok { 492 | err = fmt.Errorf("Could not get next_results from search") 493 | return 494 | } 495 | if next, ok = n.(string); !ok { 496 | err = fmt.Errorf("Could not convert next_results to str: %v", n) 497 | return 498 | } 499 | if next[0] == '?' { 500 | next = next[1:] 501 | } 502 | val, err = url.ParseQuery(next) 503 | return 504 | } 505 | 506 | // A List! 507 | type List map[string]interface{} 508 | 509 | func (l List) User() User { 510 | return User(mapValue(l, "user")) 511 | } 512 | 513 | func (l List) Id() (id uint64) { 514 | var ( 515 | err error 516 | src = stringValue(l, "id_str") 517 | ) 518 | if id, err = strconv.ParseUint(src, 10, 64); err != nil { 519 | return 0 // Could not parse the ID 520 | } 521 | return 522 | } 523 | 524 | func (l List) IdStr() string { 525 | return stringValue(l, "id_str") 526 | } 527 | 528 | func (l List) Mode() string { 529 | return stringValue(l, "mode") 530 | } 531 | 532 | func (l List) Name() string { 533 | return stringValue(l, "name") 534 | } 535 | 536 | func (l List) Slug() string { 537 | return stringValue(l, "slug") 538 | } 539 | 540 | func (l List) SubscriberCount() int64 { 541 | return int64Value(l, "subscriber_count") 542 | } 543 | 544 | func (l List) MemberCount() int64 { 545 | return int64Value(l, "member_count") 546 | } 547 | 548 | // It's a less structured list of Lists! 549 | type Lists []List 550 | 551 | // It's a cursored list of Lists! 552 | 553 | type CursoredLists map[string]interface{} 554 | 555 | func (cl CursoredLists) NextCursorStr() string { 556 | return stringValue(cl, "next_cursor_str") 557 | } 558 | 559 | func (cl CursoredLists) PreviousCursorStr() string { 560 | return stringValue(cl, "previous_cursor_str") 561 | } 562 | 563 | func (cl CursoredLists) Lists() Lists { 564 | var a []interface{} = arrayValue(cl, "lists") 565 | b := make([]List, len(a)) 566 | for i, v := range a { 567 | b[i] = v.(map[string]interface{}) 568 | } 569 | return b 570 | } 571 | 572 | // Nested response structure for video uploads. 573 | type VideoUpload map[string]interface{} 574 | 575 | func (v VideoUpload) Type() string { 576 | return stringValue(v, "video_type") 577 | } 578 | 579 | // Response for media upload requests. 580 | type MediaResponse map[string]interface{} 581 | 582 | func (r MediaResponse) MediaId() int64 { 583 | return int64Value(r, "media_id") 584 | } 585 | 586 | func (r MediaResponse) Size() int64 { 587 | return int64Value(r, "size") 588 | } 589 | 590 | func (r MediaResponse) ExpiresAfterSecs() int32 { 591 | return int32Value(r, "expires_after_secs") 592 | } 593 | 594 | func (r MediaResponse) Video() VideoUpload { 595 | return VideoUpload(mapValue(r, "video")) 596 | } 597 | -------------------------------------------------------------------------------- /models_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 Arne Roomann-Kurrik 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package twittergo 16 | 17 | import ( 18 | "bytes" 19 | "fmt" 20 | "net/http" 21 | "testing" 22 | "time" 23 | ) 24 | 25 | type Body struct { 26 | *bytes.Buffer 27 | } 28 | 29 | func NewBody(body string) *Body { 30 | return &Body{ 31 | Buffer: bytes.NewBufferString(body), 32 | } 33 | } 34 | 35 | func (b *Body) Close() error { 36 | return nil 37 | } 38 | 39 | func getResponse(code int, body string) *http.Response { 40 | var resp = &http.Response{} 41 | resp.Body = NewBody(body) 42 | resp.Header = http.Header(map[string][]string{}) 43 | resp.StatusCode = code 44 | return resp 45 | } 46 | 47 | func TestRateLimitError(t *testing.T) { 48 | // Setup 49 | var body = `{"errors":[{"message":"Rate limit exceeded","code":88}]}` 50 | var resp = getResponse(429, body) 51 | resp.Status = "Too Many Requests" 52 | resp.Header = http.Header(map[string][]string{ 53 | "Content-Length": []string{"56"}, 54 | "X-Rate-Limit-Limit": []string{"180"}, 55 | "X-Rate-Limit-Remaining": []string{"0"}, 56 | "X-Rate-Limit-Reset": []string{"1369331745"}, 57 | }) 58 | 59 | // Test 60 | var ( 61 | api_resp *APIResponse 62 | tweet *Tweet 63 | err error 64 | rle RateLimitError 65 | ok bool 66 | ) 67 | api_resp = (*APIResponse)(resp) 68 | tweet = &Tweet{} 69 | err = api_resp.Parse(tweet) 70 | if err == nil { 71 | t.Fatalf("Expected an error in Parse") 72 | } 73 | if rle, ok = err.(RateLimitError); !ok { 74 | t.Fatalf("Expected a RateLimitError error") 75 | } 76 | if !rle.Reset.Equal(time.Unix(1369331745, 0)) { 77 | t.Errorf("Reset not parsed correctly, got %v", rle.Reset) 78 | } 79 | if rle.Remaining != 0 { 80 | t.Errorf("Remaining not parsed correctly, got %v", rle.Remaining) 81 | } 82 | if rle.Limit != 180 { 83 | t.Errorf("Limit not parsed correctly, got %v", rle.Limit) 84 | } 85 | } 86 | 87 | func TestErrorsError(t *testing.T) { 88 | // Setup 89 | var err1 = `{"code":187,"message":"Status is a duplicate"}` 90 | var err2 = `{"message":"Rate limit exceeded","code":88}` 91 | var body = fmt.Sprintf(`{"errors":[%v,%v]}`, err1, err2) 92 | var resp = getResponse(403, body) 93 | resp.Status = "Forbidden" 94 | resp.Header.Set("Content-Length", fmt.Sprintf("%v", len(body))) 95 | 96 | // Test 97 | var ( 98 | api_resp *APIResponse 99 | tweet *Tweet 100 | err error 101 | errs Errors 102 | ok bool 103 | code int64 104 | msg string 105 | ) 106 | api_resp = (*APIResponse)(resp) 107 | tweet = &Tweet{} 108 | err = api_resp.Parse(tweet) 109 | if err == nil { 110 | t.Fatalf("Expected an error in Parse") 111 | } 112 | if errs, ok = err.(Errors); !ok { 113 | t.Fatalf("Expected a RateLimitError error") 114 | } 115 | if len(errs.Errors()) != 2 { 116 | t.Fatalf("Expected 2 errors to be parsed") 117 | } 118 | code = errs.Errors()[0].Code() 119 | if code != 187 { 120 | t.Errorf("Expected 187, got %v", code) 121 | } 122 | msg = errs.Errors()[0].Message() 123 | if msg != "Status is a duplicate" { 124 | t.Errorf("Got incorrect dupe status text: %v", msg) 125 | } 126 | code = errs.Errors()[1].Code() 127 | if code != 88 { 128 | t.Errorf("Expected 88, got %v", code) 129 | } 130 | msg = errs.Errors()[1].Message() 131 | if msg != "Rate limit exceeded" { 132 | t.Errorf("Got incorrect rle text: %v", msg) 133 | } 134 | } 135 | 136 | func TestNonJSONErrorWith500(t *testing.T) { 137 | // Setup 138 | var body = `Foo` 139 | var resp = getResponse(500, body) 140 | resp.Status = "Server Error" 141 | resp.Header.Set("Content-Length", fmt.Sprintf("%v", len(body))) 142 | 143 | // Test 144 | var ( 145 | api_resp *APIResponse 146 | tweet *Tweet 147 | err error 148 | rerr ResponseError 149 | ok bool 150 | expected string 151 | ) 152 | api_resp = (*APIResponse)(resp) 153 | tweet = &Tweet{} 154 | err = api_resp.Parse(tweet) 155 | if err == nil { 156 | t.Fatalf("Expected an error in Parse") 157 | } 158 | if _, ok = err.(RateLimitError); ok { 159 | t.Fatalf("Error should not parse to a RateLimitError error") 160 | } 161 | if _, ok = err.(Errors); ok { 162 | t.Fatalf("Error should not parse to a Errors error (ugh)") 163 | } 164 | expected = fmt.Sprintf("Unable to handle response (status code 500): `%v`", body) 165 | if err.Error() != expected { 166 | t.Fatalf("Rendered error string `%s`, expected `%s`", err.Error(), expected) 167 | } 168 | if rerr, ok = err.(ResponseError); !ok { 169 | t.Fatalf("Error should be castable to a ResponseError") 170 | } 171 | if rerr.Code != 500 { 172 | t.Errorf("ResponseError code should be 500, got %d", rerr.Code) 173 | } 174 | if rerr.Body != body { 175 | t.Errorf("ResponseError body should be `%s`, got `%s`", body, rerr.Body) 176 | } 177 | } 178 | 179 | func TestNonJSONErrorWith502(t *testing.T) { 180 | // Setup 181 | var body = `Foo` 182 | var resp = getResponse(502, body) 183 | resp.Status = "Bad Gateway" 184 | resp.Header.Set("Content-Length", fmt.Sprintf("%v", len(body))) 185 | 186 | // Test 187 | var ( 188 | api_resp *APIResponse 189 | tweet *Tweet 190 | err error 191 | rerr ResponseError 192 | ok bool 193 | expected string 194 | ) 195 | api_resp = (*APIResponse)(resp) 196 | tweet = &Tweet{} 197 | err = api_resp.Parse(tweet) 198 | if err == nil { 199 | t.Fatalf("Expected an error in Parse") 200 | } 201 | if _, ok = err.(RateLimitError); ok { 202 | t.Fatalf("Error should not parse to a RateLimitError error") 203 | } 204 | if _, ok = err.(Errors); ok { 205 | t.Fatalf("Error should not parse to a Errors error (ugh)") 206 | } 207 | expected = fmt.Sprintf("Unable to handle response (status code 502): `%v`", body) 208 | if err.Error() != expected { 209 | t.Fatalf("Rendered error string `%s`, expected `%s`", err.Error(), expected) 210 | } 211 | if rerr, ok = err.(ResponseError); !ok { 212 | t.Fatalf("Error should be castable to a ResponseError") 213 | } 214 | if rerr.Code != 502 { 215 | t.Errorf("ResponseError code should be 502, got %d", rerr.Code) 216 | } 217 | if rerr.Body != body { 218 | t.Errorf("ResponseError body should be `%s`, got `%s`", body, rerr.Body) 219 | } 220 | } 221 | 222 | func TestEmptyErrorWith403(t *testing.T) { 223 | // Setup 224 | var body = `` 225 | var resp = getResponse(403, body) 226 | resp.Status = "Forbidden" 227 | resp.Header.Set("Content-Length", "0") 228 | 229 | // Test 230 | var ( 231 | api_resp *APIResponse 232 | tweet *Tweet 233 | err error 234 | rerr ResponseError 235 | ok bool 236 | expected string 237 | ) 238 | api_resp = (*APIResponse)(resp) 239 | tweet = &Tweet{} 240 | err = api_resp.Parse(tweet) 241 | if err == nil { 242 | t.Fatalf("Expected an error in Parse") 243 | } 244 | expected = "Unable to handle response (status code 403): ``" 245 | if err.Error() != expected { 246 | t.Fatalf("Rendered error string `%s`, expected `%s`", err.Error(), expected) 247 | } 248 | if _, ok = err.(RateLimitError); ok { 249 | t.Fatalf("Error should not be castable to a RateLimitError error") 250 | } 251 | if rerr, ok = err.(ResponseError); !ok { 252 | t.Fatalf("Error should be castable to a ResponseError") 253 | } 254 | if rerr.Code != 403 { 255 | t.Errorf("ResponseError code should be 403, got %d", rerr.Code) 256 | } 257 | if rerr.Body != `` { 258 | t.Errorf("ResponseError body should be ``, got `%s`", rerr.Body) 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /twittergo.go: -------------------------------------------------------------------------------- 1 | // Copyright 2011 Arne Roomann-Kurrik 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Implements a Twitter client library in Go. 16 | package twittergo 17 | 18 | import ( 19 | "bytes" 20 | "crypto/tls" 21 | "encoding/base64" 22 | "encoding/json" 23 | "fmt" 24 | "github.com/kurrik/oauth1a" 25 | "io/ioutil" 26 | "log" 27 | "net/http" 28 | "net/url" 29 | "os" 30 | "strings" 31 | ) 32 | 33 | // Implements a Twitter client. 34 | type Client struct { 35 | Host string 36 | OAuth *oauth1a.Service 37 | User *oauth1a.UserConfig 38 | AppToken *BearerToken 39 | HttpClient *http.Client 40 | } 41 | 42 | type BearerToken struct { 43 | AccessToken string 44 | } 45 | 46 | func getEnvEitherCase(k string) string { 47 | if v := os.Getenv(strings.ToUpper(k)); v != "" { 48 | return v 49 | } 50 | return os.Getenv(strings.ToLower(k)) 51 | } 52 | 53 | // Creates a new Twitter client with the supplied OAuth configuration. 54 | // Supports the use of HTTP proxies through the $HTTP_PROXY env var. 55 | // For example: 56 | // export HTTP_PROXY=http://localhost:8888 57 | // 58 | // When using a proxy, disable TLS certificate verification with the following: 59 | // export TLS_INSECURE=1 60 | func NewClient(config *oauth1a.ClientConfig, user *oauth1a.UserConfig) *Client { 61 | var ( 62 | host = "api.twitter.com" 63 | base = "https://" + host 64 | req, _ = http.NewRequest("GET", "https://api.twitter.com", nil) 65 | proxy, _ = http.ProxyFromEnvironment(req) 66 | transport *http.Transport 67 | tlsconfig *tls.Config 68 | ) 69 | if proxy != nil { 70 | tlsconfig = &tls.Config{ 71 | InsecureSkipVerify: getEnvEitherCase("TLS_INSECURE") != "", 72 | } 73 | if tlsconfig.InsecureSkipVerify { 74 | log.Printf("WARNING: SSL cert verification disabled\n") 75 | } 76 | transport = &http.Transport{ 77 | Proxy: http.ProxyURL(proxy), 78 | TLSClientConfig: tlsconfig, 79 | } 80 | } else { 81 | transport = &http.Transport{} 82 | } 83 | return &Client{ 84 | Host: host, 85 | HttpClient: &http.Client{ 86 | Transport: transport, 87 | }, 88 | User: user, 89 | AppToken: nil, 90 | OAuth: &oauth1a.Service{ 91 | RequestURL: base + "/oauth/request_token", 92 | AuthorizeURL: base + "/oauth/authorize", 93 | AccessURL: base + "/oauth/access_token", 94 | ClientConfig: config, 95 | Signer: new(oauth1a.HmacSha1Signer), 96 | }, 97 | } 98 | } 99 | 100 | // Changes the user authorization credentials for this client. 101 | func (c *Client) SetUser(user *oauth1a.UserConfig) { 102 | c.User = user 103 | } 104 | 105 | // Sets the app-only auth token to the specified string. 106 | func (c *Client) SetAppToken(token string) { 107 | c.AppToken = &BearerToken{ 108 | AccessToken: token, 109 | } 110 | } 111 | 112 | // Returns the current app-only auth token or an empty string. 113 | // Must call FetchAppToken to populate this value before getting it. 114 | // You may call SetAppToken with the value returned by this call in order 115 | // to restore a previously-fetched bearer token to use. 116 | func (c *Client) GetAppToken() string { 117 | if c.AppToken == nil { 118 | return "" 119 | } 120 | return c.AppToken.AccessToken 121 | } 122 | 123 | // Requests a new app auth bearer token and stores it. 124 | func (c *Client) FetchAppToken() (err error) { 125 | var ( 126 | req *http.Request 127 | resp *http.Response 128 | rb []byte 129 | rj = map[string]interface{}{} 130 | url = fmt.Sprintf("https://%v/oauth2/token", c.Host) 131 | ct = "application/x-www-form-urlencoded;charset=UTF-8" 132 | body = "grant_type=client_credentials" 133 | ek = oauth1a.Rfc3986Escape(c.OAuth.ClientConfig.ConsumerKey) 134 | es = oauth1a.Rfc3986Escape(c.OAuth.ClientConfig.ConsumerSecret) 135 | cred = fmt.Sprintf("%v:%v", ek, es) 136 | ec = base64.StdEncoding.EncodeToString([]byte(cred)) 137 | h = fmt.Sprintf("Basic %v", ec) 138 | ) 139 | req, err = http.NewRequest("POST", url, bytes.NewBufferString(body)) 140 | if err != nil { 141 | return 142 | } 143 | req.Header.Set("Authorization", h) 144 | req.Header.Set("Content-Type", ct) 145 | if resp, err = c.HttpClient.Do(req); err != nil { 146 | return 147 | } 148 | if resp.StatusCode != 200 { 149 | err = fmt.Errorf("Got HTTP %v instead of 200", resp.StatusCode) 150 | return 151 | } 152 | if rb, err = ioutil.ReadAll(resp.Body); err != nil { 153 | return 154 | } 155 | if err = json.Unmarshal(rb, &rj); err != nil { 156 | return 157 | } 158 | var ( 159 | token_type = rj["token_type"].(string) 160 | access_token = rj["access_token"].(string) 161 | ) 162 | if token_type != "bearer" { 163 | err = fmt.Errorf("Got invalid token type: %v", token_type) 164 | } 165 | c.SetAppToken(access_token) 166 | return nil 167 | } 168 | 169 | // Signs the request with app-only auth, fetching a bearer token if needed. 170 | func (c *Client) Sign(req *http.Request) (err error) { 171 | if c.AppToken == nil { 172 | if err = c.FetchAppToken(); err != nil { 173 | return 174 | } 175 | } 176 | var ( 177 | h = fmt.Sprintf("Bearer %v", c.AppToken.AccessToken) 178 | ) 179 | req.Header.Set("Authorization", h) 180 | return 181 | } 182 | 183 | // Sends a HTTP request through this instance's HTTP client. 184 | func (c *Client) SendRequest(req *http.Request) (resp *APIResponse, err error) { 185 | u := req.URL.String() 186 | if !strings.HasPrefix(u, "http") { 187 | u = fmt.Sprintf("https://%v%v", c.Host, u) 188 | req.URL, err = url.Parse(u) 189 | if err != nil { 190 | return 191 | } 192 | } 193 | if c.User != nil { 194 | c.OAuth.Sign(req, c.User) 195 | } else { 196 | if err = c.Sign(req); err != nil { 197 | return 198 | } 199 | } 200 | var r *http.Response 201 | r, err = c.HttpClient.Do(req) 202 | resp = (*APIResponse)(r) 203 | return 204 | } 205 | --------------------------------------------------------------------------------