├── .gitignore ├── Cargo.toml ├── LICENSE.txt ├── README.md ├── examples ├── installed_app_auth.rs ├── pm.rs ├── script_auth.rs └── stream.rs ├── rustfmt.toml └── src ├── app ├── account.rs ├── auth.rs ├── links.rs ├── listings.rs ├── messages.rs ├── mod.rs └── users.rs ├── data ├── comments.rs ├── listing.rs ├── mod.rs ├── post.rs ├── sub.rs ├── thing.rs └── user.rs ├── errors.rs ├── lib.rs ├── net ├── auth.rs └── mod.rs └── test.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | **/*.rs.bk 3 | Cargo.lock 4 | .idea/ 5 | .vscode/ 6 | design.txt 7 | test -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "orca" 3 | version = "0.7.0" 4 | authors = ["Benny A "] 5 | description = "A Reddit API client for Rust" 6 | readme = "README.md" 7 | documentation = "https://docs.rs/orca" 8 | repository = "https://github.com/IntrepidPig/orca" 9 | license = "MPL-2.0" 10 | keywords = ["client", "api", "reddit"] 11 | 12 | [dependencies] 13 | chrono = "0.4" 14 | serde = "1.0" 15 | serde_json = "1.0" 16 | failure = "0.1" 17 | failure_derive = "0.1" 18 | open = "1.2" 19 | url = "1.6" 20 | rand = "0.3" 21 | hyper = "0.12" 22 | futures = "0.1" 23 | tokio-core = "0.1" 24 | hyper-tls = "0.3" 25 | log = "0.3" 26 | base64 = "0.10" 27 | 28 | [dev-dependencies] 29 | env_logger = "0.4" 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # orca 2 | A simple reddit API interface for Rust 3 | 4 | ### Features Implemented: 5 | - Comment streams from entire subreddits 6 | - Comment tree traversing 7 | - Oauth script authorization 8 | - Oauth installed app authorization 9 | - Self post submissions 10 | - User info 11 | - Comment data structure 12 | - Listing data structure 13 | - Comment submissions 14 | - Automatic ratelimiting (steady and burst) 15 | - Failure for error handling 16 | 17 | ### Features Todo (nonexhaustive): 18 | - All data structures, or maybe pure json. Consistency is the goal. 19 | - More reddit api implementation 20 | - More complete error handling 21 | 22 | 23 | ### Contributing 24 | If you've ever made a pull request on github before, you probably know more about it than me. I would really appreciate any help on this project, so if you have an idea on how to improve it, please feel free to submit an issue or pull request. 25 | 26 | ### Example: Recursively traversing a comment tree 27 | ```rust 28 | fn print_tree(listing: Listing, level: i32) { 29 | for comment in listing { 30 | for _ in 0..level { 31 | print!("\t"); 32 | } 33 | println!("Comment by {}", comment.author); 34 | print_tree(comment.replies, level + 1); 35 | } 36 | } 37 | 38 | print_tree(tree, 0); 39 | ``` 40 | 41 | ### Example: Authorizing as OAuth Script type 42 | ```rust 43 | let mut app = App::new("appnamehere", "v0.1.0", "/u/usernamehere/").unwrap(); 44 | app.authorize_script(id, secret, username, password).unwrap(); 45 | ``` 46 | 47 | In order to send HTTPS requests, this library depends on openssl-sys, which requires openssl development libraries to be installed on your system to build the project. Instructions detailing this process can be found [here](https://github.com/sfackler/rust-openssl) 48 | 49 | > Generic Notice: This is an unstable project yadda yadda yadda use it if you dare thanks 50 | -------------------------------------------------------------------------------- /examples/installed_app_auth.rs: -------------------------------------------------------------------------------- 1 | //! This example shows authorizing as an installed app to retrieve info about the user authorized. 2 | //! 3 | //! This example requires registering the app as an installed app at [Reddit](https://www.reddit.com/prefs/apps) 4 | 5 | extern crate hyper; 6 | extern crate orca; 7 | 8 | use orca::{App, InstalledAppError, ResponseGenFn, Scopes}; 9 | 10 | use hyper::{Body, Response}; 11 | 12 | fn input(query: &str) -> String { 13 | use std::io::Write; 14 | let stdin = std::io::stdin(); 15 | print!("{}", query); 16 | std::io::stdout().flush().unwrap(); 17 | let mut input = String::new(); 18 | stdin.read_line(&mut input).unwrap(); 19 | input.trim().to_string() 20 | } 21 | 22 | fn main() { 23 | println!("Please enter the requested information"); 24 | let id = input("App id: "); 25 | let redirect = input("Redirect URI: "); 26 | // If you don't want to deal with custom response generation you can just set this to None to have simple defaults 27 | let response_gen: Option> = Some(std::sync::Arc::new(|result| match result { 28 | Ok(_code) => { 29 | println!("Authorized successfully"); 30 | Response::new(Body::from("Congratulations! You authorized successfully")) 31 | } 32 | Err(_e) => { 33 | println!("Authorization error"); 34 | Response::new(Body::from("Sorry! There was an error with the authorization.")) 35 | } 36 | })); 37 | let scopes = Scopes::all(); 38 | 39 | let mut reddit = App::new("orca_installed_app_example", "1.0", "/u/IntrepidPig").unwrap(); 40 | reddit.authorize_installed_app(&id, &redirect, response_gen, &scopes).unwrap(); 41 | 42 | let user = reddit.get_self().unwrap(); 43 | println!("Got data: {}", user); 44 | } 45 | -------------------------------------------------------------------------------- /examples/pm.rs: -------------------------------------------------------------------------------- 1 | //! This example lets you PM someone from the command line. It also requires setting up your own script 2 | //! app at [Reddit](https://www.reddit.com/prefs/apps). This one loads the variables from the environment. 3 | 4 | extern crate orca; 5 | 6 | use orca::App; 7 | 8 | fn get_client_data() -> (String, String) { 9 | use std::env; 10 | let id = env::var("ORCA_CLIENT_ID").expect("ORCA_CLIENT_ID must be set"); 11 | let secret = env::var("ORCA_CLIENT_SECRET").expect("ORCA_CLIENT_SECRET must be set"); 12 | (id, secret) 13 | } 14 | 15 | fn input(query: &str) -> String { 16 | use std::io::Write; 17 | let stdin = std::io::stdin(); 18 | print!("{}", query); 19 | std::io::stdout().flush().unwrap(); 20 | let mut input = String::new(); 21 | stdin.read_line(&mut input).unwrap(); 22 | input.trim().to_string() 23 | } 24 | 25 | fn main() { 26 | let (id, secret) = get_client_data(); 27 | println!("Please log in."); 28 | let username = input("Username: "); 29 | let password = input("Password: "); 30 | 31 | let mut reddit = App::new("orca_pm_example", "1.0", "/u/IntrepidPig").unwrap(); 32 | reddit.authorize_script(&id, &secret, &username, &password).unwrap(); 33 | 34 | println!("Please enter the details of the message."); 35 | let user = input("To: "); 36 | let subject = input("Subject: "); 37 | let message = input("Message: "); 38 | 39 | reddit.message(&user, &subject, &message).unwrap(); 40 | } 41 | -------------------------------------------------------------------------------- /examples/script_auth.rs: -------------------------------------------------------------------------------- 1 | //! This example shows authorizing as a script to retrieve info about the user authorized. 2 | //! 3 | //! This example requires registering the app as a script at [Reddit](https://www.reddit.com/prefs/apps) 4 | 5 | extern crate orca; 6 | 7 | use orca::App; 8 | 9 | fn input(query: &str) -> String { 10 | use std::io::Write; 11 | let stdin = std::io::stdin(); 12 | print!("{}", query); 13 | std::io::stdout().flush().unwrap(); 14 | let mut input = String::new(); 15 | stdin.read_line(&mut input).unwrap(); 16 | input.trim().to_string() 17 | } 18 | 19 | fn main() { 20 | println!("Please enter the requested information"); 21 | let username = input("Username: "); 22 | let password = input("Password: "); 23 | let id = input("Client id: "); 24 | let secret = input("Client secret: "); 25 | 26 | let mut reddit = App::new("orca_script_example", "1.0", "/u/IntrepidPig").unwrap(); 27 | reddit.authorize_script(&id, &secret, &username, &password).unwrap(); 28 | 29 | let user = reddit.get_self().unwrap(); 30 | println!("Got data: {}", user); 31 | } 32 | -------------------------------------------------------------------------------- /examples/stream.rs: -------------------------------------------------------------------------------- 1 | //! This example is the processing of a stream of every comment submitted to Reddit in real time. 2 | 3 | extern crate orca; 4 | 5 | use orca::App; 6 | 7 | fn main() { 8 | let reddit = App::new("orca_stream_example", "1.0", "/u/IntrepidPig").unwrap(); 9 | 10 | for comment in reddit.create_comment_stream("all") { 11 | println!("{}: {}\n", comment.author, comment.body); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 300 2 | tab_spaces = 4 3 | hard_tabs = true 4 | -------------------------------------------------------------------------------- /src/app/account.rs: -------------------------------------------------------------------------------- 1 | use failure::Error; 2 | use hyper::{Body, Request}; 3 | use json::Value; 4 | 5 | use App; 6 | 7 | impl App { 8 | /// Get info of the user currently authorized 9 | /// 10 | /// Note: requires connection to be authorized 11 | /// # Returns 12 | /// A result with the json value of the user data 13 | pub fn get_self(&self) -> Result { 14 | let req = Request::get("https://oauth.reddit.com/api/v1/me/.json").body(Body::empty()).unwrap(); 15 | 16 | self.conn.run_auth_request(req) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/auth.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use net::auth::OAuth; 4 | use {App, ResponseGenFn, Scopes}; 5 | 6 | use failure::Error; 7 | 8 | impl App { 9 | /// Authorize this app as a script 10 | /// # Arguments 11 | /// * `id` - The app id registered on Reddit 12 | /// * `secret` - The app secret registered on Reddit 13 | /// * `username` - The username of the user to authorize as 14 | /// * `password` - The password of the user to authorize as 15 | pub fn authorize_script(&mut self, id: &str, secret: &str, username: &str, password: &str) -> Result<(), Error> { 16 | let auth = OAuth::create_script(&self.conn, id, secret, username, password)?; 17 | self.conn.auth = Some(auth); 18 | Ok(()) 19 | } 20 | 21 | /// Authorize this app as an installed app 22 | /// # Arguments 23 | /// * `conn` - A reference to the connection to authorize 24 | /// * `id` - The app id registered on Reddit 25 | /// * `redirect` - The app redirect URI registered on Reddit 26 | /// * `response_gen` - An optional function that generates a hyper Response to give to the user 27 | /// based on the result of the authorization attempt. The signature is `(Result Result`. 28 | /// The result passed in is either Ok with the code recieved, or Err with the error that occurred. 29 | /// The value returned should usually be an Ok(Response), but you can return Err(Response) to indicate 30 | /// that an error occurred within the function. 31 | /// * `scopes` - A reference to a Scopes instance representing the capabilites you are requesting 32 | /// as an installed app. 33 | pub fn authorize_installed_app>>>(&mut self, id: &str, redirect: &str, response_gen: I, scopes: &Scopes) -> Result<(), Error> { 34 | let auth = OAuth::create_installed_app(&self.conn, id, redirect, response_gen, scopes)?; 35 | self.conn.auth = Some(auth); 36 | Ok(()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/links.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, VecDeque}; 2 | 3 | use failure::Error; 4 | use hyper::Request; 5 | use json::Value; 6 | use url::form_urlencoded; 7 | 8 | use data::{Comment, Listing}; 9 | use net::body_from_map; 10 | use {App, RedditError}; 11 | 12 | impl App { 13 | /// Comment on a thing. The `thing` can be a post, a comment, or a private message 14 | /// # Arguments 15 | /// * `text` - The body of the comment 16 | /// * `thing` - Fullname of the thing to comment on 17 | pub fn comment(&self, text: &str, thing: &str) -> Result<(), Error> { 18 | let text: String = form_urlencoded::byte_serialize(text.as_bytes()).collect(); 19 | let mut params: HashMap<&str, &str> = HashMap::new(); 20 | params.insert("text", &text); 21 | params.insert("thing_id", thing); 22 | 23 | let req = Request::post("https://oauth.reddit.com/api/comment").body(body_from_map(¶ms)).unwrap(); 24 | 25 | self.conn.run_auth_request(req)?; 26 | Ok(()) 27 | } 28 | 29 | /// Load more comments from a comment tree that is not completely loaded. This function at the moment can only be called 30 | /// internally due to requiring `morechildren_id` that is not available in the `Thread` type. 31 | /// # Arguments 32 | /// * `link_id` - The id of the post that has the comments that are being loaded 33 | /// * `morechildren_id` - The id of the morechildren object that is being loaded 34 | /// * `comments` - Slice of `&str`s that are the ids of the comments to be loaded 35 | pub fn more_children(&self, link_id: &str, morechildren_id: &str, comments: &[&str]) -> Result, Error> { 36 | let mut string = String::from("t3_"); 37 | let link_id = if !link_id.starts_with("t3_") { 38 | string.push_str(link_id); 39 | &string 40 | } else { 41 | link_id 42 | }; 43 | 44 | let limit = 5; 45 | // Break requests into chunks of `limit` 46 | let mut chunks: Vec = Vec::new(); 47 | let mut chunk_buf = String::new(); 48 | for (i, id) in comments.iter().enumerate() { 49 | if i != 0 && i % limit == 0 { 50 | chunk_buf.pop(); // Removes trailing comma 51 | chunks.push(chunk_buf); 52 | chunk_buf = String::new(); 53 | } 54 | 55 | chunk_buf.push_str(&format!("{},", id)); 56 | } 57 | chunk_buf.pop(); // Removes trailing comma on unfinished chunk 58 | chunks.push(chunk_buf); 59 | 60 | trace!("Chunks are {:?}", chunks); 61 | 62 | let mut lists = Vec::new(); 63 | 64 | for chunk in chunks { 65 | let mut params: HashMap<&str, &str> = HashMap::new(); 66 | params.insert("children", &chunk); 67 | params.insert("link_id", link_id); 68 | params.insert("id", morechildren_id); 69 | params.insert("api_type", "json"); 70 | 71 | trace!("Getting more children {} from {}", chunk, link_id); 72 | 73 | //let mut req = Request::new(Method::Get, Url::parse_with_params("https://www.reddit.com/api/morechildren/.json", params)?.into_string().parse()?); 74 | let req = Request::post("https://www.reddit.com/api/morechildren/.json").body(body_from_map(¶ms)).unwrap(); 75 | let data = self.conn.run_request(req)?; 76 | 77 | trace!("Scanning {}", data); 78 | 79 | let list: Listing = Listing::from_value(&data["json"]["data"]["things"], link_id, self)?; 80 | lists.push(list); 81 | } 82 | 83 | // Flatten the vec of listings 84 | let mut final_list = VecDeque::new(); 85 | for list in &mut lists { 86 | final_list.append(&mut list.children); 87 | } 88 | let mut listing: Listing = Listing::new(); 89 | 90 | for comment in final_list { 91 | listing.insert_comment(comment); 92 | } 93 | 94 | Ok(listing) 95 | } 96 | 97 | /// Sticky a post in a subreddit. Does nothing if the post is already stickied 98 | /// # Arguments 99 | /// * `sticky` - boolean value. True to set post as sticky, false to unset post as sticky 100 | /// * `slot` - Optional slot number to fill (can only be 1 or 2, and will error otherwise) 101 | /// * `id` - _fullname_ of the post to sticky 102 | pub fn set_sticky(&self, sticky: bool, slot: Option, id: &str) -> Result<(), Error> { 103 | let numstr; 104 | let mut params: HashMap<&str, &str> = HashMap::new(); 105 | params.insert("state", if sticky { "1" } else { "0" }); 106 | 107 | if let Some(num) = slot { 108 | if num != 1 && num != 2 { 109 | return Err(Error::from(RedditError::BadRequest { 110 | request: "Sticky's are limited to slots 1 and 2".to_string(), 111 | response: "not sent".to_string(), 112 | })); 113 | } 114 | numstr = num.to_string(); 115 | params.insert("num", &numstr); 116 | } 117 | 118 | params.insert("id", id); 119 | 120 | let req = Request::post("https://oauth.reddit.com/api/set_subreddit_sticky/.json").body(body_from_map(¶ms)).unwrap(); 121 | 122 | self.conn.run_auth_request(req).ok(); 123 | 124 | Ok(()) 125 | } 126 | 127 | /// Submit a self post 128 | /// # Arguments 129 | /// * `sub` - Name of the subreddit to submit a post to 130 | /// * `title` - Title of the post 131 | /// * `text` - Body of the post 132 | /// * `sendreplies` - Whether replies should be forwarded to the inbox of the submitter 133 | /// # Returns 134 | /// A result with reddit's json response to the submission 135 | pub fn submit_self(&self, sub: &str, title: &str, text: &str, sendreplies: bool) -> Result { 136 | let title: String = form_urlencoded::byte_serialize(title.as_bytes()).collect(); 137 | let text: String = form_urlencoded::byte_serialize(text.as_bytes()).collect(); 138 | let mut params: HashMap<&str, &str> = HashMap::new(); 139 | params.insert("sr", sub); 140 | params.insert("kind", "self"); 141 | params.insert("title", &title); 142 | params.insert("text", &text); 143 | params.insert("sendreplies", if sendreplies { "true" } else { "false" }); 144 | 145 | let req = Request::post("https://oauth.reddit.com/api/submit/.json").body(body_from_map(¶ms)).unwrap(); 146 | 147 | self.conn.run_auth_request(req) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/app/listings.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use failure::Error; 4 | use hyper::{Body, Request}; 5 | use json::Value; 6 | use url::Url; 7 | 8 | use data::{Comment, Comments, Listing, Post, Thing}; 9 | use net::{body_from_map, uri_params_from_map}; 10 | use {App, Sort}; 11 | 12 | impl App { 13 | /// Loads a thing and casts it to the type of anything as long as it implements the Thing trait. Experimental 14 | /// # Arguments 15 | /// * `fullame` - fullname of the thing 16 | pub fn load_post(&self, fullname: &str) -> Result { 17 | let mut params: HashMap<&str, &str> = HashMap::new(); 18 | params.insert("names", fullname); 19 | 20 | let req = Request::get(format!("https://www.reddit.com/by_id/{}/.json", fullname)).body(Body::empty()).unwrap(); 21 | let response = self.conn.run_request(req)?; 22 | 23 | Post::from_value(&response, self) 24 | } 25 | 26 | /// Get the posts in a subreddit sorted in a specific way 27 | /// # Arguments 28 | /// * `sub` - Name of subreddit to query 29 | /// * `sort` - Sort method of query 30 | /// # Returns 31 | /// A result containing a json listing of posts 32 | pub fn get_posts(&self, sub: &str, sort: Sort) -> Result { 33 | let req = Request::get( 34 | Url::parse_with_params( 35 | &format!( 36 | "https://www.reddit.com/r/{}/.\ 37 | json", 38 | sub 39 | ), 40 | sort.param(), 41 | )? 42 | .into_string(), 43 | ) 44 | .body(Body::empty()) 45 | .unwrap(); 46 | 47 | self.conn.run_request(req) 48 | } 49 | 50 | /// Get a iterator of all comments in order of being posted 51 | /// # Arguments 52 | /// * `sub` - Name of the subreddit to pull comments from. Can be 'all' to pull from all of reddit 53 | pub fn create_comment_stream(&self, sub: &str) -> Comments { 54 | Comments::new(self, sub) 55 | } 56 | 57 | /// Gets the most recent comments in a subreddit. This function is also usually called internally but 58 | /// can be called if a one time retrieval of recent comments from a subreddit is necessary 59 | /// # Arguments 60 | /// * `sub` - Subreddit to load recent comments from 61 | /// * `limit` - Optional limit to amount of comments loaded 62 | /// * `before` - Optional comment to be the starting point for the next comments loaded 63 | /// # Returns 64 | /// A listing of comments that should be flat (no replies) 65 | pub fn get_recent_comments(&self, sub: &str, limit: Option, before: Option<&str>) -> Result, Error> { 66 | let limit_str; 67 | let mut params: HashMap<&str, &str> = HashMap::new(); 68 | if let Some(limit) = limit { 69 | limit_str = limit.to_string(); 70 | params.insert("limit", &limit_str); 71 | } 72 | if let Some(ref before) = before { 73 | params.insert("before", before); 74 | } 75 | 76 | let req = Request::get(uri_params_from_map(&format!("https://www.reddit.com/r/{}/comments.json", sub), ¶ms)?).body(Body::empty()).unwrap(); 77 | 78 | let resp = self.conn.run_request(req)?; 79 | let comments = Listing::from_value(&resp["data"]["children"], "", self)?; 80 | 81 | Ok(comments) 82 | } 83 | 84 | /// Loads the comment tree of a post, returning a listing of the Comment enum, which can be 85 | /// either Loaded or NotLoaded 86 | /// # Arguments 87 | /// * `post` - The name of the post to retrieve the tree from 88 | /// # Returns 89 | /// A fully populated listing of commments (no `more` values) 90 | pub fn get_comment_tree(&self, post: &str) -> Result, Error> { 91 | // TODO add sorting and shit 92 | 93 | let mut params: HashMap<&str, &str> = HashMap::new(); 94 | params.insert("limit", "2147483648"); 95 | params.insert("depth", "2147483648"); 96 | let req = Request::get(format!("https://www.reddit.com/comments/{}/.json", post)).body(body_from_map(¶ms)).unwrap(); 97 | 98 | let data = self.conn.run_request(req)?; 99 | let data = data[1]["data"]["children"].clone(); 100 | 101 | Listing::from_value(&data, post, self) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/app/messages.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use failure::Error; 4 | use hyper::Request; 5 | use url::form_urlencoded; 6 | 7 | use net::body_from_map; 8 | use App; 9 | 10 | impl App { 11 | /// Send a private message to a user 12 | /// # Arguments 13 | /// * `to` - Name of the user to send a message to 14 | /// * `subject` - Subject of the message 15 | /// * `body` - Body of the message 16 | pub fn message(&self, to: &str, subject: &str, body: &str) -> Result<(), Error> { 17 | let subject: String = form_urlencoded::byte_serialize(subject.as_bytes()).collect(); 18 | let body: String = form_urlencoded::byte_serialize(body.as_bytes()).collect(); 19 | let mut params: HashMap<&str, &str> = HashMap::new(); 20 | params.insert("to", to); 21 | params.insert("subject", &subject); 22 | params.insert("text", &body); 23 | 24 | let req = Request::post("https://oauth.reddit.com/api/compose/.json").body(body_from_map(¶ms)).unwrap(); 25 | 26 | match self.conn.run_auth_request(req) { 27 | Ok(_) => Ok(()), 28 | Err(e) => Err(e), 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | mod account; 2 | mod auth; 3 | mod links; 4 | mod listings; 5 | mod messages; 6 | mod users; 7 | 8 | use failure::Error; 9 | 10 | use net::{Connection, LimitMethod}; 11 | 12 | /// A reddit object 13 | /// ## Usage: 14 | /// To create a new instance, use `Reddit::new()` 15 | pub struct App { 16 | pub(crate) conn: Connection, 17 | } 18 | 19 | impl App { 20 | /// Create a new reddit instance 21 | /// # Arguments 22 | /// * `appname` - Unique app name 23 | /// * `appversion` - App version 24 | /// * `appauthor` - Auther of the app 25 | /// # Returns 26 | /// A new reddit object 27 | pub fn new(appname: &str, appversion: &str, appauthor: &str) -> Result { 28 | Ok(App { conn: Connection::new(appname, appversion, appauthor)? }) 29 | } 30 | 31 | /// Sets the method to use for ratelimiting. 32 | /// # Arguments 33 | /// * `limit` - The method to use for ratelimiting 34 | pub fn set_ratelimiting(&self, limit: LimitMethod) { 35 | self.conn.set_limit(limit); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/users.rs: -------------------------------------------------------------------------------- 1 | use failure::Error; 2 | use hyper::{Body, Request}; 3 | use json::Value; 4 | 5 | use App; 6 | 7 | impl App { 8 | /// Gets information about a user that is not currently authorized 9 | /// # Arguments 10 | /// * `name` - username of the user to query 11 | /// # Returns 12 | /// A json value containing the user info 13 | pub fn get_user(&self, name: &str) -> Result { 14 | let req = Request::get(format!("https://www.reddit.com/user/{}/about/.json", name)).body(Body::empty()).unwrap(); 15 | 16 | self.conn.run_request(req) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/data/comments.rs: -------------------------------------------------------------------------------- 1 | use json; 2 | use json::Value; 3 | 4 | use data::{Listing, Thing}; 5 | use errors::ParseError; 6 | use failure::{err_msg, Error}; 7 | use App; 8 | 9 | /// An enum representing a thread which can either be a comment or a more object that represents 10 | /// a list of comments that have not yet been loaded. 11 | #[derive(Debug, Clone)] 12 | pub enum Thread { 13 | /// A comment 14 | Comment(Box), 15 | /// A vector of strings that are the ids of comments that need to be loaded 16 | More(Vec), 17 | } 18 | 19 | /// A struct representing a reddit comment. 20 | /// Does not contain all fields possible in a comment yet. 21 | #[derive(Debug, Clone)] 22 | pub struct Comment { 23 | /// The amount of seconds since the comment has been edited, if it has been. 24 | pub edited: Option, 25 | /// The id of the comment 26 | pub id: String, 27 | /// The id of the comments parent, can be either t1 or t3 28 | pub parent_id: String, 29 | /// The link that the comment is present in 30 | pub link_id: String, 31 | /// The username of the author of the comment 32 | pub author: String, 33 | /// The amount of upvotes the comment has recieved 34 | pub ups: i64, 35 | /// The amount of downvotes the comment has recieved 36 | pub downs: i64, 37 | /// The score of the comment (ups - downs) 38 | pub score: i64, 39 | /// The text of the comment 40 | pub body: String, 41 | /// Whether the comment was submitted by the same user that submitted the post 42 | /// (the author is OP or not) 43 | pub is_submitter: bool, 44 | /// Whether the comment is stickied in the thread or not 45 | pub stickied: bool, 46 | /// The subreddit the comment was posted in 47 | pub subreddit: String, 48 | /// Whether the score of the comment is hidden 49 | pub score_hidden: bool, 50 | /// The fullname of the comment (includes the t1_ prefix) 51 | pub name: String, 52 | /// A listing of replies to this comment 53 | pub replies: Listing, 54 | } 55 | 56 | impl Thing for Comment { 57 | fn from_value(val: &Value, app: &App) -> Result { 58 | // nice 59 | macro_rules! out { 60 | ($val:ident) => { 61 | return Err(Error::from(ParseError { 62 | thing_type: "Comment".to_string(), 63 | json: json::to_string_pretty($val).unwrap(), 64 | })); 65 | }; 66 | } 67 | 68 | let val = &val["data"]; 69 | let edited = match val["edited"] { 70 | Value::Bool(_) | Value::Null => None, 71 | Value::Number(ref num) => num.as_f64(), 72 | _ => return Err(format_err!("Unexpected value for \"edited\": {}", val["edited"])), 73 | }; 74 | let id: String = match val["id"].as_str() { 75 | Some(t) => t.to_string(), 76 | None => out!(val), 77 | }; 78 | let parent_id: String = match val["parent_id"].as_str() { 79 | Some(t) => t.to_string(), 80 | None => out!(val), 81 | }; 82 | let link_id: String = match val["link_id"].as_str() { 83 | Some(t) => t.to_string(), 84 | None => out!(val), 85 | }; 86 | let author: String = match val["author"].as_str() { 87 | Some(t) => t.to_string(), 88 | None => out!(val), 89 | }; 90 | let ups: i64 = match val["ups"].as_i64() { 91 | Some(t) => t, 92 | None => out!(val), 93 | }; 94 | let downs: i64 = match val["downs"].as_i64() { 95 | Some(t) => t, 96 | None => out!(val), 97 | }; 98 | let score: i64 = match val["score"].as_i64() { 99 | Some(t) => t, 100 | None => out!(val), 101 | }; 102 | let body: String = match val["body"].as_str() { 103 | Some(t) => t.to_string(), 104 | None => out!(val), 105 | }; 106 | let is_submitter: bool = match val["is_submitter"].as_bool() { 107 | Some(t) => t, 108 | None => out!(val), 109 | }; 110 | let stickied: bool = match val["stickied"].as_bool() { 111 | Some(t) => t, 112 | None => out!(val), 113 | }; 114 | let subreddit: String = match val["subreddit"].as_str() { 115 | Some(t) => t.to_string(), 116 | None => out!(val), 117 | }; 118 | let score_hidden: bool = match val["score_hidden"].as_bool() { 119 | Some(t) => t, 120 | None => out!(val), 121 | }; 122 | let name: String = match val["name"].as_str() { 123 | Some(t) => t.to_string(), 124 | None => out!(val), 125 | }; 126 | let replies: Listing = match val["replies"] { 127 | Value::String(_) => Listing::new(), 128 | Value::Object(_) => Listing::from_value(&val["replies"]["data"]["children"], &link_id, app).unwrap(), 129 | _ => return Err(err_msg(format!("Unexpected value for \"replies\": {}", val["replies"]))), 130 | }; 131 | 132 | Ok(Comment { 133 | edited, 134 | id, 135 | parent_id, 136 | link_id, 137 | author, 138 | ups, 139 | downs, 140 | score, 141 | body, 142 | is_submitter, 143 | stickied, 144 | subreddit, 145 | score_hidden, 146 | name, 147 | replies, 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/data/listing.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | use std::default::Default; 3 | 4 | use json; 5 | use json::Value; 6 | 7 | use data::{Comment, Thing}; 8 | use App; 9 | 10 | use errors::ParseError; 11 | use failure::Error; 12 | 13 | /// A listing of Things. Has special implementations, currently just for Comments. 14 | #[derive(Debug, Clone)] 15 | pub struct Listing { 16 | /// The contents of the Listing 17 | pub children: VecDeque, 18 | } 19 | 20 | impl Listing { 21 | /// Creates a new empty listing 22 | pub fn new() -> Listing { 23 | Listing { children: VecDeque::new() } 24 | } 25 | } 26 | 27 | impl Default for Listing { 28 | fn default() -> Self { 29 | Self::new() 30 | } 31 | } 32 | 33 | impl Iterator for Listing { 34 | type Item = T; 35 | 36 | fn next(&mut self) -> Option { 37 | self.children.pop_front() 38 | } 39 | } 40 | 41 | impl Listing { 42 | /// Flatten this listing of comments (consumes the listing) 43 | pub fn traverse(self) -> Vec { 44 | let mut comments = Vec::new(); 45 | 46 | for comment in self.children { 47 | comments.push(comment.clone()); 48 | { 49 | comments.append(&mut comment.replies.traverse()); 50 | } 51 | } 52 | 53 | comments 54 | } 55 | 56 | fn insert_comment_recursive(&mut self, comment: &Comment) -> bool { 57 | // For each comment in this listing 58 | for c in &mut self.children { 59 | // Check if it's the parent of the comment to be inserted, and if so, insert the comment into the parent's replies 60 | if c.id == comment.parent_id[3..comment.parent_id.len()] { 61 | c.replies.children.push_back(comment.clone()); 62 | return true; 63 | // If not, try to insert it into the replies of the current comment (recursive) 64 | } else if c.replies.insert_comment_recursive(comment) { 65 | return true; 66 | } 67 | } 68 | 69 | // The comment was not in this listing 70 | false 71 | } 72 | 73 | /// Inserts a comment into a listing in it's correct place in the tree. 74 | pub fn insert_comment(&mut self, comment: Comment) { 75 | if !self.insert_comment_recursive(&comment) { 76 | self.children.push_back(comment); 77 | } 78 | } 79 | 80 | /// Parses the listing from json, fetching more comments as necessary. 81 | pub fn from_value(listing_data: &Value, post_id: &str, app: &App) -> Result, Error> { 82 | let mut listing: Listing = Listing::new(); 83 | 84 | if let Some(array) = listing_data.as_array() { 85 | for item in array { 86 | let kind = item["kind"].as_str().unwrap(); 87 | if kind == "t1" { 88 | listing.children.push_back(if let Ok(c) = Comment::from_value(item, app) { 89 | c 90 | } else { 91 | return Err(Error::from(ParseError { 92 | thing_type: "Listing".to_string(), 93 | json: json::to_string_pretty(listing_data).unwrap(), 94 | })); 95 | }); 96 | } else if kind == "more" { 97 | let more = item["data"]["children"].as_array().unwrap(); 98 | let more_id = item["data"]["id"].as_str().unwrap(); 99 | if !more.is_empty() { 100 | debug!("Need some children {}", json::to_string_pretty(more).unwrap()); 101 | let more = more.iter().map(|i| i.as_str().unwrap()).collect::>(); 102 | for child in app.more_children(post_id, more_id, &more)? { 103 | listing.children.push_back(child); 104 | } 105 | trace!("Successfully got children"); 106 | } 107 | } 108 | } 109 | 110 | Ok(listing) 111 | } else { 112 | Err(Error::from(ParseError { 113 | thing_type: "Listing".to_string(), 114 | json: json::to_string_pretty(listing_data).unwrap(), 115 | })) 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/data/mod.rs: -------------------------------------------------------------------------------- 1 | mod comments; 2 | mod listing; 3 | mod post; 4 | mod sub; 5 | mod thing; 6 | mod user; 7 | 8 | pub use self::comments::*; 9 | pub use self::listing::*; 10 | pub use self::post::*; 11 | pub use self::sub::*; 12 | pub use self::thing::*; 13 | pub use self::user::*; 14 | -------------------------------------------------------------------------------- /src/data/post.rs: -------------------------------------------------------------------------------- 1 | use data::{Comment, Listing, Thing}; 2 | use errors::ParseError; 3 | use failure::Error; 4 | use json::{self, Value}; 5 | use App; 6 | 7 | /// A struct that represents a submission to reddit 8 | #[derive(Debug)] 9 | pub struct Post { 10 | /// Id of the post 11 | pub id: String, 12 | /// Title of the post 13 | pub title: String, 14 | /// Author of the post 15 | pub author: String, 16 | /// Subreddit the post was made in 17 | pub subreddit: String, 18 | /// Number of upvotes the post has recieved 19 | pub ups: i64, 20 | /// Number of downvotes the post has recieved 21 | pub downs: i64, 22 | /// Total score of the post (ups - downs) 23 | pub score: i64, 24 | /// Number of comments on the post 25 | pub num_comments: i64, 26 | /// Url of the post 27 | pub url: String, 28 | /// Whether the post is stickied 29 | pub stickied: bool, 30 | /// Amount of times this post has been gilded 31 | pub gilded: i64, 32 | /// The comments on this post 33 | pub comments: Listing, 34 | } 35 | 36 | impl Thing for Post { 37 | fn from_value(val: &Value, app: &App) -> Result { 38 | let post = &val["data"]["children"][0]["data"]; 39 | 40 | macro_rules! out { 41 | ($val:ident) => { 42 | return Err(Error::from(ParseError { 43 | thing_type: "Post".to_string(), 44 | json: json::to_string_pretty($val).unwrap(), 45 | })); 46 | }; 47 | } 48 | 49 | let id = match post["id"].as_str() { 50 | Some(t) => t.to_string(), 51 | None => out!(val), 52 | }; 53 | let title = match post["title"].as_str() { 54 | Some(t) => t.to_string(), 55 | None => out!(val), 56 | }; 57 | let author = match post["author"].as_str() { 58 | Some(t) => t.to_string(), 59 | None => out!(val), 60 | }; 61 | let subreddit = match post["subreddit"].as_str() { 62 | Some(t) => t.to_string(), 63 | None => out!(val), 64 | }; 65 | let ups = match post["ups"].as_i64() { 66 | Some(t) => t, 67 | None => out!(val), 68 | }; 69 | let downs = match post["downs"].as_i64() { 70 | Some(t) => t, 71 | None => out!(val), 72 | }; 73 | let score = match post["score"].as_i64() { 74 | Some(t) => t, 75 | None => out!(val), 76 | }; 77 | let num_comments = match post["num_comments"].as_i64() { 78 | Some(t) => t, 79 | None => out!(val), 80 | }; 81 | let url = match post["url"].as_str() { 82 | Some(t) => t.to_string(), 83 | None => out!(val), 84 | }; 85 | let stickied = match post["stickied"].as_bool() { 86 | Some(t) => t, 87 | None => out!(val), 88 | }; 89 | let gilded = match post["gilded"].as_i64() { 90 | Some(t) => t, 91 | None => out!(val), 92 | }; 93 | let comments = app.get_comment_tree(&id)?; 94 | 95 | Ok(Post { 96 | id, 97 | title, 98 | author, 99 | subreddit, 100 | ups, 101 | downs, 102 | score, 103 | num_comments, 104 | url, 105 | stickied, 106 | gilded, 107 | comments, 108 | }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/data/sub.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use data::Comment; 4 | use App; 5 | 6 | /// A struct that represents a stream of comments from a subreddit as they are posted. To use it 7 | /// simply create a `for` loop with this is the source. It will automatically retrieve comments 8 | /// as needed. The subreddit can be `all` to create a stream of comments from all of reddit. 9 | pub struct Comments<'a> { 10 | sub: String, 11 | cache: VecDeque, 12 | last: Option, 13 | app: &'a App, 14 | } 15 | 16 | impl<'a> Comments<'a> { 17 | /// Creates a stream of comments from a subreddit 18 | /// # Arguments 19 | /// * `app` - A reference to a Reddit `App` instance 20 | /// * `sub` - The subreddit to load comments from. Can be "all" to stream comments from all 21 | /// of reddit. 22 | pub fn new(app: &'a App, sub: &str) -> Comments<'a> { 23 | let cache: VecDeque = VecDeque::new(); 24 | let last = None; 25 | 26 | Comments { sub: sub.to_string(), cache, last, app } 27 | } 28 | 29 | fn refresh(&mut self, app: &App) { 30 | let mut resp = app.get_recent_comments(&self.sub, Some(500), self.last.as_ref().map(|s| s.as_str())).expect("Could not get recent comments"); 31 | 32 | if let Some(comment) = resp.by_ref().peekable().peek() { 33 | self.last = Some(comment.name.clone()); 34 | } 35 | 36 | self.cache.append(&mut resp.children); 37 | } 38 | } 39 | 40 | impl<'a> Iterator for Comments<'a> { 41 | type Item = Comment; 42 | 43 | fn next(&mut self) -> Option { 44 | while self.cache.is_empty() { 45 | self.refresh(self.app); 46 | } 47 | self.cache.pop_front() 48 | } 49 | } 50 | 51 | /// Sort type of a subreddit 52 | pub enum Sort { 53 | /// Hot 54 | Hot, 55 | /// New 56 | New, 57 | /// Rising 58 | Rising, 59 | /// Top within the specified `SortTime` 60 | Top(SortTime), 61 | /// Most controversial within the specified `SortTime` 62 | Controversial(SortTime), 63 | } 64 | 65 | impl Sort { 66 | /// Convert to url parameters 67 | pub fn param<'a>(self) -> Vec<(&'a str, &'a str)> { 68 | use self::Sort::*; 69 | match self { 70 | Hot => vec![("sort", "hot")], 71 | New => vec![("sort", "new")], 72 | Rising => vec![("sort", "rising")], 73 | Top(sort) => vec![("sort", "top"), sort.param()], 74 | Controversial(sort) => vec![("sort", "controversial"), sort.param()], 75 | } 76 | } 77 | } 78 | 79 | /// Time parameter of a subreddit sort 80 | pub enum SortTime { 81 | /// Hour 82 | Hour, 83 | /// Day 84 | Day, 85 | /// Week 86 | Week, 87 | /// Month 88 | Month, 89 | /// Year 90 | Year, 91 | /// All time 92 | All, 93 | } 94 | 95 | impl SortTime { 96 | /// Convert the sort time to a tuple to be used in url parameters 97 | pub fn param<'a>(self) -> (&'a str, &'a str) { 98 | use self::SortTime::*; 99 | ( 100 | "t", 101 | match self { 102 | Hour => "hour", 103 | Day => "day", 104 | Week => "week", 105 | Month => "month", 106 | Year => "year", 107 | All => "all", 108 | }, 109 | ) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/data/thing.rs: -------------------------------------------------------------------------------- 1 | use failure::Error; 2 | use json; 3 | use App; 4 | 5 | /// A trait representing a reddit Thing that can be deserialized from JSON 6 | pub trait Thing { 7 | /// Parses the thing from json 8 | /// # Arguments 9 | /// * `data` - A reference to json data to be parsed 10 | /// * `app` - A reference to a reddit app. This is necessary in case more data is needed to be 11 | /// retrieved in order to completely parse the value 12 | fn from_value(data: &json::Value, app: &App) -> Result 13 | where 14 | Self: Sized; 15 | } 16 | -------------------------------------------------------------------------------- /src/data/user.rs: -------------------------------------------------------------------------------- 1 | /// Struct that represent's a user that could either be authorized or not 2 | pub enum User { 3 | /// An authorized user 4 | Authed(AuthUserData), 5 | /// An other user 6 | Other(UserData), 7 | } 8 | 9 | /// Data structure that represents the user that is currently authorized 10 | pub struct AuthUserData { 11 | /// Data that would be present even if the user wasn't present 12 | pub userdata: UserData, 13 | } 14 | 15 | /// Data structure that represents a user's info 16 | pub struct UserData { 17 | /// Comment karma of the user 18 | pub comment_karma: i64, 19 | /// The time the user was created in seconds 20 | pub created: f64, 21 | /// I DON't KNoowW 22 | pub created_utc: f64, 23 | /// also don't know 24 | pub has_subscribed: bool, 25 | /// Whether the user has verified their email 26 | pub has_verified_email: bool, 27 | /// Don't know 28 | pub hide_from_robots: bool, 29 | /// The id of the user 30 | pub id: String, 31 | /// Whether the user is a Reddit employee 32 | pub is_employee: bool, 33 | /// Whether the user is friend of the current user 34 | pub is_friend: bool, 35 | /// Whether the user has Reddit gold or not 36 | pub is_gold: bool, 37 | /// Whether the user is a moderator 38 | pub is_mod: bool, 39 | /// Link karma of the user 40 | pub link_karma: i64, 41 | /// The user's username 42 | pub name: String, 43 | } 44 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | /// An enum containing possible errors from a request to reddit 2 | #[derive(Debug, Fail)] 3 | pub enum RedditError { 4 | /// The requested resource was not found 5 | #[fail(display = "Requested resource {} was not found", request)] 6 | NotFound { 7 | /// The requested resource 8 | request: String, 9 | }, 10 | /// The requested resource is forbidden 11 | #[fail(display = "Requested resource {} is forbidden", request)] 12 | Forbidden { 13 | /// The requested resource 14 | request: String, 15 | }, 16 | /// Recieved a response that was unexpected 17 | #[fail(display = "\nSent request {}, got unexpected reponse {}\n", request, response)] 18 | BadResponse { 19 | /// The request that was sent 20 | request: String, 21 | /// The response that was recieved 22 | response: String, 23 | }, 24 | /// A request was sent that was incorrect 25 | #[fail(display = "\nAttempted incorrect request {} got response {}\n", request, response)] 26 | BadRequest { 27 | /// The request that was sent 28 | request: String, 29 | /// The response that was recieved 30 | response: String, 31 | }, 32 | /// Authorization failed 33 | #[fail(display = "Failed to authorize")] 34 | AuthError, 35 | } 36 | 37 | /// An error representing a json value that could not be parsed as a certain struct 38 | #[derive(Debug, Fail)] 39 | #[fail(display = "Could not parse json {} as {}\n", json, thing_type)] 40 | pub struct ParseError { 41 | /// The type the json was attempted to be parsed as 42 | pub thing_type: String, 43 | /// The json that was attempted to be parsed 44 | pub json: String, 45 | } 46 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | 3 | //! # orca 4 | //! orca is a library to make using the Reddit API from Rust easy 5 | //! 6 | //! ## Features 7 | //! orca has not yet implemented near all of the functionality available in the Reddit API, but 8 | //! enough has been implemented to make simple flexible scripts or apps. Some main functionality 9 | //! includes: 10 | //! 11 | //! * submitting self posts 12 | //! * automatic ratelimiting 13 | //! * commenting and replying 14 | //! * comment streams from subreddits 15 | //! * private messages 16 | //! * authorization as script or installed oauth app type 17 | //! * more stuff 18 | //! 19 | //! ## Structure 20 | //! All of the functionality necessary is available in the implementation of 21 | //! the `App` struct. Data structures are defined in `orca::data`. Networking code is present in 22 | //! the net module, which also contains OAuth authorization functionality. 23 | //! 24 | //! ## Usage 25 | //! To simply create a reddit app instance, do 26 | //! 27 | //! ```rust 28 | //! # use orca::App; 29 | //! # let (name, version, author) = ("a", "b", "c"); 30 | //! let mut reddit = App::new(name, version, author).unwrap(); 31 | //! ``` 32 | //! 33 | //! where `name`, `version`, and `author` are all `&str`s. 34 | //! 35 | //! This instance can do actions that don't require authorization, such as retrieving a stream of 36 | //! comments from a subreddit, but actions such as commenting require authorization, which can be 37 | //! done multiple ways. The most common way for clients to authorize is as scripts, which can be 38 | //! done by just providing a username and password as well as the id and secret of the app that can 39 | //! be registered on the desktop site. It looks like this in code (assuming you already have a 40 | //! mutable reddit instance): 41 | //! 42 | //! ```rust,no_run 43 | //! # use orca::App; 44 | //! # let mut reddit = App::new("a", "b", "c").unwrap(); 45 | //! # let (id, secret, username, password) = ("a", "b", "c", "d"); 46 | //! reddit.authorize_script(id, secret, username, password).unwrap(); 47 | //! ``` 48 | //! More info can be found in the documentation for the net module 49 | //! 50 | //! Actually doing something is simple and similar to previous examples. To get info about the 51 | //! currently authorized user, simply call 52 | //! 53 | //! ```rust,no_run 54 | //! # use orca::App; 55 | //! # let mut reddit = App::new("a", "b", "c").unwrap(); 56 | //! reddit.get_self(); 57 | //! ``` 58 | //! 59 | //! which will return a json value until the actual user data structure is implemented. 60 | //! 61 | 62 | extern crate chrono; 63 | #[macro_use] 64 | extern crate failure; 65 | #[macro_use] 66 | extern crate failure_derive; 67 | extern crate futures; 68 | extern crate hyper; 69 | extern crate hyper_tls; 70 | #[macro_use] 71 | extern crate log; 72 | extern crate base64; 73 | extern crate open; 74 | extern crate rand; 75 | extern crate serde; 76 | extern crate serde_json as json; 77 | extern crate tokio_core; 78 | extern crate url; 79 | 80 | #[cfg(test)] 81 | mod test; 82 | 83 | /// Functionality for communication with reddit.com 84 | pub mod net; 85 | 86 | /// Reddit data structures 87 | pub mod data; 88 | 89 | /// Errors 90 | pub mod errors; 91 | 92 | /// Main entry point 93 | pub mod app; 94 | 95 | pub use app::App; 96 | pub use data::{Sort, SortTime}; 97 | pub use errors::RedditError; 98 | pub use net::auth::{self, InstalledAppError, ResponseGenFn, Scopes}; 99 | pub use net::{Connection, LimitMethod}; 100 | -------------------------------------------------------------------------------- /src/net/auth.rs: -------------------------------------------------------------------------------- 1 | //! # Authorization 2 | //! Authorization for a Reddit client is done by OAuth, which can be done multiple (3) ways. The 3 | //! possible methods of authorization are Script, Installed App, and Web App. Currently, only 4 | //! the first two are supported by orca. There are certain use cases for each app type. 5 | //! 6 | //! ## Scripts 7 | //! 8 | //! Script apps are used when you only want to authorize one user, that you own. This is the app 9 | //! type used for bots. It's special because it can keep a secret (secrets can be stored on the 10 | //! with the client). To create a script app, you first have to register it at 11 | //! [https://www.reddit.com/prefs/apps](https://www.reddit.com/prefs/apps). Make sure you're logged 12 | //! in as the user you want the script to authorize as when you register the app. At the bottom of 13 | //! the page, click "Create New App", and fill in the name, select script type, enter a short 14 | //! description (that will only be seen by you), leave the about url empty, and set the redirect uri 15 | //! to `https://www.example.com`. (We do this because this field is only necessary for installed 16 | //! apps but is required to be filled in anyway.) 17 | //! 18 | //! Once you create the app, a box should pop up that has the name of your app, and then shortly 19 | //! below it a string of random characters. This is the id of the script. Then lower in the 20 | //! properties there should be a field called "secret" with another long string of characters. That 21 | //! is your app's secret. 22 | //! 23 | //! Once you have the id and secret, you can instantiate an `OAuthApp::Script` enum with the id and 24 | //! secret of the script and the username and password of the user that registered the app, and 25 | //! pass it into the `authorize` function of an `App` instance. 26 | //! 27 | //! ## Installed Apps 28 | //! 29 | //! Installed apps are used when you want your program to be able to be authorized as any user that 30 | //! is using it. They are unable to keep a secret, so it is more complicated to authorize them. 31 | //! An installed app has no secret id. Instead, it requires that the user visits a url to reddit.com 32 | //! containing info for authorization. After authorizing, reddit.com will redirect the web browser 33 | //! to the redirect uri specified during the app registration, with the tokens requested as 34 | //! parameters. The redirect uri is usually the loopback address with a custom port, and the app 35 | //! starts an HTTP server to recieve that request and the tokens included. 36 | //! 37 | //! Most of this work is implemented for you by orca. At the moment, there is some lacking in 38 | //! customizability, but that will hopefully change in the future. Currently, orca opens the 39 | //! reddit.com in the default browser using the `open` crate, and the redirect uri must always be 40 | //! 127.0.0.1:7878. 41 | //! 42 | //! To create an installed app, the process at first is similar to Script app types. Visit 43 | //! [https://www.reddit.com/prefs/apps](https://www.reddit.com/prefs/apps), and create a new app, 44 | //! this time with the installed type. Fill in the name, set it to installed app, fill in a short 45 | //! description (this time it's visible by anyone using your app), enter an about url if you want, 46 | //! and set the redirect uri to exactly `http://127.0.0.1:7878` (hopefully this will be customizable 47 | //! in the future). 48 | //! 49 | //! When you create this app, the id of the app will be shorly below the name in the box that comes 50 | //! upp. Now in you application code, create an `OAuthApp::InstalledApp` with the id of you app and 51 | //! the redirect uri exactly as you entered it when you registered the app. When you call the 52 | //! `authorize` function with this as a parameter, it will open a web browser with either a reddit 53 | //! login prompt, or if you are already logged in, a request for permission for your app. Once you 54 | //! click allow, the page should redirect to a simple display of the words `Authorization successful`. 55 | //! Hopefully this too will be customizable one day. 56 | //! 57 | //! Installed apps, unlike scripts, require periodic reauthorization, or will expire without the 58 | //! possibility of refreshing if a permanent duration wasn't requested. This should be done 59 | //! automatically by the `net::Connection` instance. 60 | 61 | use rand::{self, Rng}; 62 | use std; 63 | use std::cell::{Cell, RefCell}; 64 | use std::collections::HashMap; 65 | use std::sync::{Arc, Mutex}; 66 | use std::thread; 67 | use std::time::{Duration, Instant}; 68 | 69 | use base64; 70 | use failure::Error; 71 | use futures::future::ok; 72 | use futures::sync::oneshot::{self, Sender}; 73 | use futures::Future; 74 | use hyper::header::{self, HeaderValue}; 75 | use hyper::server::Server; 76 | use hyper::service::{MakeService, Service}; 77 | use hyper::{Body, Error as HyperError, Method, Request, Response}; 78 | use open; 79 | use url::{self, Url}; 80 | 81 | use errors::RedditError; 82 | use net::body_from_map; 83 | use net::Connection; 84 | 85 | /// Function type that is passed into OAuthApp::InstalledApp to generate response from code retrieval. 86 | pub type ResponseGenFn = (Fn(&Result) -> Response) + Send + Sync; 87 | 88 | type CodeSender = Arc>>>>; 89 | 90 | /// Enum representing OAuth information that has been aquired from authorization. This should only be 91 | /// used internally within orca. 92 | #[derive(Debug, Clone)] 93 | pub enum OAuth { 94 | /// Script app type 95 | Script { 96 | /// Id of the script 97 | id: String, 98 | /// Secret of the script 99 | secret: String, 100 | /// Username of the script user 101 | username: String, 102 | /// Password of the script user 103 | password: String, 104 | /// Token retrieved from script authorization 105 | token: String, 106 | }, 107 | /// Installed app type 108 | InstalledApp { 109 | /// Id of the installed app 110 | id: String, 111 | /// Redirect url of the installed app 112 | redirect: String, 113 | /// Token currently in use 114 | token: RefCell, 115 | /// The refresh token (to be used to retrieve a new token once the current one expires). 116 | /// Not present if temporary authorization was requested 117 | refresh_token: RefCell>, 118 | /// Instant when the current token expires 119 | expire_instant: Cell>, 120 | }, 121 | } 122 | 123 | impl OAuth { 124 | /// Refreshes the token (only necessary for installed app types) 125 | pub fn refresh(&self, conn: &Connection) -> Result<(), Error> { 126 | match *self { 127 | OAuth::Script { .. } => Ok(()), 128 | OAuth::InstalledApp { 129 | ref id, 130 | redirect: ref _redirect, 131 | ref token, 132 | ref refresh_token, 133 | ref expire_instant, 134 | } => { 135 | let old_refresh_token = if let Some(ref refresh_token) = *refresh_token.borrow() { refresh_token.clone() } else { return Err(RedditError::AuthError.into()) }; 136 | // Get the access token with the new code we just got 137 | let mut params: HashMap<&str, &str> = HashMap::new(); 138 | params.insert("grant_type", "refresh_token"); 139 | params.insert("refresh_token", &old_refresh_token); 140 | 141 | // Request for the access token 142 | let mut tokenreq = Request::builder().method(Method::POST).uri("https://www.reddit.com/api/v1/access_token/.json").body(body_from_map(¶ms)).unwrap(); 143 | // httpS is important 144 | tokenreq.headers_mut().insert(header::AUTHORIZATION, HeaderValue::from_str(&format!("Basic {}", { base64::encode(&format!("{}:", id)) })).unwrap()); 145 | 146 | // Send the request and get the access token as a response 147 | let response = conn.run_request(tokenreq)?; 148 | 149 | if let (Some(expires_in), Some(new_token), Some(scope)) = (response.get("expires_in"), response.get("access_token"), response.get("scope")) { 150 | let expires_in = expires_in.as_u64().unwrap(); 151 | let new_token = new_token.as_str().unwrap(); 152 | let _scope = scope.as_str().unwrap(); 153 | *token.borrow_mut() = new_token.to_string(); 154 | expire_instant.set(Some(Instant::now() + Duration::new(expires_in.to_string().parse::().unwrap(), 0))); 155 | 156 | Ok(()) 157 | } else { 158 | Err(Error::from(RedditError::AuthError)) 159 | } 160 | } 161 | } 162 | } 163 | 164 | /// Authorize the app as a script 165 | /// # Arguments 166 | /// * `conn` - A refernce to the connection to authorize 167 | /// * `id` - The app id registered on Reddit 168 | /// * `secret` - The app secret registered on Reddit 169 | /// * `username` - The username of the user to authorize as 170 | /// * `password` - The password of the user to authorize as 171 | pub fn create_script(conn: &Connection, id: &str, secret: &str, username: &str, password: &str) -> Result { 172 | // authorization paramaters to request 173 | let mut params: HashMap<&str, &str> = HashMap::new(); 174 | params.insert("grant_type", "password"); 175 | params.insert("username", &username); 176 | params.insert("password", &password); 177 | 178 | // Request for the bearer token 179 | let mut tokenreq = Request::builder().method(Method::POST).uri("https://ssl.reddit.com/api/v1/access_token/.json").body(body_from_map(¶ms)).unwrap(); 180 | // httpS is important 181 | tokenreq.headers_mut().insert(header::AUTHORIZATION, HeaderValue::from_str(&format!("Basic {}", { base64::encode(&format!("{}:{}", id, secret)) })).unwrap()); 182 | 183 | // Send the request and get the bearer token as a response 184 | let response = conn.run_request(tokenreq)?; 185 | 186 | if let Some(token) = response.get("access_token") { 187 | let token = token.as_str().unwrap().to_string(); 188 | Ok(OAuth::Script { 189 | id: id.to_string(), 190 | secret: secret.to_string(), 191 | username: username.to_string(), 192 | password: password.to_string(), 193 | token, 194 | }) 195 | } else { 196 | Err(RedditError::AuthError.into()) 197 | } 198 | } 199 | 200 | /// Authorize the app as an installed app 201 | /// # Arguments 202 | /// * `conn` - A reference to the connection to authorize 203 | /// * `id` - The app id registered on Reddit 204 | /// * `redirect` - The app redirect URI registered on Reddit 205 | /// * `response_gen` - An optional function that generates a hyper Response to give to the user 206 | /// based on the result of the authorization attempt. The signature is `(Result Result`. 207 | /// The result passed in is either Ok with the code recieved, or Err with the error that occurred. 208 | /// The value returned should usually be an Ok(Response), but you can return Err(Response) to indicate 209 | /// that an error occurred within the function. 210 | /// * `scopes` - A reference to a Scopes instance representing the capabilites you are requesting 211 | /// as an installed app. 212 | pub fn create_installed_app>>>(conn: &Connection, id: &str, redirect: &str, response_gen: I, scopes: &Scopes) -> Result { 213 | let response_gen = response_gen.into(); 214 | // Random state string to identify this authorization instance 215 | let state = rand::thread_rng().gen_ascii_chars().take(16).collect::(); 216 | 217 | let scopes = &scopes.to_string(); 218 | let browser_uri = format!( 219 | "https://www.reddit.com/api/v1/authorize?client_id={}&response_type=code&\ 220 | state={}&redirect_uri={}&duration=permanent&scope={}", 221 | id, state, redirect, scopes 222 | ); 223 | 224 | let state_rc = Arc::new(state); 225 | 226 | // Open the auth url in the browser so the user can authenticate the app 227 | thread::spawn(move || { 228 | open::that(browser_uri).expect("Failed to open browser"); 229 | }); 230 | 231 | // A oneshot future channel that the hyper server has access to to send the code back 232 | // to this thread. 233 | let (code_sender, code_reciever) = oneshot::channel::>(); 234 | 235 | // Convert the redirect url into something parseable by the HTTP server 236 | let redirect_url = Url::parse(&redirect)?; 237 | let main_redirect = format!("{}:{}", redirect_url.host_str().unwrap_or("127.0.0.1"), redirect_url.port().unwrap_or(7878).to_string()); 238 | 239 | // Set the default response generator if necessary 240 | let response_gen = if let Some(ref response_gen) = response_gen { 241 | Arc::clone(response_gen) 242 | } else { 243 | Arc::new(|res: &Result| -> Response { 244 | match res { 245 | Ok(_) => Response::new("Successfully got the code".into()), 246 | Err(e) => Response::new(format!("{}", e).into()), 247 | } 248 | }) 249 | }; 250 | 251 | // Create a server with the instance of a NewInstalledAppService struct with the 252 | // responses given, the oneshot sender and the generated state string 253 | let server = Server::bind(&main_redirect.as_str().parse()?).serve(MakeInstalledAppService { 254 | code_sender: Arc::new(Mutex::new(Some(code_sender))), 255 | state: Arc::clone(&state_rc), 256 | response_gen: Arc::clone(&response_gen), 257 | }); 258 | 259 | // Create a code value that is optional but should be set eventually 260 | let code: Arc>> = Arc::new(Mutex::new(Err(InstalledAppError::NeverRecieved))); 261 | let code_clone = Arc::clone(&code); 262 | 263 | // When the code_reciever oneshot resolves, set the new_code value. 264 | let finish = code_reciever.then(move |new_code| { 265 | let code = code_clone; 266 | if let Ok(new_code) = new_code { 267 | match new_code { 268 | Ok(new_code) => { 269 | *code.lock().unwrap() = Ok(new_code); 270 | Ok(()) 271 | } 272 | Err(e) => { 273 | *code.lock().unwrap() = Err(e); 274 | Err(()) 275 | } 276 | } 277 | } else { 278 | Err(()) 279 | } 280 | }); 281 | 282 | let graceful = server.with_graceful_shutdown(finish).map_err(|e| eprintln!("Server failed: {}", e)); 283 | 284 | // Run the server until the code future oneshot resolves and has set the code variable. 285 | hyper::rt::run(graceful); 286 | 287 | // Make sure we got the code. Return an error if we didn't. 288 | let code = match *code.lock().unwrap() { 289 | Ok(ref new_code) => new_code.clone(), 290 | Err(ref e) => return Err(e.clone().into()), 291 | }; 292 | 293 | // Get the access token with the new code we just got 294 | let mut params: HashMap<&str, &str> = HashMap::new(); 295 | params.insert("grant_type", "authorization_code"); 296 | params.insert("code", &code); 297 | params.insert("redirect_uri", &redirect); 298 | 299 | // Request for the access token 300 | let mut tokenreq = Request::builder().method(Method::POST).uri("https://ssl.reddit.com/api/v1/access_token/.json").body(body_from_map(¶ms)).unwrap(); 301 | // httpS is important 302 | tokenreq.headers_mut().insert(header::AUTHORIZATION, HeaderValue::from_str(&format!("Basic {}", base64::encode(&format!("{}:", id)))).unwrap()); 303 | 304 | // Send the request and get the access token as a response 305 | let response = conn.run_request(tokenreq)?; 306 | 307 | if let (Some(expires_in), Some(token), Some(refresh_token), Some(scope)) = (response.get("expires_in"), response.get("access_token"), response.get("refresh_token"), response.get("scope")) { 308 | let expires_in = expires_in.as_u64().unwrap(); 309 | let token = token.as_str().unwrap(); 310 | let refresh_token = refresh_token.as_str().unwrap(); 311 | let _scope = scope.as_str().unwrap(); 312 | Ok(OAuth::InstalledApp { 313 | id: id.to_string(), 314 | redirect: redirect.to_string(), 315 | token: RefCell::new(token.to_string()), 316 | refresh_token: RefCell::new(Some(refresh_token.to_string())), 317 | expire_instant: Cell::new(Some(Instant::now() + Duration::new(expires_in.to_string().parse::().unwrap(), 0))), 318 | }) 319 | } else { 320 | Err(Error::from(RedditError::AuthError)) 321 | } 322 | } 323 | } 324 | 325 | /// A struct representing scopes that an installed app can request permission for. 326 | /// To use, create an instance of the struct and set the fields you want to use to true. 327 | /// 328 | /// Note: In the field documentation, "the user" refers to the currently authorized user 329 | pub struct Scopes { 330 | /// See detailed info about the user 331 | pub identity: bool, 332 | /// Edit posts of the user 333 | pub edit: bool, 334 | /// Flair posts of the user 335 | pub flair: bool, 336 | /// Unknown 337 | pub history: bool, 338 | /// Unknown 339 | pub modconfig: bool, 340 | /// Unknown 341 | pub modflair: bool, 342 | /// Unknown 343 | pub modlog: bool, 344 | /// Unknown 345 | pub modposts: bool, 346 | /// Unknown 347 | pub modwiki: bool, 348 | /// Unknown 349 | pub mysubreddits: bool, 350 | /// Unknown 351 | pub privatemessages: bool, 352 | /// Unknown 353 | pub read: bool, 354 | /// Report posts on behalf of the user 355 | pub report: bool, 356 | /// Save posts to the user's account 357 | pub save: bool, 358 | /// Submit posts on behalf of the user 359 | pub submit: bool, 360 | /// Unknown 361 | pub subscribe: bool, 362 | /// Vote on things on behalf of the user 363 | pub vote: bool, 364 | /// Unknown 365 | pub wikiedit: bool, 366 | /// Unknown 367 | pub wikiread: bool, 368 | /// Unknown 369 | pub account: bool, 370 | } 371 | 372 | impl Scopes { 373 | /// Create a scopes instance with no permissions requested 374 | pub fn empty() -> Scopes { 375 | Scopes { 376 | identity: false, 377 | edit: false, 378 | flair: false, 379 | history: false, 380 | modconfig: false, 381 | modflair: false, 382 | modlog: false, 383 | modposts: false, 384 | modwiki: false, 385 | mysubreddits: false, 386 | privatemessages: false, 387 | read: false, 388 | report: false, 389 | save: false, 390 | submit: false, 391 | subscribe: false, 392 | vote: false, 393 | wikiedit: false, 394 | wikiread: false, 395 | account: false, 396 | } 397 | } 398 | 399 | /// Create a scopes instance with all permissions requested 400 | pub fn all() -> Scopes { 401 | Scopes { 402 | identity: true, 403 | edit: true, 404 | flair: true, 405 | history: true, 406 | modconfig: true, 407 | modflair: true, 408 | modlog: true, 409 | modposts: true, 410 | modwiki: true, 411 | mysubreddits: true, 412 | privatemessages: true, 413 | read: true, 414 | report: true, 415 | save: true, 416 | submit: true, 417 | subscribe: true, 418 | vote: true, 419 | wikiedit: true, 420 | wikiread: true, 421 | account: true, 422 | } 423 | } 424 | 425 | /// Convert the struct to a string representation to be sent to Reddit 426 | fn to_string(&self) -> String { 427 | let mut string = String::new(); 428 | if self.identity { 429 | string.push_str("identity"); 430 | } 431 | if self.edit { 432 | string.push_str(",edit"); 433 | } 434 | if self.flair { 435 | string.push_str(",flair"); 436 | } 437 | if self.history { 438 | string.push_str(",history"); 439 | } 440 | if self.modconfig { 441 | string.push_str(",modconfig"); 442 | } 443 | if self.modflair { 444 | string.push_str(",modflair"); 445 | } 446 | if self.modlog { 447 | string.push_str(",modlog"); 448 | } 449 | if self.modposts { 450 | string.push_str(",modposts"); 451 | } 452 | if self.modwiki { 453 | string.push_str(",modwiki"); 454 | } 455 | if self.mysubreddits { 456 | string.push_str(",mysubreddits"); 457 | } 458 | if self.privatemessages { 459 | string.push_str(",privatemessages"); 460 | } 461 | if self.read { 462 | string.push_str(",read"); 463 | } 464 | if self.report { 465 | string.push_str(",report"); 466 | } 467 | if self.save { 468 | string.push_str(",save"); 469 | } 470 | if self.submit { 471 | string.push_str(",submit"); 472 | } 473 | if self.subscribe { 474 | string.push_str(",subscribe"); 475 | } 476 | if self.vote { 477 | string.push_str(",vote"); 478 | } 479 | if self.wikiedit { 480 | string.push_str(",wikiedit"); 481 | } 482 | if self.wikiread { 483 | string.push_str(",wikiread"); 484 | } 485 | if self.account { 486 | string.push_str(",account"); 487 | } 488 | 489 | string 490 | } 491 | } 492 | 493 | /// Enum that contains possible errors from a request for the OAuth Installed App type. 494 | #[derive(Debug, Fail, Clone)] 495 | pub enum InstalledAppError { 496 | /// Got a generic error in the request 497 | #[fail(display = "Got an unknown error: {}", msg)] 498 | Error { 499 | /// The message included in the error 500 | msg: String, 501 | }, 502 | /// The state string wasn't present or did not match 503 | #[fail(display = "The states did not match")] 504 | MismatchedState, 505 | /// The code has already been recieved 506 | #[fail(display = "A code was already recieved")] 507 | AlreadyRecieved, 508 | /// No message was ever recieved 509 | #[fail(display = "No message was ever recieved")] 510 | NeverRecieved, 511 | } 512 | 513 | struct MakeInstalledAppService { 514 | code_sender: CodeSender, 515 | state: Arc, 516 | response_gen: Arc, 517 | } 518 | 519 | impl MakeService for MakeInstalledAppService { 520 | type ReqBody = Body; 521 | type ResBody = Body; 522 | type Error = hyper::Error; 523 | type Service = InstalledAppService; 524 | type Future = Box + Send + Sync>; 525 | type MakeError = Box; 526 | 527 | fn make_service(&mut self, _ctx: Ctx) -> Self::Future { 528 | Box::new(futures::future::ok(InstalledAppService { 529 | code_sender: Arc::clone(&self.code_sender), 530 | state: Arc::clone(&self.state), 531 | response_gen: Arc::clone(&self.response_gen), 532 | })) 533 | } 534 | } 535 | 536 | // The service that has the code_sender to send the code back to the main thread, the state to verify 537 | // that this is the right authorization instance, the optional responses, and a tokio Core needed to 538 | // clone the responses. 539 | struct InstalledAppService { 540 | code_sender: CodeSender, 541 | state: Arc, 542 | response_gen: Arc, 543 | } 544 | 545 | impl Service for InstalledAppService { 546 | type ReqBody = Body; 547 | type ResBody = Body; 548 | type Error = HyperError; 549 | type Future = Box, Error = Self::Error> + Send>; 550 | 551 | fn call(&mut self, req: Request) -> Self::Future { 552 | // Get the data from the request (the state and the code, or the error) in a HashMap 553 | let query_str = req.uri().path_and_query().unwrap().as_str(); 554 | let query_str = &query_str[2..query_str.len()]; 555 | let params: HashMap<_, _> = url::form_urlencoded::parse(query_str.as_bytes()).collect(); 556 | 557 | // Create a HTTP response based on the result of the code retrieval, the code sender, and the 558 | // response generator. 559 | fn create_res(gen: &ResponseGenFn, res: &Result, sender: &CodeSender) -> ::Future { 560 | let mut sender = sender.lock().unwrap(); 561 | let sender = if let Some(sender) = sender.take() { 562 | sender 563 | } else { 564 | return Box::new(ok(gen(&Err(InstalledAppError::AlreadyRecieved)))); 565 | }; 566 | let resp = match sender.send(res.clone()) { 567 | Ok(_) => gen(&res), 568 | Err(_) => gen(&Err(InstalledAppError::AlreadyRecieved)), 569 | }; 570 | Box::new(ok(resp)) 571 | } 572 | 573 | // If there was an error stop here, returning the error response 574 | if params.contains_key("error") { 575 | warn!("Got failed authorization. Error was {}", ¶ms["error"]); 576 | let err = InstalledAppError::Error { msg: params["error"].to_string() }; 577 | create_res(&*self.response_gen, &Err(err.clone()), &self.code_sender) 578 | } else { 579 | // Get the state if it exists 580 | let state = if let Some(state) = params.get("state") { 581 | state 582 | } else { 583 | // Return error response if we didn't get the state 584 | return create_res(&*self.response_gen, &Err(InstalledAppError::MismatchedState), &self.code_sender); 585 | }; 586 | // Error if the state doesn't match 587 | if *state != *self.state { 588 | error!("State didn't match. Got state \"{}\", needed state \"{}\"", state, self.state); 589 | create_res(&*self.response_gen, &Err(InstalledAppError::MismatchedState), &self.code_sender) 590 | } else { 591 | // Get the code and send it with the oneshot sender back to the main thread 592 | let code = ¶ms["code"]; 593 | create_res(&*self.response_gen, &Ok(code.clone().into()), &self.code_sender) 594 | } 595 | } 596 | } 597 | } 598 | 599 | // A neat trait I came up with. If you have a RefCell>, then you can call pop() on it and 600 | // it will take the value out of the RefCell and give it back. If it doesn't exist, then it just returns None. 601 | trait RefCellExt { 602 | fn pop(&self) -> Option; 603 | } 604 | 605 | impl RefCellExt for RefCell> { 606 | fn pop(&self) -> Option { 607 | if self.borrow().is_some() { 608 | return std::mem::replace(&mut *self.borrow_mut(), None); 609 | } 610 | 611 | None 612 | } 613 | } 614 | -------------------------------------------------------------------------------- /src/net/mod.rs: -------------------------------------------------------------------------------- 1 | //! The module contains networking, http, ratelimiting, authorization and more functionality. 2 | //! 3 | //! Most use cases of this library will not require anything directly present in this module 4 | //! explicitly, but be sure to read the documentation in the auth module for any script that wants 5 | //! to authorize itself on reddit. 6 | 7 | /// Contains all functionality for OAuth and logins 8 | pub mod auth; 9 | 10 | use std::cell::{Cell, RefCell}; 11 | use std::collections::HashMap; 12 | use std::hash::BuildHasher; 13 | use std::thread; 14 | use std::time::{Duration, Instant}; 15 | 16 | use futures::Stream; 17 | use hyper::client::{Client, HttpConnector}; 18 | use hyper::header::{self, HeaderValue}; 19 | use hyper::{Body, Request, Response, Uri}; 20 | use hyper_tls::HttpsConnector; 21 | use json; 22 | use json::Value; 23 | use tokio_core::reactor::Core; 24 | 25 | use self::auth::OAuth; 26 | use errors::RedditError; 27 | 28 | use failure::Error; 29 | 30 | /// How to ratelimit 31 | #[derive(Copy, Clone)] 32 | pub enum LimitMethod { 33 | /// Wait an even amount of time between each request 34 | Steady, 35 | /// Fire off requests as they come. It's possible there will be a long waiting time for the 36 | /// next ratelimit period if too many are fired off at once. 37 | Burst, 38 | } 39 | 40 | /// A connection holder to reddit. Holds authorization info if provided, and is in charge 41 | /// of ratelimiting. 42 | pub struct Connection { 43 | /// Authorization info (optional, but required for sending authorized requests) 44 | pub auth: Option, 45 | /// User agent for the client 46 | pub useragent: HeaderValue, 47 | /// HTTP client 48 | pub client: Client, Body>, 49 | /// Tokio core 50 | core: RefCell, 51 | /// How to ratelimit (burst or steady) 52 | pub limit: Cell, 53 | /// Requests sent in the past ratelimit period 54 | reqs: Cell, 55 | /// Requests remaining 56 | remaining: Cell>, 57 | /// Time when request amount will reset 58 | reset_time: Cell, 59 | } 60 | 61 | impl Connection { 62 | /// Creates a new connection instance to reddit 63 | /// # Arguments 64 | /// * `appname` - The name of the app 65 | /// * `appversion` - The version of the app 66 | /// * `appauthor` - The author of the app (should be in reddit form as /u/) 67 | pub fn new(appname: &str, appversion: &str, appauthor: &str) -> Result { 68 | let useragent = HeaderValue::from_str(&format!("linux:{}:{} (by {})", appname, appversion, appauthor)).unwrap(); 69 | let core = Core::new()?; 70 | let client = Client::builder().build(HttpsConnector::new(1)?); 71 | Ok(Connection { 72 | auth: None, 73 | useragent, 74 | client, 75 | core: RefCell::new(core), 76 | limit: Cell::new(LimitMethod::Steady), 77 | reqs: Cell::new(0), 78 | remaining: Cell::new(None), 79 | reset_time: Cell::new(Instant::now()), 80 | }) 81 | } 82 | 83 | /// Send a request to reddit. This is where ratelimiting happens, as well as setting the 84 | /// user agent. 85 | pub fn run_request(&self, mut req: Request) -> Result { 86 | let req_str = format!("{:?}", req); 87 | 88 | // Ratelimit based on method chosen type 89 | match self.limit.get() { 90 | LimitMethod::Steady => { 91 | // Check if we have a remaining limit 92 | if let Some(remaining) = self.remaining.get() { 93 | // If the reset time is in the future 94 | if Instant::now() < self.reset_time.get() { 95 | trace!("Ratelimiting in steady mode for {:?}", self.reset_time.get() - Instant::now()); 96 | // Sleep for the amount of time until reset divided by how many requests we have for steady sending 97 | thread::sleep((self.reset_time.get() - Instant::now()).checked_div(remaining as u32).unwrap()); 98 | } 99 | // Else we must have already passed reset time and we will get a new one after this request 100 | } 101 | } 102 | LimitMethod::Burst => { 103 | // Check if we have a remaining limit 104 | if let Some(remaining) = self.remaining.get() { 105 | // If we have none remaining and we haven't passed the request limit, sleep till we do 106 | if remaining <= 0 && self.reset_time.get() > Instant::now() { 107 | trace!("Ratelimiting in burst mode for {:?}", self.reset_time.get() - Instant::now()); 108 | thread::sleep(self.reset_time.get() - Instant::now()); 109 | } 110 | } 111 | } 112 | }; 113 | 114 | // Set useragent 115 | req.headers_mut().insert(header::USER_AGENT, self.useragent.clone()); 116 | 117 | // Log the request 118 | trace!("Sending request {:?}", req); 119 | 120 | // Execute the request! 121 | let response = self.client.request(req); 122 | let response = self.core.borrow_mut().run(response)?; 123 | 124 | // Update values from response ratelimiting headers 125 | if let Some(reqs_used) = response.headers().get("x-ratelimit-used") { 126 | let reqs_used = reqs_used.to_str().unwrap().parse::().unwrap().round() as i32; 127 | trace!("Used {} of requests in ratelimit period", reqs_used); 128 | self.reqs.set(reqs_used); 129 | } 130 | if let Some(reqs_remaining) = response.headers().get("x-ratelimit-remaining") { 131 | let reqs_remaining = reqs_remaining.to_str().unwrap().parse::().unwrap().round() as i32; 132 | trace!("Have {} requests remaining in ratelimit period", reqs_remaining); 133 | self.remaining.set(Some(reqs_remaining)); 134 | } 135 | if let Some(secs_remaining) = response.headers().get("x-ratelimit-reset") { 136 | let secs_remaining = secs_remaining.to_str().unwrap().parse::().unwrap().round() as u64; 137 | trace!("Have {} seconds remaining to ratelimit reset", secs_remaining); 138 | self.reset_time.set(Instant::now() + Duration::new(secs_remaining, 0)); 139 | } 140 | trace!("Ratelimiting:\n\tRequests used: {:?}\n\tRequests remaining: {:?}\n\tReset time: {:?}\n\tNow: {:?}", self.reqs.get(), self.remaining.get(), self.reset_time.get(), Instant::now()); 141 | 142 | let response_str = format!("{:?}", response); 143 | let get_body = |response: Response| -> Result { 144 | let body = self.core.borrow_mut().run(response.into_body().concat2())?; 145 | let body: String = String::from_utf8_lossy(&body).into(); 146 | Ok(body) 147 | }; 148 | 149 | if !response.status().is_success() { 150 | error!("Got error response: {}", response_str); 151 | return Err(Error::from(RedditError::BadRequest { 152 | request: req_str, 153 | response: format!("Reponse: {}\nResponse body: {:?}", response_str, get_body(response)?), 154 | })); 155 | } 156 | 157 | let body = get_body(response)?; 158 | 159 | match json::from_str(&body) { 160 | Ok(r) => { 161 | trace!("Got successful response: {:?}\nBody: {}", response_str, body); 162 | Ok(r) 163 | } 164 | Err(_) => Err(Error::from(RedditError::BadResponse { request: req_str, response: body })), 165 | } 166 | } 167 | 168 | /// Send a request to reddit with authorization headers 169 | pub fn run_auth_request(&self, mut req: Request) -> Result { 170 | if let Some(ref auth) = self.auth { 171 | let req_str = format!("{:?}", req); 172 | req.headers_mut().insert( 173 | header::AUTHORIZATION, 174 | HeaderValue::from_str(&format!( 175 | "Bearer {}", 176 | match *auth { 177 | OAuth::Script { 178 | id: ref _id, 179 | secret: ref _secret, 180 | username: ref _username, 181 | password: ref _password, 182 | ref token, 183 | } => token.to_string(), 184 | OAuth::InstalledApp { 185 | id: ref _id, 186 | redirect: ref _redirect, 187 | ref token, 188 | ref refresh_token, 189 | ref expire_instant, 190 | } => { 191 | // If the token can expire and we are able to refresh it 192 | if let (Some(_refresh_token), Some(expire_instant)) = (refresh_token.borrow().clone(), expire_instant.get()) { 193 | // If the token's expired, refresh it 194 | if Instant::now() > expire_instant { 195 | auth.refresh(self)?; 196 | } 197 | token.borrow().to_string() 198 | } else if let Some(expire_instant) = expire_instant.get() { 199 | if Instant::now() > expire_instant { 200 | return Err(Error::from(RedditError::Forbidden { request: format!("{:?}", req_str) })); 201 | } else { 202 | token.borrow().to_string() 203 | } 204 | } else { 205 | token.borrow().to_string() 206 | } 207 | } 208 | } 209 | )) 210 | .unwrap(), 211 | ); 212 | self.run_request(req) 213 | } else { 214 | Err(Error::from(RedditError::Forbidden { request: format!("{:?}", req) })) 215 | } 216 | } 217 | 218 | /// Set's the ratelimiting method 219 | pub fn set_limit(&self, limit: LimitMethod) { 220 | self.limit.set(limit); 221 | } 222 | 223 | /// Returns a reference to the tokio core in a RefCell 224 | pub fn get_core(&self) -> &RefCell { 225 | &self.core 226 | } 227 | } 228 | 229 | /// Creates a HTTP/hyper Body from a hashmap, in urlencoded form. 230 | pub fn body_from_map(map: &HashMap<&str, &str, S>) -> Body { 231 | let mut body_str = String::new(); 232 | 233 | for (i, item) in map.iter().enumerate() { 234 | // Push the paramater to the body with an & at the end unless it's the last parameter 235 | body_str.push_str(&format!("{}={}{}", item.0, item.1, if i < map.len() - 1 { "&" } else { "" })); 236 | } 237 | 238 | trace!("Setup body: \n{}\n", body_str); 239 | 240 | Body::from(body_str) 241 | } 242 | 243 | /// Creates a url with encoded parameters from hashmap. Right now it's kinda hacky 244 | pub fn uri_params_from_map(url: &str, map: &HashMap<&str, &str, S>) -> Result { 245 | use url::Url; 246 | 247 | Ok(Url::parse_with_params(url, map)?.to_string().parse()?) 248 | } 249 | -------------------------------------------------------------------------------- /src/test.rs: -------------------------------------------------------------------------------- 1 | extern crate env_logger; 2 | 3 | use std::sync::{Arc, Once, ONCE_INIT}; 4 | use std::thread; 5 | use std::time::Duration; 6 | 7 | use hyper::{Body, Response}; 8 | use log; 9 | 10 | use auth::OAuth; 11 | use data::*; 12 | use net::LimitMethod; 13 | use *; 14 | 15 | static ONCE: Once = ONCE_INIT; 16 | 17 | fn init_logging() { 18 | ONCE.call_once(|| { 19 | let mut builder = env_logger::LogBuilder::new(); 20 | builder.filter(Some("orca"), log::LogLevelFilter::Trace); 21 | builder.target(env_logger::LogTarget::Stdout); 22 | builder.init(); 23 | }); 24 | } 25 | 26 | fn source_env() -> Result<(String, String, String, String, String, String), ()> { 27 | use std::env; 28 | fn get_env(var: &str) -> String { 29 | match env::var(var) { 30 | Ok(item) => item, 31 | _ => panic!("{} must be set", var), 32 | } 33 | }; 34 | 35 | let username = get_env("REDDIT_USERNAME"); 36 | let password = get_env("REDDIT_PASSWORD"); 37 | let script_id = get_env("REDDIT_SCRIPT_ID"); 38 | let secret = get_env("REDDIT_SCRIPT_SECRET"); 39 | let installed_id = get_env("REDDIT_INSTALLED_ID"); 40 | let redirect = get_env("REDDIT_INSTALLED_REDIRECT"); 41 | 42 | Ok((username, password, script_id, secret, installed_id, redirect)) 43 | } 44 | 45 | fn init_reddit() -> App { 46 | init_logging(); 47 | let mut reddit = App::new("OrcaLibTest", "v0.2.0", "/u/IntrepidPig").unwrap(); 48 | let (username, password, script_id, secret, installed_id, redirect) = source_env().unwrap(); 49 | reddit.authorize_script(&script_id, &secret, &username, &password).unwrap(); 50 | 51 | reddit 52 | } 53 | 54 | #[test(posts)] 55 | fn get_posts() { 56 | init_reddit().get_posts("unixporn", Sort::Top(SortTime::All)).unwrap(); 57 | } 58 | 59 | // Conflicts with the force_refresh test 60 | //#[test(installed_auth)] 61 | fn installed_app_auth() { 62 | init_logging(); 63 | let (username, password, script_id, secret, installed_id, redirect) = source_env().unwrap(); 64 | let mut reddit = App::new("Orca Test Installed App", "v0.3.0", "/u/IntrepidPig").unwrap(); 65 | use net::auth::InstalledAppError; 66 | let response_gen: Arc = Arc::new(|res: &Result| -> Response { 67 | match res { 68 | Ok(code) => Response::new(Body::from("Congratulations! You have been authorized")), 69 | Err(e) => Response::new(Body::from(format!("ERROR: {}\n\nSorry for the inconvience", e))), 70 | } 71 | }); 72 | let mut scopes = Scopes::all(); 73 | scopes.submit = false; 74 | 75 | reddit.authorize_installed_app(&installed_id, &redirect, response_gen, &scopes).unwrap(); 76 | reddit.get_self().unwrap(); 77 | assert!(reddit.submit_self("test", "You shouldn't be seeing this", "Sorry if you do", false).is_err()); 78 | } 79 | 80 | #[test(sort)] 81 | fn post_sort() { 82 | init_logging(); 83 | assert_eq!(Sort::Top(SortTime::All).param(), &[("sort", "top"), ("t", "all")]) 84 | } 85 | 86 | #[test(auth)] 87 | fn test_auth() { 88 | init_reddit().get_self().unwrap(); 89 | } 90 | 91 | #[test(selfuser)] 92 | fn self_info() { 93 | let reddit = init_reddit(); 94 | 95 | let user = reddit.get_self().unwrap(); 96 | info!("Me:\n{}", json::to_string_pretty(&user).unwrap()); 97 | } 98 | 99 | #[test(otheruser)] 100 | fn other_info() { 101 | let reddit = init_reddit(); 102 | 103 | let otherguy = reddit.get_user("DO_U_EVN_SPAGHETTI").unwrap(); 104 | info!("That one guy:\n{}", json::to_string_pretty(&otherguy).unwrap()); 105 | } 106 | 107 | #[test(stream)] 108 | fn comment_stream() { 109 | let reddit = init_reddit(); 110 | let comments = reddit.create_comment_stream("all"); 111 | 112 | let mut count = 0; 113 | 114 | for comment in comments { 115 | count += 1; 116 | trace!("Got comment #{} by {}", count, comment.author); 117 | 118 | if count > 500 { 119 | break; 120 | }; 121 | } 122 | } 123 | 124 | #[test(tree)] 125 | fn comment_tree() { 126 | let reddit = init_reddit(); 127 | let tree = reddit.get_comment_tree("7le01h").unwrap(); 128 | 129 | fn print_tree(listing: Listing, level: i32) { 130 | for comment in listing { 131 | for _ in 0..level { 132 | print!("\t"); 133 | } 134 | println!("{} by {} (parent: {})", comment.id, comment.author, comment.parent_id); 135 | print_tree(comment.replies, level + 1); 136 | } 137 | }; 138 | 139 | print_tree(tree, 0); 140 | } 141 | 142 | //#[test(Stress)] 143 | fn stress_test() { 144 | let requests = 60; 145 | 146 | let reddit = init_reddit(); 147 | reddit.conn.set_limit(LimitMethod::Steady); 148 | 149 | use std::time::{Duration, Instant}; 150 | 151 | let mut times: Vec = Vec::new(); 152 | 153 | let start = Instant::now(); 154 | for userstuff in 0..requests { 155 | let t1 = Instant::now(); 156 | reddit.get_self().unwrap(); 157 | times.push(Instant::now() - t1); 158 | } 159 | let total = Instant::now() - start; 160 | 161 | info!("Total time for {} requests: {:?}", requests, total); 162 | 163 | let mut sum = Duration::new(0, 0); 164 | for i in times.iter() { 165 | sum += i.clone(); 166 | } 167 | 168 | info!("Average wait time: {:?}", sum / requests); 169 | } 170 | 171 | #[test(Sticky)] 172 | fn sticky() { 173 | let reddit = init_reddit(); 174 | let name = "t3_6u65br"; 175 | 176 | reddit.set_sticky(true, Some(2), name).unwrap(); 177 | thread::sleep(Duration::new(3, 0)); 178 | let post = reddit.load_post(name).unwrap(); 179 | assert!(post.stickied); 180 | 181 | reddit.set_sticky(false, Some(2), name).unwrap(); 182 | thread::sleep(Duration::new(3, 0)); 183 | let post = reddit.load_post(name).unwrap(); 184 | assert!(!post.stickied); 185 | } 186 | 187 | #[test(load_post)] 188 | fn load_post() { 189 | let reddit = init_reddit(); 190 | 191 | let post = reddit.load_post("t3_7am0zo").unwrap(); 192 | info!("Got post: {:?}", post); 193 | } 194 | 195 | #[test(message)] 196 | fn message() { 197 | let reddit = init_reddit(); 198 | 199 | reddit.message("intrepidpig", "please don't spam me", "oops").unwrap(); 200 | } 201 | 202 | #[test(submit)] 203 | fn test_post() { 204 | println!("{}", init_reddit().submit_self("pigasusland", "Test Post", "The time is dank-o-clock", true).unwrap()); 205 | } 206 | 207 | #[test(urlencode)] 208 | fn urlencode() { 209 | println!("{}", init_reddit().submit_self("pigasusland", "Tanks & Banks", "Will it work? Cheese & Rice", true).unwrap()); 210 | } 211 | 212 | #[test(force_refresh)] 213 | fn force_refresh() { 214 | init_logging(); 215 | let (username, password, script_id, secret, installed_id, redirect) = source_env().unwrap(); 216 | let mut reddit = App::new("Orca Test Installed App", "v0.4.0", "/u/IntrepidPig").unwrap(); 217 | reddit.authorize_installed_app(&installed_id, &redirect, None, &Scopes::all()).unwrap(); 218 | 219 | let auth = reddit.conn.auth.as_ref().unwrap(); 220 | let old_auth = auth.clone(); 221 | thread::sleep(Duration::new(2, 0)); 222 | auth.refresh(&reddit.conn).unwrap(); 223 | reddit.get_self().unwrap(); 224 | let new_auth = auth.clone(); 225 | 226 | match (old_auth, new_auth) { 227 | ( 228 | OAuth::InstalledApp { 229 | id: old_id, 230 | redirect: old_redirect, 231 | token: old_token, 232 | refresh_token: old_refresh_token, 233 | expire_instant: old_expire_instant, 234 | }, 235 | OAuth::InstalledApp { 236 | id: new_id, 237 | redirect: new_redirect, 238 | token: new_token, 239 | refresh_token: new_refresh_token, 240 | expire_instant: new_expire_instant, 241 | }, 242 | ) => { 243 | assert_eq!(old_id, new_id); 244 | assert_eq!(old_redirect, new_redirect); 245 | assert_ne!(old_token, new_token); 246 | assert_eq!(old_refresh_token, new_refresh_token); 247 | assert_ne!(old_expire_instant, new_expire_instant); 248 | } 249 | _ => panic!("Got unmatching authorization types"), 250 | } 251 | } 252 | 253 | // Takes over 2 hours 254 | //#[test(auto_refresh)] 255 | fn auto_refresh() { 256 | init_logging(); 257 | let (username, password, script_id, secret, installed_id, redirect) = source_env().unwrap(); 258 | let mut reddit = App::new("Orca Test Installed App", "v0.4.0", "/u/IntrepidPig").unwrap(); 259 | reddit.authorize_installed_app(&installed_id, &redirect, None, &Scopes::all()).unwrap(); 260 | reddit.get_self().unwrap(); 261 | 262 | thread::sleep(Duration::new(60 * 60 + 60, 0)); // Wait a little over an hour 263 | let mut first = true; 264 | reddit.get_self().unwrap_or_else(|_| { 265 | first = false; 266 | json::Value::Null 267 | }); 268 | let mut second = true; 269 | reddit.get_self().unwrap_or_else(|_| { 270 | second = false; 271 | json::Value::Null 272 | }); 273 | 274 | thread::sleep(Duration::new(60 * 60 + 60, 0)); // Wait a little over an hour 275 | let mut third = true; 276 | reddit.get_self().unwrap_or_else(|_| { 277 | third = false; 278 | json::Value::Null 279 | }); 280 | let mut fourth = true; 281 | reddit.get_self().unwrap_or_else(|_| { 282 | fourth = false; 283 | json::Value::Null 284 | }); 285 | 286 | fn bs(b: bool) -> &'static str { 287 | if b { 288 | "Success" 289 | } else { 290 | "Failure" 291 | } 292 | } 293 | 294 | println!("Tests:\n1: {}\n2: {}\n3: {}\n4: {}", bs(first), bs(second), bs(third), bs(fourth)); 295 | if !(first && second && third && fourth) { 296 | panic!("Test failed") 297 | } 298 | } 299 | --------------------------------------------------------------------------------