├── .gitignore ├── README.md ├── ch_01 ├── hello │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── minimal-tcp │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── minimal-warp │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── main.rs └── minimal_reqwest │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ └── main.rs ├── ch_02 ├── Cargo.lock ├── Cargo.toml └── src │ └── main.rs ├── ch_03 ├── Cargo.lock ├── Cargo.toml └── src │ └── main.rs ├── ch_04 ├── ch_04-s4.1.2 │ ├── Cargo.lock │ ├── Cargo.toml │ ├── question.json │ └── src │ │ └── main.rs └── final │ ├── Cargo.lock │ ├── Cargo.toml │ ├── questions.json │ └── src │ └── main.rs ├── ch_05 ├── Cargo.lock ├── Cargo.toml ├── handle-errors │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── questions.json └── src │ ├── main.rs │ ├── routes │ ├── answer.rs │ ├── mod.rs │ └── question.rs │ ├── store.rs │ └── types │ ├── answer.rs │ ├── mod.rs │ ├── pagination.rs │ └── question.rs ├── ch_06 ├── Cargo.lock ├── Cargo.toml ├── handle-errors │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── log4rs.yaml ├── questions.json ├── src │ ├── main.rs │ ├── routes │ │ ├── answer.rs │ │ ├── mod.rs │ │ └── question.rs │ ├── store.rs │ └── types │ │ ├── answer.rs │ │ ├── mod.rs │ │ ├── pagination.rs │ │ └── question.rs └── stderr.log ├── ch_07 ├── .vscode │ └── launch.json ├── Cargo.lock ├── Cargo.toml ├── handle-errors │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── log4rs.yaml ├── migrations │ ├── 20220509150516_questions_table.down.sql │ ├── 20220509150516_questions_table.up.sql │ ├── 20220514145724_answers_table.down.sql │ └── 20220514145724_answers_table.up.sql ├── questions.json ├── rustfmt.toml ├── src │ ├── main.rs │ ├── routes │ │ ├── answer.rs │ │ ├── mod.rs │ │ └── question.rs │ ├── store.rs │ └── types │ │ ├── answer.rs │ │ ├── mod.rs │ │ ├── pagination.rs │ │ └── question.rs └── stderr.log ├── ch_08 ├── .vscode │ └── launch.json ├── Cargo.lock ├── Cargo.toml ├── handle-errors │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── migrations │ ├── 20220509150516_questions_table.down.sql │ ├── 20220509150516_questions_table.up.sql │ ├── 20220514145724_answers_table.down.sql │ └── 20220514145724_answers_table.up.sql ├── rustfmt.toml └── src │ ├── main.rs │ ├── profanity.rs │ ├── routes │ ├── answer.rs │ ├── mod.rs │ └── question.rs │ ├── store.rs │ └── types │ ├── answer.rs │ ├── mod.rs │ ├── pagination.rs │ └── question.rs ├── ch_09 ├── .vscode │ └── launch.json ├── Cargo.lock ├── Cargo.toml ├── handle-errors │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── migrations │ ├── 20220509150516_questions_table.down.sql │ ├── 20220509150516_questions_table.up.sql │ ├── 20220514145724_answers_table.down.sql │ ├── 20220514145724_answers_table.up.sql │ ├── 20220523174842_create_accounts_table.down.sql │ ├── 20220523174842_create_accounts_table.up.sql │ ├── 20220523175814_extend_questions_table.down.sql │ ├── 20220523175814_extend_questions_table.up.sql │ ├── 20220523175821_extend_answers_table.down.sql │ └── 20220523175821_extend_answers_table.up.sql ├── rustfmt.toml └── src │ ├── main.rs │ ├── profanity.rs │ ├── routes │ ├── answer.rs │ ├── authentication.rs │ ├── mod.rs │ └── question.rs │ ├── store.rs │ └── types │ ├── account.rs │ ├── answer.rs │ ├── mod.rs │ ├── pagination.rs │ └── question.rs ├── ch_10 ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── build.rs ├── docker-compose.yml ├── handle-errors │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── migrations │ ├── 20220509150516_questions_table.down.sql │ ├── 20220509150516_questions_table.up.sql │ ├── 20220514145724_answers_table.down.sql │ ├── 20220514145724_answers_table.up.sql │ ├── 20220523174842_create_accounts_table.down.sql │ ├── 20220523174842_create_accounts_table.up.sql │ ├── 20220523175814_extend_questions_table.down.sql │ ├── 20220523175814_extend_questions_table.up.sql │ ├── 20220523175821_extend_answers_table.down.sql │ └── 20220523175821_extend_answers_table.up.sql ├── rustfmt.toml └── src │ ├── config.rs │ ├── main.rs │ ├── profanity.rs │ ├── routes │ ├── answer.rs │ ├── authentication.rs │ ├── mod.rs │ └── question.rs │ ├── store.rs │ └── types │ ├── account.rs │ ├── answer.rs │ ├── mod.rs │ ├── pagination.rs │ └── question.rs └── ch_11 ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── build.rs ├── docker-compose.yml ├── handle-errors ├── Cargo.toml └── src │ └── lib.rs ├── integration-tests ├── Cargo.lock ├── Cargo.toml └── src │ └── main.rs ├── migrations ├── 20220509150516_questions_table.down.sql ├── 20220509150516_questions_table.up.sql ├── 20220514145724_answers_table.down.sql ├── 20220514145724_answers_table.up.sql ├── 20220523174842_create_accounts_table.down.sql ├── 20220523174842_create_accounts_table.up.sql ├── 20220523175814_extend_questions_table.down.sql ├── 20220523175814_extend_questions_table.up.sql ├── 20220523175821_extend_answers_table.down.sql └── 20220523175821_extend_answers_table.up.sql ├── mock-server ├── Cargo.toml └── src │ └── lib.rs └── src ├── bin └── server.rs ├── config.rs ├── lib.rs ├── profanity.rs ├── routes ├── answer.rs ├── authentication.rs ├── mod.rs └── question.rs ├── store.rs └── types ├── account.rs ├── answer.rs ├── mod.rs ├── pagination.rs └── question.rs /.gitignore: -------------------------------------------------------------------------------- 1 | **/target 2 | **/.DS_STORE 3 | **/.vscode 4 | **/.env 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rust Web Development 2 | 3 | Source code (WIP) for the [Rust book published with Manning](https://www.manning.com/books/rust-web-development). 4 | -------------------------------------------------------------------------------- /ch_01/hello/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "hello" 7 | version = "0.1.0" 8 | -------------------------------------------------------------------------------- /ch_01/hello/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hello" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | -------------------------------------------------------------------------------- /ch_01/hello/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("Hello, world!"); 3 | } 4 | -------------------------------------------------------------------------------- /ch_01/minimal-tcp/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "minimal-tcp" 5 | version = "0.1.0" 6 | -------------------------------------------------------------------------------- /ch_01/minimal-tcp/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "minimal-tcp" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | -------------------------------------------------------------------------------- /ch_01/minimal-tcp/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{io::prelude::*, net::{TcpStream, TcpListener}}; 2 | 3 | fn main() { 4 | let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); 5 | for stream in listener.incoming() { 6 | let stream = stream.unwrap(); 7 | handle_stream(stream); 8 | } 9 | } 10 | 11 | fn handle_stream(mut stream: TcpStream) { 12 | let mut buffer = [0; 1024]; 13 | stream.read_exact(&mut buffer).unwrap(); 14 | println!("Request: {}", String::from_utf8_lossy(&buffer[..])); 15 | 16 | let response = "HTTP/1.1 200 OK\r\n\r\n"; 17 | stream.write_all(response.as_bytes()).unwrap(); 18 | stream.flush().unwrap(); 19 | } 20 | -------------------------------------------------------------------------------- /ch_01/minimal-warp/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "minimal-warp" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tokio = { version = "1.2", features = ["full"] } 8 | warp = "0.3" 9 | -------------------------------------------------------------------------------- /ch_01/minimal-warp/src/main.rs: -------------------------------------------------------------------------------- 1 | use warp::Filter; 2 | 3 | #[tokio::main] 4 | async fn main() { 5 | let hello = warp::path("hello") 6 | .and(warp::path::param()) 7 | .map(|name: String| format!("Hello, {}!", name)); 8 | 9 | warp::serve(hello) 10 | .run(([127, 0, 0, 1], 1337)) 11 | .await; 12 | } 13 | -------------------------------------------------------------------------------- /ch_01/minimal_reqwest/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "minimal_reqwest" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | reqwest = { version = "0.11", features = ["json"] } 8 | tokio = { version = "1", features = ["full"] } 9 | -------------------------------------------------------------------------------- /ch_01/minimal_reqwest/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | #[tokio::main] 4 | async fn main() -> Result<(), Box> { 5 | let resp = reqwest::get("https://httpbin.org/ip") 6 | .await? 7 | .json::>() 8 | .await?; 9 | println!("{:#?}", resp); 10 | Ok(()) 11 | } -------------------------------------------------------------------------------- /ch_02/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ch_02" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tokio = { version = "1.2", features = ["full"] } 8 | warp = "0.3" -------------------------------------------------------------------------------- /ch_02/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | use std::io::{Error, ErrorKind}; 3 | 4 | use warp::Filter; 5 | 6 | #[derive(Debug)] 7 | struct Question { 8 | id: QuestionId, 9 | title: String, 10 | content: String, 11 | tags: Option>, 12 | } 13 | 14 | #[derive(Debug)] 15 | struct QuestionId(String); 16 | 17 | impl Question { 18 | fn new(id: QuestionId, title: String, content: String, tags: Option>) -> Self { 19 | Question { 20 | id, 21 | title, 22 | content, 23 | tags, 24 | } 25 | } 26 | } 27 | 28 | impl FromStr for QuestionId { 29 | type Err = std::io::Error; 30 | 31 | fn from_str(id: &str) -> Result { 32 | match id.is_empty() { 33 | false => Ok(QuestionId(id.to_string())), 34 | true => Err(Error::new(ErrorKind::InvalidInput, "No id provided")), 35 | } 36 | } 37 | } 38 | 39 | #[tokio::main] 40 | async fn main() { 41 | let hello = warp::get() 42 | .map(|| format!("Hello, World!")); 43 | 44 | warp::serve(hello) 45 | .run(([127, 0, 0, 1], 3030)) 46 | .await; 47 | } 48 | -------------------------------------------------------------------------------- /ch_03/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ch_03" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | tokio = { version = "1.2", features = ["full"] } 9 | serde = { version = "1.0", features = ["derive"] } 10 | -------------------------------------------------------------------------------- /ch_03/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | use std::io::{Error, ErrorKind}; 3 | use warp::{ 4 | Filter, 5 | http::Method, 6 | filters::{ 7 | cors::CorsForbidden, 8 | }, 9 | reject::Reject, 10 | Rejection, 11 | Reply, 12 | http::StatusCode 13 | }; 14 | use serde::Serialize; 15 | 16 | #[derive(Debug, Serialize)] 17 | struct Question { 18 | id: QuestionId, 19 | title: String, 20 | content: String, 21 | tags: Option>, 22 | } 23 | #[derive(Debug, Serialize)] 24 | struct QuestionId(String); 25 | 26 | impl Question { 27 | fn new(id: QuestionId, title: String, content: String, tags: Option>) -> Self { 28 | Question { 29 | id, 30 | title, 31 | content, 32 | tags, 33 | } 34 | } 35 | } 36 | 37 | impl FromStr for QuestionId { 38 | type Err = std::io::Error; 39 | 40 | fn from_str(id: &str) -> Result { 41 | match id.is_empty() { 42 | false => Ok(QuestionId(id.to_string())), 43 | true => Err(Error::new(ErrorKind::InvalidInput, "No id provided")), 44 | } 45 | } 46 | } 47 | 48 | 49 | #[derive(Debug)] 50 | struct InvalidId; 51 | impl Reject for InvalidId {} 52 | 53 | async fn get_questions() -> Result { 54 | let question = Question::new( 55 | QuestionId::from_str("1").expect("No id provided"), 56 | "First Question".to_string(), 57 | "Content of question".to_string(), 58 | Some(vec!("faq".to_string())), 59 | ); 60 | 61 | match question.id.0.parse::() { 62 | Err(_) => { 63 | Err(warp::reject::custom(InvalidId)) 64 | }, 65 | Ok(_) => { 66 | Ok(warp::reply::json( 67 | &question 68 | )) 69 | } 70 | } 71 | } 72 | 73 | async fn return_error(r: Rejection) -> Result { 74 | if let Some(error) = r.find::() { 75 | Ok(warp::reply::with_status( 76 | error.to_string(), 77 | StatusCode::FORBIDDEN, 78 | )) 79 | } else if let Some(InvalidId) = r.find() { 80 | Ok(warp::reply::with_status( 81 | "No valid ID presented".to_string(), 82 | StatusCode::UNPROCESSABLE_ENTITY, 83 | )) 84 | } else { 85 | Ok(warp::reply::with_status( 86 | "Route not found".to_string(), 87 | StatusCode::NOT_FOUND, 88 | )) 89 | } 90 | } 91 | 92 | 93 | #[tokio::main] 94 | async fn main() { 95 | let cors = warp::cors() 96 | .allow_any_origin() 97 | .allow_header("content-type") 98 | .allow_methods(&[Method::PUT, Method::DELETE, Method::GET, Method::POST]); 99 | 100 | let get_items = warp::get() 101 | .and(warp::path("questions")) 102 | .and(warp::path::end()) 103 | .and_then(get_questions) 104 | .recover(return_error); 105 | 106 | let routes = get_items.with(cors); 107 | 108 | warp::serve(routes) 109 | .run(([127, 0, 0, 1], 3030)) 110 | .await; 111 | } 112 | -------------------------------------------------------------------------------- /ch_04/ch_04-s4.1.2/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ch_03" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | tokio = { version = "1.2", features = ["full"] } 9 | serde = { version = "1.0", features = ["derive"] } 10 | serde_json = "1.0" -------------------------------------------------------------------------------- /ch_04/ch_04-s4.1.2/question.json: -------------------------------------------------------------------------------- 1 | { 2 | "1" : { 3 | "id": "1", 4 | "title": "How?", 5 | "content": "Please help!", 6 | "tags": ["general"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ch_04/ch_04-s4.1.2/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | use std::io::{Error, ErrorKind}; 3 | use std::collections::HashMap; 4 | 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use warp::{ 8 | Filter, 9 | http::Method, 10 | filters::{ 11 | cors::CorsForbidden, 12 | }, 13 | reject::Reject, 14 | Rejection, 15 | Reply, 16 | http::StatusCode 17 | }; 18 | 19 | #[derive(Clone)] 20 | struct Store { 21 | questions: HashMap, 22 | } 23 | 24 | impl Store { 25 | fn new() -> Self { 26 | Store { 27 | questions: Self::init(), 28 | } 29 | } 30 | 31 | fn init() -> HashMap { 32 | let file = include_str!("../question.json"); 33 | serde_json::from_str(file).expect("can't read questions.json") 34 | } 35 | 36 | 37 | fn add_question(mut self, question: Question) -> Self { 38 | self.questions.insert(question.id.clone(), question); 39 | self 40 | } 41 | } 42 | 43 | #[derive(Clone, Debug, Deserialize, Serialize)] 44 | struct Question { 45 | id: QuestionId, 46 | title: String, 47 | content: String, 48 | tags: Option>, 49 | } 50 | 51 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] 52 | struct QuestionId(String); 53 | 54 | impl Question { 55 | fn new(id: QuestionId, title: String, content: String, tags: Option>) -> Self { 56 | Question { 57 | id, 58 | title, 59 | content, 60 | tags, 61 | } 62 | } 63 | } 64 | 65 | impl FromStr for QuestionId { 66 | type Err = std::io::Error; 67 | 68 | fn from_str(id: &str) -> Result { 69 | match id.is_empty() { 70 | false => Ok(QuestionId(id.to_string())), 71 | true => Err(Error::new(ErrorKind::InvalidInput, "No id provided")), 72 | } 73 | } 74 | } 75 | 76 | 77 | #[derive(Debug)] 78 | struct InvalidId; 79 | impl Reject for InvalidId {} 80 | 81 | async fn get_questions() -> Result { 82 | let question = Question::new( 83 | QuestionId::from_str("1").expect("No id provided"), 84 | "First Question".to_string(), 85 | "Content of question".to_string(), 86 | Some(vec!("faq".to_string())), 87 | ); 88 | 89 | match question.id.0.parse::() { 90 | Err(_) => { 91 | Err(warp::reject::custom(InvalidId)) 92 | }, 93 | Ok(_) => { 94 | Ok(warp::reply::json( 95 | &question 96 | )) 97 | } 98 | } 99 | } 100 | 101 | async fn return_error(r: Rejection) -> Result { 102 | if let Some(error) = r.find::() { 103 | Ok(warp::reply::with_status( 104 | error.to_string(), 105 | StatusCode::FORBIDDEN, 106 | )) 107 | } else { 108 | Ok(warp::reply::with_status( 109 | "Route not found".to_string(), 110 | StatusCode::NOT_FOUND, 111 | )) 112 | } 113 | } 114 | 115 | 116 | #[tokio::main] 117 | async fn main() { 118 | let cors = warp::cors() 119 | .allow_any_origin() 120 | .allow_header("content-type") 121 | .allow_methods(&[Method::PUT, Method::DELETE, Method::GET, Method::POST]); 122 | 123 | let get_items = warp::get() 124 | .and(warp::path("questions")) 125 | .and(warp::path::end()) 126 | .and_then(get_questions) 127 | .recover(return_error); 128 | 129 | let routes = get_items.with(cors); 130 | 131 | warp::serve(routes) 132 | .run(([127, 0, 0, 1], 3030)) 133 | .await; 134 | } 135 | -------------------------------------------------------------------------------- /ch_04/final/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ch_04" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | serde = { version = "1.0", features = ["derive"] } 9 | serde_json = "1.0" 10 | tokio = { version = "1.1.1", features = ["full"] } -------------------------------------------------------------------------------- /ch_04/final/questions.json: -------------------------------------------------------------------------------- 1 | { 2 | "QI0001" : { 3 | "id": "QI0001", 4 | "title": "First question ever asked", 5 | "content": "How does this work?", 6 | "tags": ["general"], 7 | "comments": ["CI001"], 8 | "upvotes": null 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ch_04/final/src/main.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::{collections::HashMap, sync::Arc}; 3 | use warp::{ 4 | filters::{body::BodyDeserializeError, cors::CorsForbidden}, 5 | http::Method, 6 | http::StatusCode, 7 | reject::Reject, 8 | Filter, Rejection, Reply, 9 | }; 10 | 11 | use tokio::sync::RwLock; 12 | 13 | #[derive(Deserialize, Serialize, Debug, Clone)] 14 | struct Question { 15 | id: QuestionId, 16 | title: String, 17 | content: String, 18 | tags: Option>, 19 | } 20 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 21 | struct QuestionId(String); 22 | 23 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 24 | struct AnswerId(String); 25 | 26 | #[derive(Serialize, Deserialize, Debug, Clone)] 27 | struct Answer { 28 | id: AnswerId, 29 | content: String, 30 | question_id: QuestionId, 31 | } 32 | 33 | #[derive(Debug)] 34 | struct Pagination { 35 | start: usize, 36 | end: usize, 37 | } 38 | 39 | #[derive(Clone)] 40 | struct Store { 41 | questions: Arc>>, 42 | answers: Arc>>, 43 | } 44 | 45 | impl Store { 46 | fn new() -> Self { 47 | Store { 48 | questions: Arc::new(RwLock::new(Self::init())), 49 | answers: Arc::new(RwLock::new(HashMap::new())), 50 | } 51 | } 52 | 53 | fn init() -> HashMap { 54 | let file = include_str!("../questions.json"); 55 | serde_json::from_str(file).expect("can't read questions.json") 56 | } 57 | } 58 | 59 | #[derive(Debug)] 60 | enum Error { 61 | ParseError(std::num::ParseIntError), 62 | MissingParameters, 63 | QuestionNotFound, 64 | } 65 | 66 | impl std::fmt::Display for Error { 67 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 68 | match *self { 69 | Error::ParseError(ref err) => write!(f, "Cannot parse parameter: {}", err), 70 | Error::MissingParameters => write!(f, "Missing parameter"), 71 | Error::QuestionNotFound => write!(f, "Question not found"), 72 | } 73 | } 74 | } 75 | 76 | impl Reject for Error {} 77 | 78 | async fn return_error(r: Rejection) -> Result { 79 | if let Some(error) = r.find::() { 80 | Ok(warp::reply::with_status( 81 | error.to_string(), 82 | StatusCode::RANGE_NOT_SATISFIABLE, 83 | )) 84 | } else if let Some(error) = r.find::() { 85 | Ok(warp::reply::with_status( 86 | error.to_string(), 87 | StatusCode::FORBIDDEN, 88 | )) 89 | } else if let Some(error) = r.find::() { 90 | Ok(warp::reply::with_status( 91 | error.to_string(), 92 | StatusCode::UNPROCESSABLE_ENTITY, 93 | )) 94 | } else { 95 | Ok(warp::reply::with_status( 96 | "Route not found".to_string(), 97 | StatusCode::NOT_FOUND, 98 | )) 99 | } 100 | } 101 | 102 | fn extract_pagination(params: HashMap) -> Result { 103 | if params.contains_key("start") && params.contains_key("end") { 104 | return Ok(Pagination { 105 | start: params 106 | .get("start") 107 | .unwrap() 108 | .parse::() 109 | .map_err(Error::ParseError)?, 110 | end: params 111 | .get("end") 112 | .unwrap() 113 | .parse::() 114 | .map_err(Error::ParseError)?, 115 | }); 116 | } 117 | 118 | Err(Error::MissingParameters) 119 | } 120 | 121 | async fn get_questions( 122 | params: HashMap, 123 | store: Store, 124 | ) -> Result { 125 | if !params.is_empty() { 126 | let pagination = extract_pagination(params)?; 127 | let res: Vec = store.questions.read().await.values().cloned().collect(); 128 | let res = &res[pagination.start..pagination.end]; 129 | Ok(warp::reply::json(&res)) 130 | } else { 131 | let res: Vec = store.questions.read().await.values().cloned().collect(); 132 | Ok(warp::reply::json(&res)) 133 | } 134 | } 135 | 136 | async fn add_question( 137 | store: Store, 138 | question: Question, 139 | ) -> Result { 140 | store 141 | .questions 142 | .write() 143 | .await 144 | .insert(question.id.clone(), question); 145 | 146 | Ok(warp::reply::with_status("Question added", StatusCode::OK)) 147 | } 148 | 149 | async fn update_question( 150 | id: String, 151 | store: Store, 152 | question: Question, 153 | ) -> Result { 154 | match store.questions.write().await.get_mut(&QuestionId(id)) { 155 | Some(q) => *q = question, 156 | None => return Err(warp::reject::custom(Error::QuestionNotFound)), 157 | } 158 | 159 | Ok(warp::reply::with_status("Question updated", StatusCode::OK)) 160 | } 161 | 162 | async fn delete_question(id: String, store: Store) -> Result { 163 | match store.questions.write().await.remove(&QuestionId(id)) { 164 | Some(_) => return Ok(warp::reply::with_status("Question deleted", StatusCode::OK)), 165 | None => return Err(warp::reject::custom(Error::QuestionNotFound)), 166 | } 167 | } 168 | 169 | async fn add_answer( 170 | store: Store, 171 | params: HashMap, 172 | ) -> Result { 173 | let answer = Answer { 174 | id: AnswerId("1".to_string()), 175 | content: params.get("content").unwrap().to_string(), 176 | question_id: QuestionId(params.get("questionId").unwrap().to_string()), 177 | }; 178 | 179 | store 180 | .answers 181 | .write() 182 | .await 183 | .insert(answer.id.clone(), answer); 184 | 185 | Ok(warp::reply::with_status("Answer added", StatusCode::OK)) 186 | } 187 | 188 | #[tokio::main] 189 | async fn main() { 190 | let store = Store::new(); 191 | let store_filter = warp::any().map(move || store.clone()); 192 | 193 | let cors = warp::cors() 194 | .allow_any_origin() 195 | .allow_header("content-type") 196 | .allow_methods(&[Method::PUT, Method::DELETE, Method::GET, Method::POST]); 197 | 198 | let get_questions = warp::get() 199 | .and(warp::path("questions")) 200 | .and(warp::path::end()) 201 | .and(warp::query()) 202 | .and(store_filter.clone()) 203 | .and_then(get_questions); 204 | 205 | let update_question = warp::put() 206 | .and(warp::path("questions")) 207 | .and(warp::path::param::()) 208 | .and(warp::path::end()) 209 | .and(store_filter.clone()) 210 | .and(warp::body::json()) 211 | .and_then(update_question); 212 | 213 | let delete_question = warp::delete() 214 | .and(warp::path("questions")) 215 | .and(warp::path::param::()) 216 | .and(warp::path::end()) 217 | .and(store_filter.clone()) 218 | .and_then(delete_question); 219 | 220 | let add_question = warp::post() 221 | .and(warp::path("questions")) 222 | .and(warp::path::end()) 223 | .and(store_filter.clone()) 224 | .and(warp::body::json()) 225 | .and_then(add_question); 226 | 227 | let add_answer = warp::post() 228 | .and(warp::path("comments")) 229 | .and(warp::path::end()) 230 | .and(store_filter.clone()) 231 | .and(warp::body::form()) 232 | .and_then(add_answer); 233 | 234 | let routes = get_questions 235 | .or(update_question) 236 | .or(add_question) 237 | .or(add_answer) 238 | .or(delete_question) 239 | .with(cors) 240 | .recover(return_error); 241 | 242 | warp::serve(routes).run(([127, 0, 0, 1], 3030)).await; 243 | } 244 | -------------------------------------------------------------------------------- /ch_05/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "practical-rust-book" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | serde = { version = "1.0", features = ["derive"] } 9 | serde_json = "1.0" 10 | tokio = { version = "1.1.1", features = ["full"] } 11 | # We can omit the version number for local imports 12 | handle-errors = { path = "handle-errors" } -------------------------------------------------------------------------------- /ch_05/handle-errors/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "handle-errors" 3 | version = "0.1.0" 4 | authors = ["Bastian Gruber "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | warp = "0.3" -------------------------------------------------------------------------------- /ch_05/handle-errors/src/lib.rs: -------------------------------------------------------------------------------- 1 | use warp::{ 2 | filters::{body::BodyDeserializeError, cors::CorsForbidden}, 3 | http::StatusCode, 4 | reject::Reject, 5 | Rejection, Reply, 6 | }; 7 | 8 | #[derive(Debug)] 9 | pub enum Error { 10 | ParseError(std::num::ParseIntError), 11 | MissingParameters, 12 | QuestionNotFound, 13 | } 14 | 15 | impl std::fmt::Display for Error { 16 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 17 | match *self { 18 | Error::ParseError(ref err) => write!(f, "Cannot parse parameter: {}", err), 19 | Error::MissingParameters => write!(f, "Missing parameter"), 20 | Error::QuestionNotFound => write!(f, "Question not found"), 21 | } 22 | } 23 | } 24 | 25 | impl Reject for Error {} 26 | 27 | pub async fn return_error(r: Rejection) -> Result { 28 | println!("{:?}", r); 29 | if let Some(error) = r.find::() { 30 | Ok(warp::reply::with_status( 31 | error.to_string(), 32 | StatusCode::UNPROCESSABLE_ENTITY, 33 | )) 34 | } else if let Some(error) = r.find::() { 35 | Ok(warp::reply::with_status( 36 | error.to_string(), 37 | StatusCode::FORBIDDEN, 38 | )) 39 | } else if let Some(error) = r.find::() { 40 | Ok(warp::reply::with_status( 41 | error.to_string(), 42 | StatusCode::UNPROCESSABLE_ENTITY, 43 | )) 44 | } else { 45 | Ok(warp::reply::with_status( 46 | "Route not found".to_string(), 47 | StatusCode::NOT_FOUND, 48 | )) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ch_05/questions.json: -------------------------------------------------------------------------------- 1 | { 2 | "QI0001" : { 3 | "id": "QI0001", 4 | "title": "First question ever asked", 5 | "content": "How does this work?", 6 | "tags": ["general"], 7 | "comments": ["CI001"], 8 | "upvotes": null 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ch_05/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all)] 2 | 3 | use handle_errors::return_error; 4 | use warp::{http::Method, Filter}; 5 | 6 | mod routes; 7 | mod store; 8 | mod types; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | let store = store::Store::new(); 13 | let store_filter = warp::any().map(move || store.clone()); 14 | 15 | let cors = warp::cors() 16 | .allow_any_origin() 17 | .allow_header("content-type") 18 | .allow_methods(&[Method::PUT, Method::DELETE, Method::GET, Method::POST]); 19 | 20 | let get_questions = warp::get() 21 | .and(warp::path("questions")) 22 | .and(warp::path::end()) 23 | .and(warp::query()) 24 | .and(store_filter.clone()) 25 | .and_then(routes::question::get_questions); 26 | 27 | let update_question = warp::put() 28 | .and(warp::path("questions")) 29 | .and(warp::path::param::()) 30 | .and(warp::path::end()) 31 | .and(store_filter.clone()) 32 | .and(warp::body::json()) 33 | .and_then(routes::question::update_question); 34 | 35 | let delete_question = warp::delete() 36 | .and(warp::path("questions")) 37 | .and(warp::path::param::()) 38 | .and(warp::path::end()) 39 | .and(store_filter.clone()) 40 | .and_then(routes::question::delete_question); 41 | 42 | let add_question = warp::post() 43 | .and(warp::path("questions")) 44 | .and(warp::path::end()) 45 | .and(store_filter.clone()) 46 | .and(warp::body::json()) 47 | .and_then(routes::question::add_question); 48 | 49 | let add_answer = warp::post() 50 | .and(warp::path("comments")) 51 | .and(warp::path::end()) 52 | .and(store_filter.clone()) 53 | .and(warp::body::form()) 54 | .and_then(routes::answer::add_answer); 55 | 56 | let routes = get_questions 57 | .or(update_question) 58 | .or(add_question) 59 | .or(add_answer) 60 | .or(delete_question) 61 | .with(cors) 62 | .recover(return_error); 63 | 64 | warp::serve(routes).run(([127, 0, 0, 1], 3030)).await; 65 | } 66 | -------------------------------------------------------------------------------- /ch_05/src/routes/answer.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use warp::http::StatusCode; 3 | 4 | use crate::store::Store; 5 | use crate::types::{ 6 | answer::{Answer, AnswerId}, 7 | question::QuestionId, 8 | }; 9 | 10 | pub async fn add_answer( 11 | store: Store, 12 | params: HashMap, 13 | ) -> Result { 14 | let answer = Answer { 15 | id: AnswerId("1".to_string()), 16 | content: params.get("content").unwrap().to_string(), 17 | question_id: QuestionId(params.get("questionId").unwrap().to_string()), 18 | }; 19 | 20 | store.answers.write().await.insert(answer.id.clone(), answer); 21 | 22 | Ok(warp::reply::with_status("Answer added", StatusCode::OK)) 23 | } 24 | -------------------------------------------------------------------------------- /ch_05/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod question; 3 | -------------------------------------------------------------------------------- /ch_05/src/routes/question.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use warp::http::StatusCode; 3 | 4 | use crate::store::Store; 5 | use crate::types::pagination::extract_pagination; 6 | use crate::types::question::{Question, QuestionId}; 7 | use handle_errors::Error; 8 | 9 | pub async fn get_questions( 10 | params: HashMap, 11 | store: Store, 12 | ) -> Result { 13 | if !params.is_empty() { 14 | let pagination = extract_pagination(params)?; 15 | let res: Vec = store.questions.read().await.values().cloned().collect(); 16 | let res = &res[pagination.start..pagination.end]; 17 | Ok(warp::reply::json(&res)) 18 | } else { 19 | let res: Vec = store.questions.read().await.values().cloned().collect(); 20 | Ok(warp::reply::json(&res)) 21 | } 22 | } 23 | 24 | pub async fn update_question( 25 | id: String, 26 | store: Store, 27 | question: Question, 28 | ) -> Result { 29 | match store.questions.write().await.get_mut(&QuestionId(id)) { 30 | Some(q) => *q = question, 31 | None => return Err(warp::reject::custom(Error::QuestionNotFound)), 32 | } 33 | 34 | Ok(warp::reply::with_status("Question updated", StatusCode::OK)) 35 | } 36 | 37 | pub async fn delete_question( 38 | id: String, 39 | store: Store, 40 | ) -> Result { 41 | match store.questions.write().await.remove(&QuestionId(id)) { 42 | Some(_) => (), 43 | None => return Err(warp::reject::custom(Error::QuestionNotFound)), 44 | } 45 | 46 | Ok(warp::reply::with_status("Question deleted", StatusCode::OK)) 47 | } 48 | 49 | pub async fn add_question( 50 | store: Store, 51 | question: Question, 52 | ) -> Result { 53 | store 54 | .questions 55 | .write() 56 | .await 57 | .insert(question.clone().id, question); 58 | 59 | Ok(warp::reply::with_status("Question added", StatusCode::OK)) 60 | } 61 | -------------------------------------------------------------------------------- /ch_05/src/store.rs: -------------------------------------------------------------------------------- 1 | use tokio::sync::RwLock; 2 | use std::collections::HashMap; 3 | use std::sync::Arc; 4 | 5 | use crate::types::{ 6 | answer::{Answer, AnswerId}, 7 | question::{Question, QuestionId}, 8 | }; 9 | 10 | #[derive(Clone)] 11 | pub struct Store { 12 | pub questions: Arc>>, 13 | pub answers: Arc>>, 14 | } 15 | 16 | impl Store { 17 | pub fn new() -> Self { 18 | Store { 19 | questions: Arc::new(RwLock::new(Self::init())), 20 | answers: Arc::new(RwLock::new(HashMap::new())), 21 | } 22 | } 23 | 24 | fn init() -> HashMap { 25 | let file = include_str!("../questions.json"); 26 | serde_json::from_str(file).expect("can't read questions.json") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ch_05/src/types/answer.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::types::question::QuestionId; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone)] 6 | pub struct Answer { 7 | pub id: AnswerId, 8 | pub content: String, 9 | pub question_id: QuestionId, 10 | } 11 | 12 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 13 | pub struct AnswerId(pub String); 14 | -------------------------------------------------------------------------------- /ch_05/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod pagination; 3 | pub mod question; 4 | -------------------------------------------------------------------------------- /ch_05/src/types/pagination.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use handle_errors::Error; 4 | 5 | /// Pagination struct which is getting extract 6 | /// from query params 7 | #[derive(Debug)] 8 | pub struct Pagination { 9 | /// The index of the first item which has to be returned 10 | pub start: usize, 11 | /// The index of the last item which has to be returned 12 | pub end: usize, 13 | } 14 | 15 | /// Extract query parameters from the `/questions` route 16 | /// # Example query 17 | /// GET requests to this route can have a pagination attached so we just 18 | /// return the questions we need 19 | /// `/questions?start=1&end=10` 20 | /// # Example usage 21 | /// ```rust 22 | /// use std::collections::HashMap; 23 | /// 24 | /// let mut query = HashMap::new(); 25 | /// query.insert("start".to_string(), "1".to_string()); 26 | /// query.insert("end".to_string(), "10".to_string()); 27 | /// let p = pagination::extract_pagination(query).unwrap(); 28 | /// assert_eq!(p.start, 1); 29 | /// assert_eq!(p.end, 10); 30 | /// ``` 31 | pub fn extract_pagination(params: HashMap) 32 | -> Result { 33 | // Could be improved in the future 34 | if params.contains_key("start") && params.contains_key("end") { 35 | return Ok(Pagination { 36 | // Takes the "start" parameter in the query 37 | // and tries to convert it to a number 38 | start: params 39 | .get("start") 40 | .unwrap() 41 | .parse::() 42 | .map_err(Error::ParseError)?, 43 | // Takes the "end" parameter in the query 44 | // and tries to convert it to a number 45 | end: params 46 | .get("end") 47 | .unwrap() 48 | .parse::() 49 | .map_err(Error::ParseError)?, 50 | }); 51 | } 52 | 53 | Err(Error::MissingParameters) 54 | } 55 | -------------------------------------------------------------------------------- /ch_05/src/types/question.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | #[derive(Deserialize, Serialize, Debug, Clone)] 3 | pub struct Question { 4 | pub id: QuestionId, 5 | pub title: String, 6 | pub content: String, 7 | pub tags: Option>, 8 | } 9 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 10 | pub struct QuestionId(pub String); 11 | -------------------------------------------------------------------------------- /ch_06/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "practical-rust-book" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | serde = { version = "1.0", features = ["derive"] } 9 | serde_json = "1.0" 10 | tokio = { version = "1.1.1", features = ["full"] } 11 | # We can omit the version number for local imports 12 | handle-errors = { path = "handle-errors" } 13 | log = "0.4" 14 | env_logger = "0.9" 15 | log4rs = "1.0" 16 | uuid = { version = "0.8", features = ["v4"] } 17 | tracing = { version = "0.1", features = ["log"] } 18 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 19 | -------------------------------------------------------------------------------- /ch_06/handle-errors/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "handle-errors" 3 | version = "0.1.0" 4 | authors = ["Bastian Gruber "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | warp = "0.3" -------------------------------------------------------------------------------- /ch_06/handle-errors/src/lib.rs: -------------------------------------------------------------------------------- 1 | use warp::{ 2 | filters::{body::BodyDeserializeError, cors::CorsForbidden}, 3 | http::StatusCode, 4 | reject::Reject, 5 | Rejection, Reply, 6 | }; 7 | 8 | #[derive(Debug)] 9 | pub enum Error { 10 | ParseError(std::num::ParseIntError), 11 | MissingParameters, 12 | QuestionNotFound, 13 | } 14 | 15 | impl std::fmt::Display for Error { 16 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 17 | match *self { 18 | Error::ParseError(ref err) => write!(f, "Cannot parse parameter: {}", err), 19 | Error::MissingParameters => write!(f, "Missing parameter"), 20 | Error::QuestionNotFound => write!(f, "Question not found"), 21 | } 22 | } 23 | } 24 | 25 | impl Reject for Error {} 26 | 27 | pub async fn return_error(r: Rejection) -> Result { 28 | println!("{:?}", r); 29 | if let Some(error) = r.find::() { 30 | Ok(warp::reply::with_status( 31 | error.to_string(), 32 | StatusCode::UNPROCESSABLE_ENTITY, 33 | )) 34 | } else if let Some(error) = r.find::() { 35 | Ok(warp::reply::with_status( 36 | error.to_string(), 37 | StatusCode::FORBIDDEN, 38 | )) 39 | } else if let Some(error) = r.find::() { 40 | Ok(warp::reply::with_status( 41 | error.to_string(), 42 | StatusCode::UNPROCESSABLE_ENTITY, 43 | )) 44 | } else { 45 | Ok(warp::reply::with_status( 46 | "Route not found".to_string(), 47 | StatusCode::NOT_FOUND, 48 | )) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ch_06/log4rs.yaml: -------------------------------------------------------------------------------- 1 | refresh_rate: 30 seconds 2 | appenders: 3 | stdout: 4 | kind: console 5 | encoder: 6 | kind: json 7 | file: 8 | kind: file 9 | path: "stderr.log" 10 | encoder: 11 | kind: json 12 | 13 | root: 14 | level: info 15 | appenders: 16 | - stdout 17 | - file 18 | -------------------------------------------------------------------------------- /ch_06/questions.json: -------------------------------------------------------------------------------- 1 | { 2 | "QI0001" : { 3 | "id": "QI0001", 4 | "title": "First question ever asked", 5 | "content": "How does this work?", 6 | "tags": ["general"], 7 | "comments": ["CI001"], 8 | "upvotes": null 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ch_06/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all)] 2 | 3 | use warp::{http::Method, Filter}; 4 | use handle_errors::return_error; 5 | use tracing_subscriber::fmt::format::FmtSpan; 6 | 7 | mod routes; 8 | mod store; 9 | mod types; 10 | 11 | #[tokio::main] 12 | async fn main() { 13 | let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "practical_rust_book=info,warp=error".to_owned()); 14 | 15 | let store = store::Store::new(); 16 | let store_filter = warp::any().map(move || store.clone()); 17 | 18 | tracing_subscriber::fmt() 19 | // Use the filter we built above to determine which traces to record. 20 | .with_env_filter(log_filter) 21 | // Record an event when each span closes. This can be used to time our 22 | // routes' durations! 23 | .with_span_events(FmtSpan::CLOSE) 24 | .init(); 25 | 26 | let cors = warp::cors() 27 | .allow_any_origin() 28 | .allow_header("content-type") 29 | .allow_methods(&[Method::PUT, Method::DELETE, Method::GET, Method::POST]); 30 | 31 | let get_questions = warp::get() 32 | .and(warp::path("questions")) 33 | .and(warp::path::end()) 34 | .and(warp::query()) 35 | .and(store_filter.clone()) 36 | .and_then(routes::question::get_questions) 37 | .with(warp::trace(|info| { 38 | tracing::info_span!( 39 | "get_questions request", 40 | method = %info.method(), 41 | path = %info.path(), 42 | id = %uuid::Uuid::new_v4(), 43 | )}) 44 | ); 45 | 46 | let update_question = warp::put() 47 | .and(warp::path("questions")) 48 | .and(warp::path::param::()) 49 | .and(warp::path::end()) 50 | .and(store_filter.clone()) 51 | .and(warp::body::json()) 52 | .and_then(routes::question::update_question); 53 | 54 | let delete_question = warp::delete() 55 | .and(warp::path("questions")) 56 | .and(warp::path::param::()) 57 | .and(warp::path::end()) 58 | .and(store_filter.clone()) 59 | .and_then(routes::question::delete_question); 60 | 61 | let add_question = warp::post() 62 | .and(warp::path("questions")) 63 | .and(warp::path::end()) 64 | .and(store_filter.clone()) 65 | .and(warp::body::json()) 66 | .and_then(routes::question::add_question); 67 | 68 | let add_answer = warp::post() 69 | .and(warp::path("comments")) 70 | .and(warp::path::end()) 71 | .and(store_filter.clone()) 72 | .and(warp::body::form()) 73 | .and_then(routes::answer::add_answer); 74 | 75 | let routes = get_questions 76 | .or(update_question) 77 | .or(add_question) 78 | .or(add_answer) 79 | .or(delete_question) 80 | .with(cors) 81 | .with(warp::trace::request()) 82 | .recover(return_error); 83 | 84 | warp::serve(routes).run(([127, 0, 0, 1], 3030)).await; 85 | } 86 | -------------------------------------------------------------------------------- /ch_06/src/routes/answer.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use warp::http::StatusCode; 3 | 4 | use crate::store::Store; 5 | use crate::types::{ 6 | answer::{Answer, AnswerId}, 7 | question::QuestionId, 8 | }; 9 | 10 | pub async fn add_answer( 11 | store: Store, 12 | params: HashMap, 13 | ) -> Result { 14 | let answer = Answer { 15 | id: AnswerId("1".to_string()), 16 | content: params.get("content").unwrap().to_string(), 17 | question_id: QuestionId(params.get("questionId").unwrap().to_string()), 18 | }; 19 | 20 | store.answers.write().await.insert(answer.id.clone(), answer); 21 | 22 | Ok(warp::reply::with_status("Answer added", StatusCode::OK)) 23 | } 24 | -------------------------------------------------------------------------------- /ch_06/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod question; 3 | -------------------------------------------------------------------------------- /ch_06/src/routes/question.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use warp::http::StatusCode; 4 | use tracing::{instrument, info}; 5 | 6 | use handle_errors::Error; 7 | use crate::store::Store; 8 | use crate::types::pagination::extract_pagination; 9 | use crate::types::question::{Question, QuestionId}; 10 | 11 | #[instrument] 12 | pub async fn get_questions( 13 | params: HashMap, 14 | store: Store, 15 | ) -> Result { 16 | info!("querying questions"); 17 | if !params.is_empty() { 18 | let pagination = extract_pagination(params)?; 19 | info!(pagination = true); 20 | let res: Vec = store.questions.read().await.values().cloned().collect(); 21 | let res = &res[pagination.start..pagination.end]; 22 | Ok(warp::reply::json(&res)) 23 | } else { 24 | info!(pagination = false); 25 | let res: Vec = store.questions.read().await.values().cloned().collect(); 26 | Ok(warp::reply::json(&res)) 27 | } 28 | } 29 | 30 | pub async fn update_question( 31 | id: String, 32 | store: Store, 33 | question: Question, 34 | ) -> Result { 35 | match store.questions.write().await.get_mut(&QuestionId(id)) { 36 | Some(q) => *q = question, 37 | None => return Err(warp::reject::custom(Error::QuestionNotFound)), 38 | } 39 | 40 | Ok(warp::reply::with_status("Question updated", StatusCode::OK)) 41 | } 42 | 43 | pub async fn delete_question( 44 | id: String, 45 | store: Store, 46 | ) -> Result { 47 | match store.questions.write().await.remove(&QuestionId(id)) { 48 | Some(_) => (), 49 | None => return Err(warp::reject::custom(Error::QuestionNotFound)), 50 | } 51 | 52 | Ok(warp::reply::with_status("Question deleted", StatusCode::OK)) 53 | } 54 | 55 | pub async fn add_question( 56 | store: Store, 57 | question: Question, 58 | ) -> Result { 59 | store 60 | .questions 61 | .write() 62 | .await 63 | .insert(question.clone().id, question); 64 | 65 | Ok(warp::reply::with_status("Question added", StatusCode::OK)) 66 | } 67 | -------------------------------------------------------------------------------- /ch_06/src/store.rs: -------------------------------------------------------------------------------- 1 | use tokio::sync::RwLock; 2 | use std::collections::HashMap; 3 | use std::sync::Arc; 4 | 5 | use crate::types::{ 6 | answer::{Answer, AnswerId}, 7 | question::{Question, QuestionId}, 8 | }; 9 | 10 | #[derive(Debug, Clone)] 11 | pub struct Store { 12 | pub questions: Arc>>, 13 | pub answers: Arc>>, 14 | } 15 | 16 | impl Store { 17 | pub fn new() -> Self { 18 | Store { 19 | questions: Arc::new(RwLock::new(Self::init())), 20 | answers: Arc::new(RwLock::new(HashMap::new())), 21 | } 22 | } 23 | 24 | fn init() -> HashMap { 25 | let file = include_str!("../questions.json"); 26 | serde_json::from_str(file).expect("can't read questions.json") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ch_06/src/types/answer.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::types::question::QuestionId; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone)] 6 | pub struct Answer { 7 | pub id: AnswerId, 8 | pub content: String, 9 | pub question_id: QuestionId, 10 | } 11 | 12 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 13 | pub struct AnswerId(pub String); 14 | -------------------------------------------------------------------------------- /ch_06/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod pagination; 3 | pub mod question; 4 | -------------------------------------------------------------------------------- /ch_06/src/types/pagination.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use handle_errors::Error; 4 | 5 | /// Pagination struct which is getting extract 6 | /// from query params 7 | #[derive(Debug)] 8 | pub struct Pagination { 9 | /// The index of the first item which has to be returned 10 | pub start: usize, 11 | /// The index of the last item which has to be returned 12 | pub end: usize, 13 | } 14 | /// Extract query parameters from the `/questions` route 15 | /// # Example query 16 | /// GET requests to this route can have a pagination attached so we just 17 | /// return the questions we need 18 | /// `/questions?start=1&end=10` 19 | /// # Example usage 20 | /// ```rust 21 | /// use std::collections::HashMap; 22 | /// 23 | /// let mut query = HashMap::new(); 24 | /// query.insert("start".to_string(), "1".to_string()); 25 | /// query.insert("end".to_string(), "10".to_string()); 26 | /// let p = pagination::extract_pagination(query).unwrap(); 27 | /// assert_eq!(p.start, 1); 28 | /// assert_eq!(p.end, 10); 29 | /// ``` 30 | pub fn extract_pagination(params: HashMap) -> Result { 31 | // Could be improved in the future 32 | if params.contains_key("start") && params.contains_key("end") { 33 | return Ok(Pagination { 34 | // Takes the "start" parameter in the query and tries to convert it to a number 35 | start: params 36 | .get("start") 37 | .unwrap() 38 | .parse::() 39 | .map_err(Error::ParseError)?, 40 | // Takes the "end" parameter in the query and tries to convert it to a number 41 | end: params 42 | .get("end") 43 | .unwrap() 44 | .parse::() 45 | .map_err(Error::ParseError)?, 46 | }); 47 | } 48 | 49 | Err(Error::MissingParameters) 50 | } 51 | -------------------------------------------------------------------------------- /ch_06/src/types/question.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | #[derive(Deserialize, Serialize, Debug, Clone)] 3 | pub struct Question { 4 | pub id: QuestionId, 5 | pub title: String, 6 | pub content: String, 7 | pub tags: Option>, 8 | } 9 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 10 | pub struct QuestionId(pub String); 11 | -------------------------------------------------------------------------------- /ch_07/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug executable 'practical-rust-book'", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--bin=practical-rust-book", 15 | "--package=practical-rust-book" 16 | ], 17 | "filter": { 18 | "name": "practical-rust-book", 19 | "kind": "bin" 20 | } 21 | }, 22 | "args": [], 23 | "cwd": "${workspaceFolder}" 24 | }, 25 | { 26 | "type": "lldb", 27 | "request": "launch", 28 | "name": "Debug unit tests in executable 'practical-rust-book'", 29 | "cargo": { 30 | "args": [ 31 | "test", 32 | "--no-run", 33 | "--bin=practical-rust-book", 34 | "--package=practical-rust-book" 35 | ], 36 | "filter": { 37 | "name": "practical-rust-book", 38 | "kind": "bin" 39 | } 40 | }, 41 | "args": [ 42 | 43 | ], 44 | "cwd": "${workspaceFolder}" 45 | } 46 | ] 47 | } -------------------------------------------------------------------------------- /ch_07/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "practical-rust-book" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | serde = { version = "1.0", features = ["derive"] } 9 | serde_json = "1.0" 10 | tokio = { version = "1.1.1", features = ["full"] } 11 | # We can omit the version number for local imports 12 | handle-errors = { path = "handle-errors" } 13 | log = "0.4" 14 | env_logger = "0.9" 15 | log4rs = "1.0" 16 | uuid = { version = "0.8", features = ["v4"] } 17 | tracing = { version = "0.1", features = ["log"] } 18 | tracing-subscriber = "0.2" 19 | sqlx = { version = "0.5", features = [ "runtime-tokio-rustls", "migrate", "postgres" ] } 20 | -------------------------------------------------------------------------------- /ch_07/handle-errors/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "handle-errors" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | tracing = { version = "0.1", features = ["log"] } -------------------------------------------------------------------------------- /ch_07/handle-errors/src/lib.rs: -------------------------------------------------------------------------------- 1 | use warp::{ 2 | filters::{body::BodyDeserializeError, cors::CorsForbidden}, 3 | http::StatusCode, 4 | reject::Reject, 5 | Rejection, Reply, 6 | }; 7 | use tracing::{event, Level, instrument}; 8 | 9 | #[derive(Debug)] 10 | pub enum Error { 11 | ParseError(std::num::ParseIntError), 12 | MissingParameters, 13 | DatabaseQueryError, 14 | } 15 | 16 | impl std::fmt::Display for Error { 17 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 18 | match &*self { 19 | Error::ParseError(ref err) => write!(f, "Cannot parse parameter: {}", err), 20 | Error::MissingParameters => write!(f, "Missing parameter"), 21 | Error::DatabaseQueryError => write!(f, "Cannot update, invalid data."), 22 | } 23 | } 24 | } 25 | 26 | impl Reject for Error {} 27 | 28 | #[instrument] 29 | pub async fn return_error(r: Rejection) -> Result { 30 | if let Some(crate::Error::DatabaseQueryError) = r.find() { 31 | event!(Level::ERROR, "Database query error"); 32 | Ok(warp::reply::with_status( 33 | crate::Error::DatabaseQueryError.to_string(), 34 | StatusCode::UNPROCESSABLE_ENTITY, 35 | )) 36 | } else if let Some(error) = r.find::() { 37 | event!(Level::ERROR, "CORS forbidden error: {}", error); 38 | Ok(warp::reply::with_status( 39 | error.to_string(), 40 | StatusCode::FORBIDDEN, 41 | )) 42 | } else if let Some(error) = r.find::() { 43 | event!(Level::ERROR, "Cannot deserizalize request body: {}", error); 44 | Ok(warp::reply::with_status( 45 | error.to_string(), 46 | StatusCode::UNPROCESSABLE_ENTITY, 47 | )) 48 | } else if let Some(error) = r.find::() { 49 | event!(Level::ERROR, "{}", error); 50 | Ok(warp::reply::with_status( 51 | error.to_string(), 52 | StatusCode::UNPROCESSABLE_ENTITY, 53 | )) 54 | } else { 55 | event!(Level::WARN, "Requested route was not found"); 56 | Ok(warp::reply::with_status( 57 | "Route not found".to_string(), 58 | StatusCode::NOT_FOUND, 59 | )) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /ch_07/log4rs.yaml: -------------------------------------------------------------------------------- 1 | refresh_rate: 30 seconds 2 | appenders: 3 | stdout: 4 | kind: console 5 | encoder: 6 | kind: json 7 | file: 8 | kind: file 9 | path: "stderr.log" 10 | encoder: 11 | kind: json 12 | 13 | root: 14 | level: info 15 | appenders: 16 | - stdout 17 | - file 18 | -------------------------------------------------------------------------------- /ch_07/migrations/20220509150516_questions_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS questions; -------------------------------------------------------------------------------- /ch_07/migrations/20220509150516_questions_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS questions ( 2 | id serial PRIMARY KEY, 3 | title VARCHAR (255) NOT NULL, 4 | content TEXT NOT NULL, 5 | tags TEXT [], 6 | created_on TIMESTAMP NOT NULL DEFAULT NOW() 7 | ); 8 | -------------------------------------------------------------------------------- /ch_07/migrations/20220514145724_answers_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS answers; 2 | -------------------------------------------------------------------------------- /ch_07/migrations/20220514145724_answers_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS answers ( 2 | id serial PRIMARY KEY, 3 | content TEXT NOT NULL, 4 | created_on TIMESTAMP NOT NULL DEFAULT NOW(), 5 | corresponding_question integer REFERENCES questions 6 | ); 7 | -------------------------------------------------------------------------------- /ch_07/questions.json: -------------------------------------------------------------------------------- 1 | { 2 | "QI0001" : { 3 | "id": "QI0001", 4 | "title": "First question ever asked", 5 | "content": "How does this work?", 6 | "tags": ["general"], 7 | "comments": ["CI001"], 8 | "upvotes": null 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ch_07/rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 75 -------------------------------------------------------------------------------- /ch_07/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all)] 2 | 3 | use handle_errors::return_error; 4 | use tracing_subscriber::fmt::format::FmtSpan; 5 | use warp::{http::Method, Filter}; 6 | 7 | mod routes; 8 | mod store; 9 | mod types; 10 | 11 | #[tokio::main] 12 | async fn main() { 13 | let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| { 14 | "handle_errors=warn,practical_rust_book=warn,warp=warn".to_owned() 15 | }); 16 | 17 | let store = 18 | store::Store::new("postgres://localhost:5432/rustwebdev").await; 19 | 20 | sqlx::migrate!() 21 | .run(&store.clone().connection) 22 | .await 23 | .expect("Cannot migrate DB"); 24 | 25 | let store_filter = warp::any().map(move || store.clone()); 26 | 27 | tracing_subscriber::fmt() 28 | // Use the filter we built above to determine which traces to record. 29 | .with_env_filter(log_filter) 30 | // Record an event when each span closes. This can be used to time our 31 | // routes' durations! 32 | .with_span_events(FmtSpan::CLOSE) 33 | .init(); 34 | 35 | let cors = warp::cors() 36 | .allow_any_origin() 37 | .allow_header("content-type") 38 | .allow_methods(&[ 39 | Method::PUT, 40 | Method::DELETE, 41 | Method::GET, 42 | Method::POST, 43 | ]); 44 | 45 | let get_questions = warp::get() 46 | .and(warp::path("questions")) 47 | .and(warp::path::end()) 48 | .and(warp::query()) 49 | .and(store_filter.clone()) 50 | .and_then(routes::question::get_questions); 51 | 52 | let update_question = warp::put() 53 | .and(warp::path("questions")) 54 | .and(warp::path::param::()) 55 | .and(warp::path::end()) 56 | .and(store_filter.clone()) 57 | .and(warp::body::json()) 58 | .and_then(routes::question::update_question); 59 | 60 | let delete_question = warp::delete() 61 | .and(warp::path("questions")) 62 | .and(warp::path::param::()) 63 | .and(warp::path::end()) 64 | .and(store_filter.clone()) 65 | .and_then(routes::question::delete_question); 66 | 67 | let add_question = warp::post() 68 | .and(warp::path("questions")) 69 | .and(warp::path::end()) 70 | .and(store_filter.clone()) 71 | .and(warp::body::json()) 72 | .and_then(routes::question::add_question); 73 | 74 | let add_answer = warp::post() 75 | .and(warp::path("answers")) 76 | .and(warp::path::end()) 77 | .and(store_filter.clone()) 78 | .and(warp::body::form()) 79 | .and_then(routes::answer::add_answer); 80 | 81 | let routes = get_questions 82 | .or(update_question) 83 | .or(add_question) 84 | .or(delete_question) 85 | .or(add_answer) 86 | .with(cors) 87 | .with(warp::trace::request()) 88 | .recover(return_error); 89 | 90 | warp::serve(routes).run(([127, 0, 0, 1], 3030)).await; 91 | } 92 | -------------------------------------------------------------------------------- /ch_07/src/routes/answer.rs: -------------------------------------------------------------------------------- 1 | use warp::http::StatusCode; 2 | 3 | use crate::store::Store; 4 | use crate::types::answer::NewAnswer; 5 | 6 | pub async fn add_answer( 7 | store: Store, 8 | new_answer: NewAnswer, 9 | ) -> Result { 10 | match store.add_answer(new_answer).await { 11 | Ok(_) => { 12 | Ok(warp::reply::with_status("Answer added", StatusCode::OK)) 13 | } 14 | Err(e) => Err(warp::reject::custom(e)), 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ch_07/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod question; 3 | -------------------------------------------------------------------------------- /ch_07/src/routes/question.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use tracing::{event, instrument, Level}; 4 | use warp::http::StatusCode; 5 | 6 | use crate::store::Store; 7 | use crate::types::pagination::{extract_pagination, Pagination}; 8 | use crate::types::question::{NewQuestion, Question}; 9 | 10 | #[instrument] 11 | pub async fn get_questions( 12 | params: HashMap, 13 | store: Store, 14 | ) -> Result { 15 | event!(target: "practical_rust_book", Level::INFO, "querying questions"); 16 | let mut pagination = Pagination::default(); 17 | 18 | if !params.is_empty() { 19 | event!(Level::INFO, pagination = true); 20 | pagination = extract_pagination(params)?; 21 | } 22 | 23 | match store 24 | .get_questions(pagination.limit, pagination.offset) 25 | .await 26 | { 27 | Ok(res) => Ok(warp::reply::json(&res)), 28 | Err(e) => Err(warp::reject::custom(e)), 29 | } 30 | } 31 | 32 | pub async fn update_question( 33 | id: i32, 34 | store: Store, 35 | question: Question, 36 | ) -> Result { 37 | match store.update_question(question, id).await { 38 | Ok(res) => Ok(warp::reply::json(&res)), 39 | Err(e) => Err(warp::reject::custom(e)), 40 | } 41 | } 42 | 43 | pub async fn delete_question( 44 | id: i32, 45 | store: Store, 46 | ) -> Result { 47 | match store.delete_question(id).await { 48 | Ok(_) => Ok(warp::reply::with_status( 49 | format!("Question {} deleted", id), 50 | StatusCode::OK, 51 | )), 52 | Err(e) => Err(warp::reject::custom(e)), 53 | } 54 | } 55 | 56 | pub async fn add_question( 57 | store: Store, 58 | new_question: NewQuestion, 59 | ) -> Result { 60 | match store.add_question(new_question).await { 61 | Ok(_) => { 62 | Ok(warp::reply::with_status("Question added", StatusCode::OK)) 63 | } 64 | Err(e) => Err(warp::reject::custom(e)), 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /ch_07/src/store.rs: -------------------------------------------------------------------------------- 1 | use sqlx::postgres::{PgPool, PgPoolOptions, PgRow}; 2 | use sqlx::Row; 3 | 4 | use handle_errors::Error; 5 | 6 | use crate::types::{ 7 | answer::{Answer, AnswerId, NewAnswer}, 8 | question::{NewQuestion, Question, QuestionId}, 9 | }; 10 | 11 | #[derive(Debug, Clone)] 12 | pub struct Store { 13 | pub connection: PgPool, 14 | } 15 | 16 | impl Store { 17 | pub async fn new(db_url: &str) -> Self { 18 | let db_pool = match PgPoolOptions::new() 19 | .max_connections(5) 20 | .connect(db_url) 21 | .await 22 | { 23 | Ok(pool) => pool, 24 | Err(e) => panic!("Couldn't establish DB connection: {}", e), 25 | }; 26 | 27 | Store { 28 | connection: db_pool, 29 | } 30 | } 31 | 32 | pub async fn get_questions( 33 | &self, 34 | limit: Option, 35 | offset: u32, 36 | ) -> Result, Error> { 37 | match sqlx::query("SELECT * from questions LIMIT $1 OFFSET $2") 38 | .bind(limit) 39 | .bind(offset) 40 | .map(|row: PgRow| Question { 41 | id: QuestionId(row.get("id")), 42 | title: row.get("title"), 43 | content: row.get("content"), 44 | tags: row.get("tags"), 45 | }) 46 | .fetch_all(&self.connection) 47 | .await 48 | { 49 | Ok(questions) => Ok(questions), 50 | Err(e) => { 51 | tracing::event!(tracing::Level::ERROR, "{:?}", e); 52 | Err(Error::DatabaseQueryError) 53 | } 54 | } 55 | } 56 | 57 | pub async fn add_question( 58 | &self, 59 | new_question: NewQuestion, 60 | ) -> Result { 61 | match sqlx::query( 62 | "INSERT INTO questions (title, content, tags) 63 | VALUES ($1, $2, $3) 64 | RETURNING id, title, content, tags", 65 | ) 66 | .bind(new_question.title) 67 | .bind(new_question.content) 68 | .bind(new_question.tags) 69 | .map(|row: PgRow| Question { 70 | id: QuestionId(row.get("id")), 71 | title: row.get("title"), 72 | content: row.get("content"), 73 | tags: row.get("tags"), 74 | }) 75 | .fetch_one(&self.connection) 76 | .await 77 | { 78 | Ok(question) => Ok(question), 79 | Err(e) => { 80 | tracing::event!(tracing::Level::ERROR, "{:?}", e); 81 | Err(Error::DatabaseQueryError) 82 | } 83 | } 84 | } 85 | 86 | pub async fn update_question( 87 | &self, 88 | question: Question, 89 | question_id: i32, 90 | ) -> Result { 91 | match sqlx::query( 92 | "UPDATE questions SET title = $1, content = $2, tags = $3 93 | WHERE id = $4 94 | RETURNING id, title, content, tags", 95 | ) 96 | .bind(question.title) 97 | .bind(question.content) 98 | .bind(question.tags) 99 | .bind(question_id) 100 | .map(|row: PgRow| Question { 101 | id: QuestionId(row.get("id")), 102 | title: row.get("title"), 103 | content: row.get("content"), 104 | tags: row.get("tags"), 105 | }) 106 | .fetch_one(&self.connection) 107 | .await 108 | { 109 | Ok(question) => Ok(question), 110 | Err(e) => { 111 | tracing::event!(tracing::Level::ERROR, "{:?}", e); 112 | Err(Error::DatabaseQueryError) 113 | } 114 | } 115 | } 116 | 117 | pub async fn delete_question( 118 | &self, 119 | question_id: i32, 120 | ) -> Result { 121 | match sqlx::query("DELETE FROM questions WHERE id = $1") 122 | .bind(question_id) 123 | .execute(&self.connection) 124 | .await 125 | { 126 | Ok(_) => Ok(true), 127 | Err(e) => { 128 | tracing::event!(tracing::Level::ERROR, "{:?}", e); 129 | Err(Error::DatabaseQueryError) 130 | } 131 | } 132 | } 133 | 134 | pub async fn add_answer( 135 | &self, 136 | new_answer: NewAnswer, 137 | ) -> Result { 138 | match sqlx::query( 139 | "INSERT INTO answers (content, question_id) VALUES ($1, $2)", 140 | ) 141 | .bind(new_answer.content) 142 | .bind(new_answer.question_id.0) 143 | .map(|row: PgRow| Answer { 144 | id: AnswerId(row.get("id")), 145 | content: row.get("content"), 146 | question_id: QuestionId(row.get("question_id")), 147 | }) 148 | .fetch_one(&self.connection) 149 | .await 150 | { 151 | Ok(answer) => Ok(answer), 152 | Err(e) => { 153 | tracing::event!(tracing::Level::ERROR, "{:?}", e); 154 | Err(Error::DatabaseQueryError) 155 | } 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /ch_07/src/types/answer.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::types::question::QuestionId; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone)] 6 | pub struct Answer { 7 | pub id: AnswerId, 8 | pub content: String, 9 | pub question_id: QuestionId, 10 | } 11 | 12 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 13 | pub struct AnswerId(pub i32); 14 | 15 | #[derive(Deserialize, Serialize, Debug, Clone)] 16 | pub struct NewAnswer { 17 | pub content: String, 18 | pub question_id: QuestionId, 19 | } 20 | -------------------------------------------------------------------------------- /ch_07/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod pagination; 3 | pub mod question; 4 | -------------------------------------------------------------------------------- /ch_07/src/types/pagination.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use handle_errors::Error; 4 | 5 | /// Pagination struct which is getting extract 6 | /// from query params 7 | #[derive(Default, Debug)] 8 | pub struct Pagination { 9 | /// The index of the last item which has to be returned 10 | pub limit: Option, 11 | /// The index of the first item which has to be returned 12 | pub offset: u32, 13 | } 14 | 15 | /// Extract query parameters from the `/questions` route 16 | /// # Example query 17 | /// GET requests to this route can have a pagination attached so we just 18 | /// return the questions we need 19 | /// `/questions?start=1&end=10` 20 | /// # Example usage 21 | /// ```rust 22 | /// use std::collections::HashMap; 23 | /// 24 | /// let mut query = HashMap::new(); 25 | /// query.insert("limit".to_string(), "1".to_string()); 26 | /// query.insert("offset".to_string(), "10".to_string()); 27 | /// let p = pagination::extract_pagination(query).unwrap(); 28 | /// assert_eq!(p.limit, Some(1)); 29 | /// assert_eq!(p.offset, 10); 30 | /// ``` 31 | pub fn extract_pagination( 32 | params: HashMap, 33 | ) -> Result { 34 | // Could be improved in the future 35 | if params.contains_key("limit") && params.contains_key("offset") { 36 | return Ok(Pagination { 37 | // Takes the "limit" parameter in the query and tries to convert it to a number 38 | limit: Some( 39 | params 40 | .get("limit") 41 | .unwrap() 42 | .parse::() 43 | .map_err(Error::ParseError)?, 44 | ), 45 | // Takes the "offset" parameter in the query and tries to convert it to a number 46 | offset: params 47 | .get("offset") 48 | .unwrap() 49 | .parse::() 50 | .map_err(Error::ParseError)?, 51 | }); 52 | } 53 | 54 | Err(Error::MissingParameters) 55 | } 56 | -------------------------------------------------------------------------------- /ch_07/src/types/question.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize, Debug, Clone)] 4 | pub struct Question { 5 | pub id: QuestionId, 6 | pub title: String, 7 | pub content: String, 8 | pub tags: Option>, 9 | } 10 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 11 | pub struct QuestionId(pub i32); 12 | 13 | #[derive(Deserialize, Serialize, Debug, Clone)] 14 | pub struct NewQuestion { 15 | pub title: String, 16 | pub content: String, 17 | pub tags: Option>, 18 | } 19 | -------------------------------------------------------------------------------- /ch_08/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug executable 'practical-rust-book'", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--bin=practical-rust-book", 15 | "--package=practical-rust-book" 16 | ], 17 | "filter": { 18 | "name": "practical-rust-book", 19 | "kind": "bin" 20 | } 21 | }, 22 | "args": [], 23 | "cwd": "${workspaceFolder}" 24 | }, 25 | { 26 | "type": "lldb", 27 | "request": "launch", 28 | "name": "Debug unit tests in executable 'practical-rust-book'", 29 | "cargo": { 30 | "args": [ 31 | "test", 32 | "--no-run", 33 | "--bin=practical-rust-book", 34 | "--package=practical-rust-book" 35 | ], 36 | "filter": { 37 | "name": "practical-rust-book", 38 | "kind": "bin" 39 | } 40 | }, 41 | "args": [ 42 | 43 | ], 44 | "cwd": "${workspaceFolder}" 45 | } 46 | ] 47 | } -------------------------------------------------------------------------------- /ch_08/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "practical-rust-book" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | serde = { version = "1.0", features = ["derive"] } 9 | serde_json = "1.0" 10 | tokio = { version = "1.1.1", features = ["full"] } 11 | handle-errors = { path = "handle-errors", version = "0.1.0" } 12 | log = "0.4" 13 | env_logger = "0.8" 14 | log4rs = "1.0" 15 | uuid = { version = "0.8", features = ["v4"] } 16 | tracing = { version = "0.1", features = ["log"] } 17 | tracing-subscriber = "0.2" 18 | sqlx = { version = "0.5", features = [ "runtime-tokio-rustls", "migrate", "postgres" ] } 19 | reqwest = { version = "0.11", features = ["json"] } 20 | reqwest-middleware = "0.1.1" 21 | reqwest-retry = "0.1.1" 22 | -------------------------------------------------------------------------------- /ch_08/handle-errors/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "handle-errors" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | tracing = { version = "0.1", features = ["log"] } 9 | reqwest = "0.11" 10 | reqwest-middleware = "0.1.1" -------------------------------------------------------------------------------- /ch_08/handle-errors/src/lib.rs: -------------------------------------------------------------------------------- 1 | use warp::{ 2 | filters::{body::BodyDeserializeError, cors::CorsForbidden}, 3 | http::StatusCode, 4 | reject::Reject, 5 | Rejection, Reply, 6 | }; 7 | use tracing::{event, Level, instrument}; 8 | use reqwest::Error as ReqwestError; 9 | use reqwest_middleware::Error as MiddlewareReqwestError; 10 | 11 | 12 | #[derive(Debug)] 13 | pub enum Error { 14 | ParseError(std::num::ParseIntError), 15 | MissingParameters, 16 | DatabaseQueryError, 17 | ReqwestAPIError(ReqwestError), 18 | MiddlewareReqwestAPIError(MiddlewareReqwestError), 19 | ClientError(APILayerError), 20 | ServerError(APILayerError) 21 | } 22 | 23 | #[derive(Debug, Clone)] 24 | pub struct APILayerError { 25 | pub status: u16, 26 | pub message: String, 27 | } 28 | 29 | impl std::fmt::Display for APILayerError { 30 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 31 | write!(f, "Status: {}, Message: {}", self.status, self.message) 32 | } 33 | } 34 | 35 | impl std::fmt::Display for Error { 36 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 37 | match &*self { 38 | Error::ParseError(ref err) => write!(f, "Cannot parse parameter: {}", err), 39 | Error::MissingParameters => write!(f, "Missing parameter"), 40 | Error::DatabaseQueryError => write!(f, "Cannot update, invalid data."), 41 | Error::ReqwestAPIError(err) => write!(f, "External API error: {}", err), 42 | Error::MiddlewareReqwestAPIError(err) => write!(f, "External API error: {}", err), 43 | Error::ClientError(err) => write!(f, "External Client error: {}", err), 44 | Error::ServerError(err) => write!(f, "External Server error: {}", err), 45 | } 46 | } 47 | } 48 | 49 | impl Reject for Error {} 50 | impl Reject for APILayerError {} 51 | 52 | #[instrument] 53 | pub async fn return_error(r: Rejection) -> Result { 54 | if let Some(crate::Error::DatabaseQueryError) = r.find() { 55 | event!(Level::ERROR, "Database query error"); 56 | Ok(warp::reply::with_status( 57 | crate::Error::DatabaseQueryError.to_string(), 58 | StatusCode::UNPROCESSABLE_ENTITY, 59 | )) 60 | } else if let Some(crate::Error::ReqwestAPIError(e)) = r.find() { 61 | event!(Level::ERROR, "{}", e); 62 | Ok(warp::reply::with_status( 63 | "Internal Server Error".to_string(), 64 | StatusCode::INTERNAL_SERVER_ERROR, 65 | )) 66 | } else if let Some(crate::Error::MiddlewareReqwestAPIError(e)) = r.find() { 67 | event!(Level::ERROR, "{}", e); 68 | Ok(warp::reply::with_status( 69 | "Internal Server Error".to_string(), 70 | StatusCode::INTERNAL_SERVER_ERROR, 71 | )) 72 | } else if let Some(crate::Error::ClientError(e)) = r.find() { 73 | event!(Level::ERROR, "{}", e); 74 | Ok(warp::reply::with_status( 75 | "Internal Server Error".to_string(), 76 | StatusCode::INTERNAL_SERVER_ERROR, 77 | )) 78 | } else if let Some(crate::Error::ServerError(e)) = r.find() { 79 | event!(Level::ERROR, "{}", e); 80 | Ok(warp::reply::with_status( 81 | "Internal Server Error".to_string(), 82 | StatusCode::INTERNAL_SERVER_ERROR, 83 | )) 84 | } else if let Some(error) = r.find::() { 85 | event!(Level::ERROR, "CORS forbidden error: {}", error); 86 | Ok(warp::reply::with_status( 87 | error.to_string(), 88 | StatusCode::FORBIDDEN, 89 | )) 90 | } else if let Some(error) = r.find::() { 91 | event!(Level::ERROR, "Cannot deserizalize request body: {}", error); 92 | Ok(warp::reply::with_status( 93 | error.to_string(), 94 | StatusCode::UNPROCESSABLE_ENTITY, 95 | )) 96 | } else if let Some(error) = r.find::() { 97 | event!(Level::ERROR, "{}", error); 98 | Ok(warp::reply::with_status( 99 | error.to_string(), 100 | StatusCode::UNPROCESSABLE_ENTITY, 101 | )) 102 | } else { 103 | event!(Level::WARN, "Requested route was not found"); 104 | Ok(warp::reply::with_status( 105 | "Route not found".to_string(), 106 | StatusCode::NOT_FOUND, 107 | )) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /ch_08/migrations/20220509150516_questions_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS questions; -------------------------------------------------------------------------------- /ch_08/migrations/20220509150516_questions_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS questions ( 2 | id serial PRIMARY KEY, 3 | title VARCHAR (255) NOT NULL, 4 | content TEXT NOT NULL, 5 | tags TEXT [], 6 | created_on TIMESTAMP NOT NULL DEFAULT NOW() 7 | ); 8 | -------------------------------------------------------------------------------- /ch_08/migrations/20220514145724_answers_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS answers; 2 | -------------------------------------------------------------------------------- /ch_08/migrations/20220514145724_answers_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS answers ( 2 | id serial PRIMARY KEY, 3 | content TEXT NOT NULL, 4 | created_on TIMESTAMP NOT NULL DEFAULT NOW(), 5 | corresponding_question integer REFERENCES questions 6 | ); 7 | -------------------------------------------------------------------------------- /ch_08/rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 75 -------------------------------------------------------------------------------- /ch_08/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all)] 2 | 3 | use handle_errors::return_error; 4 | use tracing_subscriber::fmt::format::FmtSpan; 5 | use warp::{http::Method, Filter}; 6 | 7 | mod profanity; 8 | mod routes; 9 | mod store; 10 | mod types; 11 | 12 | #[tokio::main] 13 | async fn main() { 14 | let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| { 15 | "handle_errors=warn,practical_rust_book=warn,warp=warn".to_owned() 16 | }); 17 | 18 | let store = 19 | store::Store::new("postgres://localhost:5432/rustwebdev").await; 20 | 21 | sqlx::migrate!() 22 | .run(&store.clone().connection) 23 | .await 24 | .expect("Cannot run migrations"); 25 | 26 | let store_filter = warp::any().map(move || store.clone()); 27 | 28 | tracing_subscriber::fmt() 29 | // Use the filter we built above to determine which traces to record. 30 | .with_env_filter(log_filter) 31 | // Record an event when each span closes. This can be used to time our 32 | // routes' durations! 33 | .with_span_events(FmtSpan::CLOSE) 34 | .init(); 35 | 36 | let cors = warp::cors() 37 | .allow_any_origin() 38 | .allow_header("content-type") 39 | .allow_methods(&[ 40 | Method::PUT, 41 | Method::DELETE, 42 | Method::GET, 43 | Method::POST, 44 | ]); 45 | 46 | let get_questions = warp::get() 47 | .and(warp::path("questions")) 48 | .and(warp::path::end()) 49 | .and(warp::query()) 50 | .and(store_filter.clone()) 51 | .and_then(routes::question::get_questions); 52 | 53 | let update_question = warp::put() 54 | .and(warp::path("questions")) 55 | .and(warp::path::param::()) 56 | .and(warp::path::end()) 57 | .and(store_filter.clone()) 58 | .and(warp::body::json()) 59 | .and_then(routes::question::update_question); 60 | 61 | let delete_question = warp::delete() 62 | .and(warp::path("questions")) 63 | .and(warp::path::param::()) 64 | .and(warp::path::end()) 65 | .and(store_filter.clone()) 66 | .and_then(routes::question::delete_question); 67 | 68 | let add_question = warp::post() 69 | .and(warp::path("questions")) 70 | .and(warp::path::end()) 71 | .and(store_filter.clone()) 72 | .and(warp::body::json()) 73 | .and_then(routes::question::add_question); 74 | 75 | let add_answer = warp::post() 76 | .and(warp::path("answers")) 77 | .and(warp::path::end()) 78 | .and(store_filter.clone()) 79 | .and(warp::body::form()) 80 | .and_then(routes::answer::add_answer); 81 | 82 | let routes = get_questions 83 | .or(update_question) 84 | .or(add_question) 85 | .or(delete_question) 86 | .or(add_answer) 87 | .with(cors) 88 | .with(warp::trace::request()) 89 | .recover(return_error); 90 | 91 | warp::serve(routes).run(([127, 0, 0, 1], 3030)).await; 92 | } 93 | -------------------------------------------------------------------------------- /ch_08/src/profanity.rs: -------------------------------------------------------------------------------- 1 | use reqwest_middleware::ClientBuilder; 2 | use reqwest_retry::{ 3 | policies::ExponentialBackoff, RetryTransientMiddleware, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Deserialize, Serialize, Debug, Clone)] 8 | pub struct APIResponse { 9 | message: String, 10 | } 11 | 12 | #[derive(Deserialize, Serialize, Debug, Clone)] 13 | struct BadWord { 14 | original: String, 15 | word: String, 16 | deviations: i64, 17 | info: i64, 18 | #[serde(rename = "replacedLen")] 19 | replaced_len: i64, 20 | } 21 | 22 | #[derive(Deserialize, Serialize, Debug, Clone)] 23 | struct BadWordsResponse { 24 | content: String, 25 | bad_words_total: i64, 26 | bad_words_list: Vec, 27 | censored_content: String, 28 | } 29 | 30 | pub async fn check_profanity( 31 | content: String, 32 | ) -> Result { 33 | let retry_policy = 34 | ExponentialBackoff::builder().build_with_max_retries(3); 35 | let client = ClientBuilder::new(reqwest::Client::new()) 36 | // Trace HTTP requests. See the tracing crate to make use of these traces. 37 | // Retry failed requests. 38 | .with(RetryTransientMiddleware::new_with_policy(retry_policy)) 39 | .build(); 40 | 41 | let res = client 42 | .post("https://api.apilayer.com/bad_words?censor_character=*") 43 | .header("apikey", "API_KEY") 44 | .body(content) 45 | .send() 46 | .await 47 | .map_err(|e| handle_errors::Error::MiddlewareReqwestAPIError(e))?; 48 | 49 | if !res.status().is_success() { 50 | if res.status().is_client_error() { 51 | let err = transform_error(res).await; 52 | return Err(handle_errors::Error::ClientError(err)); 53 | } else { 54 | let err = transform_error(res).await; 55 | return Err(handle_errors::Error::ServerError(err)); 56 | } 57 | } 58 | 59 | match res.json::().await { 60 | Ok(res) => Ok(res.censored_content), 61 | Err(e) => Err(handle_errors::Error::ReqwestAPIError(e)), 62 | } 63 | } 64 | 65 | async fn transform_error( 66 | res: reqwest::Response, 67 | ) -> handle_errors::APILayerError { 68 | handle_errors::APILayerError { 69 | status: res.status().as_u16(), 70 | message: res.json::().await.unwrap().message, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /ch_08/src/routes/answer.rs: -------------------------------------------------------------------------------- 1 | use warp::http::StatusCode; 2 | 3 | use crate::profanity::check_profanity; 4 | use crate::store::Store; 5 | use crate::types::answer::NewAnswer; 6 | 7 | pub async fn add_answer( 8 | store: Store, 9 | new_answer: NewAnswer, 10 | ) -> Result { 11 | let content = match 12 | check_profanity(new_answer.content).await { 13 | Ok(res) => res, 14 | Err(e) => return Err(warp::reject::custom(e)), 15 | }; 16 | 17 | let answer = NewAnswer { 18 | content, 19 | question_id: new_answer.question_id, 20 | }; 21 | 22 | match store.add_answer(answer).await { 23 | Ok(_) => { 24 | Ok(warp::reply::with_status("Answer added", StatusCode::OK)) 25 | } 26 | Err(e) => Err(warp::reject::custom(e)), 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ch_08/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod question; 3 | -------------------------------------------------------------------------------- /ch_08/src/routes/question.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use tracing::{event, instrument, Level}; 4 | use warp::http::StatusCode; 5 | 6 | use crate::profanity::check_profanity; 7 | use crate::store::Store; 8 | use crate::types::pagination::{extract_pagination, Pagination}; 9 | use crate::types::question::{NewQuestion, Question}; 10 | 11 | #[instrument] 12 | pub async fn get_questions( 13 | params: HashMap, 14 | store: Store, 15 | ) -> Result { 16 | event!(target: "practical_rust_book", Level::INFO, "querying questions"); 17 | let mut pagination = Pagination::default(); 18 | 19 | if !params.is_empty() { 20 | event!(Level::INFO, pagination = true); 21 | pagination = extract_pagination(params)?; 22 | } 23 | 24 | match store 25 | .get_questions(pagination.limit, pagination.offset) 26 | .await 27 | { 28 | Ok(res) => Ok(warp::reply::json(&res)), 29 | Err(e) => Err(warp::reject::custom(e)), 30 | } 31 | } 32 | 33 | pub async fn update_question( 34 | id: i32, 35 | store: Store, 36 | question: Question, 37 | ) -> Result { 38 | let title = check_profanity(question.title); 39 | let content = check_profanity(question.content); 40 | 41 | let (title, content) = tokio::join!(title, content); 42 | 43 | if title.is_err() { 44 | return Err(warp::reject::custom(title.unwrap_err())); 45 | } 46 | 47 | if content.is_err() { 48 | return Err(warp::reject::custom(content.unwrap_err())); 49 | } 50 | 51 | let question = Question { 52 | id: question.id, 53 | title: title.unwrap(), 54 | content: content.unwrap(), 55 | tags: question.tags, 56 | }; 57 | 58 | match store.update_question(question, id).await { 59 | Ok(res) => Ok(warp::reply::json(&res)), 60 | Err(e) => Err(warp::reject::custom(e)), 61 | } 62 | } 63 | 64 | pub async fn delete_question( 65 | id: i32, 66 | store: Store, 67 | ) -> Result { 68 | match store.delete_question(id).await { 69 | Ok(_) => Ok(warp::reply::with_status( 70 | format!("Question {} deleted", id), 71 | StatusCode::OK, 72 | )), 73 | Err(e) => Err(warp::reject::custom(e)), 74 | } 75 | } 76 | 77 | pub async fn add_question( 78 | store: Store, 79 | new_question: NewQuestion, 80 | ) -> Result { 81 | let title = match check_profanity(new_question.title).await { 82 | Ok(res) => res, 83 | Err(e) => return Err(warp::reject::custom(e)), 84 | }; 85 | 86 | let content = match check_profanity(new_question.content).await { 87 | Ok(res) => res, 88 | Err(e) => return Err(warp::reject::custom(e)), 89 | }; 90 | 91 | let question = NewQuestion { 92 | title, 93 | content, 94 | tags: new_question.tags, 95 | }; 96 | 97 | match store.add_question(question).await { 98 | Ok(question) => Ok(warp::reply::json(&question)), 99 | Err(e) => Err(warp::reject::custom(e)), 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /ch_08/src/store.rs: -------------------------------------------------------------------------------- 1 | use sqlx::postgres::{PgPool, PgPoolOptions, PgRow}; 2 | use sqlx::Row; 3 | 4 | use handle_errors::Error; 5 | 6 | use crate::types::{ 7 | answer::{Answer, AnswerId, NewAnswer}, 8 | question::{NewQuestion, Question, QuestionId}, 9 | }; 10 | 11 | #[derive(Debug, Clone)] 12 | pub struct Store { 13 | pub connection: PgPool, 14 | } 15 | 16 | impl Store { 17 | pub async fn new(db_url: &str) -> Self { 18 | let db_pool = match PgPoolOptions::new() 19 | .max_connections(5) 20 | .connect(db_url) 21 | .await 22 | { 23 | Ok(pool) => pool, 24 | Err(e) => panic!("Couldn't establish DB connection: {}", e), 25 | }; 26 | 27 | Store { 28 | connection: db_pool, 29 | } 30 | } 31 | 32 | pub async fn get_questions( 33 | self, 34 | limit: Option, 35 | offset: i32, 36 | ) -> Result, Error> { 37 | match sqlx::query("SELECT * from questions LIMIT $1 OFFSET $2") 38 | .bind(limit) 39 | .bind(offset) 40 | .map(|row: PgRow| Question { 41 | id: QuestionId(row.get("id")), 42 | title: row.get("title"), 43 | content: row.get("content"), 44 | tags: row.get("tags"), 45 | }) 46 | .fetch_all(&self.connection) 47 | .await 48 | { 49 | Ok(questions) => Ok(questions), 50 | Err(e) => { 51 | tracing::event!(tracing::Level::ERROR, "{:?}", e); 52 | Err(Error::DatabaseQueryError) 53 | } 54 | } 55 | } 56 | 57 | pub async fn add_question( 58 | self, 59 | new_question: NewQuestion, 60 | ) -> Result { 61 | match sqlx::query("INSERT INTO questions (title, content, tags) VALUES ($1, $2, $3) RETURNING id, title, content, tags") 62 | .bind(new_question.title) 63 | .bind(new_question.content) 64 | .bind(new_question.tags) 65 | .map(|row: PgRow| Question { 66 | id: QuestionId(row.get("id")), 67 | title: row.get("title"), 68 | content: row.get("content"), 69 | tags: row.get("tags"), 70 | }) 71 | .fetch_one(&self.connection) 72 | .await { 73 | Ok(question) => Ok(question), 74 | Err(e) => { 75 | tracing::event!(tracing::Level::ERROR, "{:?}", e); 76 | Err(Error::DatabaseQueryError) 77 | }, 78 | } 79 | } 80 | 81 | pub async fn update_question( 82 | self, 83 | question: Question, 84 | id: i32, 85 | ) -> Result { 86 | match sqlx::query( 87 | "UPDATE questions SET title = $1, content = $2, tags = $3 88 | WHERE id = $4 89 | RETURNING id, title, content, tags", 90 | ) 91 | .bind(question.title) 92 | .bind(question.content) 93 | .bind(question.tags) 94 | .bind(id) 95 | .map(|row: PgRow| Question { 96 | id: QuestionId(row.get("id")), 97 | title: row.get("title"), 98 | content: row.get("content"), 99 | tags: row.get("tags"), 100 | }) 101 | .fetch_one(&self.connection) 102 | .await 103 | { 104 | Ok(question) => Ok(question), 105 | Err(e) => { 106 | tracing::event!(tracing::Level::ERROR, "{:?}", e); 107 | Err(Error::DatabaseQueryError) 108 | } 109 | } 110 | } 111 | 112 | pub async fn delete_question(self, id: i32) -> Result { 113 | match sqlx::query("DELETE FROM questions WHERE id = $1") 114 | .bind(id) 115 | .execute(&self.connection) 116 | .await 117 | { 118 | Ok(_) => Ok(true), 119 | Err(e) => { 120 | tracing::event!(tracing::Level::ERROR, "{:?}", e); 121 | Err(Error::DatabaseQueryError) 122 | } 123 | } 124 | } 125 | 126 | pub async fn add_answer( 127 | &self, 128 | new_answer: NewAnswer, 129 | ) -> Result { 130 | match sqlx::query( 131 | "INSERT INTO answers (content, question_id) VALUES ($1, $2)", 132 | ) 133 | .bind(new_answer.content) 134 | .bind(new_answer.question_id.0) 135 | .map(|row: PgRow| Answer { 136 | id: AnswerId(row.get("id")), 137 | content: row.get("content"), 138 | question_id: QuestionId(row.get("question_id")), 139 | }) 140 | .fetch_one(&self.connection) 141 | .await 142 | { 143 | Ok(answer) => Ok(answer), 144 | Err(e) => { 145 | tracing::event!(tracing::Level::ERROR, "{:?}", e); 146 | Err(Error::DatabaseQueryError) 147 | } 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /ch_08/src/types/answer.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::types::question::QuestionId; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone)] 6 | pub struct Answer { 7 | pub id: AnswerId, 8 | pub content: String, 9 | pub question_id: QuestionId, 10 | } 11 | 12 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 13 | pub struct AnswerId(pub i32); 14 | 15 | #[derive(Deserialize, Serialize, Debug, Clone)] 16 | pub struct NewAnswer { 17 | pub content: String, 18 | pub question_id: QuestionId, 19 | } 20 | -------------------------------------------------------------------------------- /ch_08/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod pagination; 3 | pub mod question; 4 | -------------------------------------------------------------------------------- /ch_08/src/types/pagination.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use handle_errors::Error; 4 | 5 | /// Pagination struct which is getting extract 6 | /// from query params 7 | #[derive(Default, Debug)] 8 | pub struct Pagination { 9 | /// The index of the last item which has to be returned 10 | pub limit: Option, 11 | /// The index of the first item which has to be returned 12 | pub offset: i32, 13 | } 14 | 15 | /// Extract query parameters from the `/questions` route 16 | /// # Example query 17 | /// GET requests to this route can have a pagination attached so we just 18 | /// return the questions we need 19 | /// `/questions?start=1&end=10` 20 | /// # Example usage 21 | /// ```rust 22 | /// use std::collections::HashMap; 23 | /// 24 | /// let mut query = HashMap::new(); 25 | /// query.insert("limit".to_string(), "1".to_string()); 26 | /// query.insert("offset".to_string(), "10".to_string()); 27 | /// let p = pagination::extract_pagination(query).unwrap(); 28 | /// assert_eq!(p.limit, Some(1)); 29 | /// assert_eq!(p.offset, 10); 30 | /// ``` 31 | pub fn extract_pagination( 32 | params: HashMap, 33 | ) -> Result { 34 | // Could be improved in the future 35 | if params.contains_key("limit") && params.contains_key("offset") { 36 | return Ok(Pagination { 37 | // Takes the "limit" parameter in the query and tries to convert it to a number 38 | limit: Some( 39 | params 40 | .get("limit") 41 | .unwrap() 42 | .parse() 43 | .map_err(Error::ParseError)?, 44 | ), 45 | // Takes the "offset" parameter in the query and tries to convert it to a number 46 | offset: params 47 | .get("offset") 48 | .unwrap() 49 | .parse() 50 | .map_err(Error::ParseError)?, 51 | }); 52 | } 53 | 54 | Err(Error::MissingParameters) 55 | } 56 | -------------------------------------------------------------------------------- /ch_08/src/types/question.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize, Debug, Clone)] 4 | pub struct Question { 5 | pub id: QuestionId, 6 | pub title: String, 7 | pub content: String, 8 | pub tags: Option>, 9 | } 10 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 11 | pub struct QuestionId(pub i32); 12 | 13 | #[derive(Deserialize, Serialize, Debug, Clone)] 14 | pub struct NewQuestion { 15 | pub title: String, 16 | pub content: String, 17 | pub tags: Option>, 18 | } 19 | -------------------------------------------------------------------------------- /ch_09/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug executable 'practical-rust-book'", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--bin=practical-rust-book", 15 | "--package=practical-rust-book" 16 | ], 17 | "filter": { 18 | "name": "practical-rust-book", 19 | "kind": "bin" 20 | } 21 | }, 22 | "args": [], 23 | "cwd": "${workspaceFolder}" 24 | }, 25 | { 26 | "type": "lldb", 27 | "request": "launch", 28 | "name": "Debug unit tests in executable 'practical-rust-book'", 29 | "cargo": { 30 | "args": [ 31 | "test", 32 | "--no-run", 33 | "--bin=practical-rust-book", 34 | "--package=practical-rust-book" 35 | ], 36 | "filter": { 37 | "name": "practical-rust-book", 38 | "kind": "bin" 39 | } 40 | }, 41 | "args": [ 42 | 43 | ], 44 | "cwd": "${workspaceFolder}" 45 | } 46 | ] 47 | } -------------------------------------------------------------------------------- /ch_09/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "practical-rust-book" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | serde = { version = "1.0", features = ["derive"] } 9 | serde_json = "1.0" 10 | tokio = { version = "1.1.1", features = ["full"] } 11 | handle-errors = { path = "handle-errors", version = "0.1.0" } 12 | log = "0.4" 13 | env_logger = "0.9" 14 | log4rs = "1.0" 15 | uuid = { version = "0.8", features = ["serde", "v4"] } 16 | tracing = { version = "0.1", features = ["log"] } 17 | tracing-subscriber = "0.2" 18 | sqlx = { version = "0.5", features = [ "runtime-tokio-rustls", "migrate", "postgres" ] } 19 | reqwest = { version = "0.11", features = ["json"] } 20 | reqwest-middleware = "0.1.1" 21 | reqwest-retry = "0.1.1" 22 | rand = "0.8" 23 | rust-argon2 = "1.0" 24 | paseto = "2.0" 25 | chrono = "0.4.19" -------------------------------------------------------------------------------- /ch_09/handle-errors/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "handle-errors" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | warp = "0.3" 10 | tracing = { version = "0.1", features = ["log"] } 11 | reqwest = "0.11" 12 | reqwest-middleware = "0.1.1" 13 | sqlx = { version = "0.5", features = [ "postgres" ] } 14 | rust-argon2 = "1.0" -------------------------------------------------------------------------------- /ch_09/handle-errors/src/lib.rs: -------------------------------------------------------------------------------- 1 | use argon2::Error as ArgonError; 2 | use reqwest::Error as ReqwestError; 3 | use reqwest_middleware::Error as MiddlewareReqwestError; 4 | use tracing::{event, instrument, Level}; 5 | use warp::{ 6 | filters::{body::BodyDeserializeError, cors::CorsForbidden}, 7 | http::StatusCode, 8 | reject::Reject, 9 | Rejection, Reply, 10 | }; 11 | 12 | #[derive(Debug)] 13 | pub enum Error { 14 | ParseError(std::num::ParseIntError), 15 | MissingParameters, 16 | WrongPassword, 17 | CannotDecryptToken, 18 | Unauthorized, 19 | ArgonLibraryError(ArgonError), 20 | DatabaseQueryError(sqlx::Error), 21 | ReqwestAPIError(ReqwestError), 22 | MiddlewareReqwestAPIError(MiddlewareReqwestError), 23 | ClientError(APILayerError), 24 | ServerError(APILayerError), 25 | } 26 | 27 | #[derive(Debug, Clone)] 28 | pub struct APILayerError { 29 | pub status: u16, 30 | pub message: String, 31 | } 32 | 33 | impl std::fmt::Display for APILayerError { 34 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 35 | write!(f, "Status: {}, Message: {}", self.status, self.message) 36 | } 37 | } 38 | 39 | impl std::fmt::Display for Error { 40 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 41 | match &*self { 42 | Error::ParseError(ref err) => { 43 | write!(f, "Cannot parse parameter: {}", err) 44 | } 45 | Error::MissingParameters => write!(f, "Missing parameter"), 46 | Error::WrongPassword => write!(f, "Wrong password"), 47 | Error::CannotDecryptToken => write!(f, "Cannot decrypt error"), 48 | Error::Unauthorized => write!( 49 | f, 50 | "No permission to change the underlying resource" 51 | ), 52 | Error::ArgonLibraryError(_) => { 53 | write!(f, "Cannot verifiy password") 54 | }, 55 | Error::DatabaseQueryError(_) => { 56 | write!(f, "Cannot update, invalid data") 57 | }, 58 | Error::ReqwestAPIError(err) => { 59 | write!(f, "External API error: {}", err) 60 | }, 61 | Error::MiddlewareReqwestAPIError(err) => { 62 | write!(f, "External API error: {}", err) 63 | } 64 | Error::ClientError(err) => { 65 | write!(f, "External Client error: {}", err) 66 | } 67 | Error::ServerError(err) => { 68 | write!(f, "External Server error: {}", err) 69 | } 70 | } 71 | } 72 | } 73 | 74 | const DUPLICATE_KEY: u32 = 23505; 75 | 76 | impl Reject for Error {} 77 | impl Reject for APILayerError {} 78 | 79 | #[instrument] 80 | pub async fn return_error(r: Rejection) -> Result { 81 | if let Some(crate::Error::DatabaseQueryError(e)) = r.find() { 82 | event!(Level::ERROR, "Database query error"); 83 | 84 | match e { 85 | sqlx::Error::Database(err) => { 86 | if err.code().unwrap().parse::().unwrap() 87 | == DUPLICATE_KEY 88 | { 89 | Ok(warp::reply::with_status( 90 | "Account already exsists".to_string(), 91 | StatusCode::UNPROCESSABLE_ENTITY, 92 | )) 93 | } else { 94 | Ok(warp::reply::with_status( 95 | "Cannot update data".to_string(), 96 | StatusCode::UNPROCESSABLE_ENTITY, 97 | )) 98 | } 99 | } 100 | _ => Ok(warp::reply::with_status( 101 | "Cannot update data".to_string(), 102 | StatusCode::UNPROCESSABLE_ENTITY, 103 | )), 104 | } 105 | } else if let Some(crate::Error::ReqwestAPIError(e)) = r.find() { 106 | event!(Level::ERROR, "{}", e); 107 | Ok(warp::reply::with_status( 108 | "Internal Server Error".to_string(), 109 | StatusCode::INTERNAL_SERVER_ERROR, 110 | )) 111 | } else if let Some(crate::Error::Unauthorized) = r.find() { 112 | event!(Level::ERROR, "Not matching account id"); 113 | Ok(warp::reply::with_status( 114 | "No permission to change underlying resource".to_string(), 115 | StatusCode::UNAUTHORIZED, 116 | )) 117 | } else if let Some(crate::Error::WrongPassword) = r.find() { 118 | event!(Level::ERROR, "Entered wrong password"); 119 | Ok(warp::reply::with_status( 120 | "Wrong E-Mail/Password combination".to_string(), 121 | StatusCode::UNAUTHORIZED, 122 | )) 123 | } else if let Some(crate::Error::MiddlewareReqwestAPIError(e)) = 124 | r.find() 125 | { 126 | event!(Level::ERROR, "{}", e); 127 | Ok(warp::reply::with_status( 128 | "Internal Server Error".to_string(), 129 | StatusCode::INTERNAL_SERVER_ERROR, 130 | )) 131 | } else if let Some(crate::Error::ClientError(e)) = r.find() { 132 | event!(Level::ERROR, "{}", e); 133 | Ok(warp::reply::with_status( 134 | "Internal Server Error".to_string(), 135 | StatusCode::INTERNAL_SERVER_ERROR, 136 | )) 137 | } else if let Some(crate::Error::ServerError(e)) = r.find() { 138 | event!(Level::ERROR, "{}", e); 139 | Ok(warp::reply::with_status( 140 | "Internal Server Error".to_string(), 141 | StatusCode::INTERNAL_SERVER_ERROR, 142 | )) 143 | } else if let Some(error) = r.find::() { 144 | event!(Level::ERROR, "CORS forbidden error: {}", error); 145 | Ok(warp::reply::with_status( 146 | error.to_string(), 147 | StatusCode::FORBIDDEN, 148 | )) 149 | } else if let Some(error) = r.find::() { 150 | event!( 151 | Level::ERROR, 152 | "Cannot deserizalize request body: {}", 153 | error 154 | ); 155 | Ok(warp::reply::with_status( 156 | error.to_string(), 157 | StatusCode::UNPROCESSABLE_ENTITY, 158 | )) 159 | } else if let Some(error) = r.find::() { 160 | event!(Level::ERROR, "{}", error); 161 | Ok(warp::reply::with_status( 162 | error.to_string(), 163 | StatusCode::UNPROCESSABLE_ENTITY, 164 | )) 165 | } else { 166 | event!(Level::WARN, "Requested route was not found"); 167 | Ok(warp::reply::with_status( 168 | "Route not found".to_string(), 169 | StatusCode::NOT_FOUND, 170 | )) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /ch_09/migrations/20220509150516_questions_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS questions; -------------------------------------------------------------------------------- /ch_09/migrations/20220509150516_questions_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS questions ( 2 | id serial PRIMARY KEY, 3 | title VARCHAR (255) NOT NULL, 4 | content TEXT NOT NULL, 5 | tags TEXT [], 6 | created_on TIMESTAMP NOT NULL DEFAULT NOW() 7 | ); 8 | -------------------------------------------------------------------------------- /ch_09/migrations/20220514145724_answers_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS answers; 2 | -------------------------------------------------------------------------------- /ch_09/migrations/20220514145724_answers_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS answers ( 2 | id serial PRIMARY KEY, 3 | content TEXT NOT NULL, 4 | created_on TIMESTAMP NOT NULL DEFAULT NOW(), 5 | corresponding_question integer REFERENCES questions 6 | ); 7 | -------------------------------------------------------------------------------- /ch_09/migrations/20220523174842_create_accounts_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS accounts; -------------------------------------------------------------------------------- /ch_09/migrations/20220523174842_create_accounts_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS accounts ( 2 | id serial NOT NULL, 3 | email VARCHAR(255) NOT NULL PRIMARY KEY, 4 | password VARCHAR(255) NOT NULL 5 | ); -------------------------------------------------------------------------------- /ch_09/migrations/20220523175814_extend_questions_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions 2 | DROP COLUMN account_id; -------------------------------------------------------------------------------- /ch_09/migrations/20220523175814_extend_questions_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions 2 | ADD COLUMN account_id serial; -------------------------------------------------------------------------------- /ch_09/migrations/20220523175821_extend_answers_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE answers 2 | DROP COLUMN account_id; 3 | -------------------------------------------------------------------------------- /ch_09/migrations/20220523175821_extend_answers_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE answers 2 | ADD COLUMN account_id serial; 3 | -------------------------------------------------------------------------------- /ch_09/rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 75 -------------------------------------------------------------------------------- /ch_09/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all)] 2 | 3 | use handle_errors::return_error; 4 | use tracing_subscriber::fmt::format::FmtSpan; 5 | use warp::{http::Method, Filter}; 6 | 7 | mod profanity; 8 | mod routes; 9 | mod store; 10 | mod types; 11 | 12 | #[tokio::main] 13 | async fn main() { 14 | let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| { 15 | "handle_errors=warn,practical_rust_book=warn,warp=warn".to_owned() 16 | }); 17 | 18 | let store = 19 | store::Store::new("postgres://localhost:5432/rustwebdev").await; 20 | 21 | sqlx::migrate!() 22 | .run(&store.clone().connection) 23 | .await 24 | .expect("Cannot migrate DB"); 25 | 26 | let store_filter = warp::any().map(move || store.clone()); 27 | 28 | tracing_subscriber::fmt() 29 | // Use the filter we built above to determine which traces to record. 30 | .with_env_filter(log_filter) 31 | // Record an event when each span closes. This can be used to time our 32 | // routes' durations! 33 | .with_span_events(FmtSpan::CLOSE) 34 | .init(); 35 | 36 | let cors = warp::cors() 37 | .allow_any_origin() 38 | .allow_header("content-type") 39 | .allow_methods(&[ 40 | Method::PUT, 41 | Method::DELETE, 42 | Method::GET, 43 | Method::POST, 44 | ]); 45 | 46 | let get_questions = warp::get() 47 | .and(warp::path("questions")) 48 | .and(warp::path::end()) 49 | .and(warp::query()) 50 | .and(store_filter.clone()) 51 | .and_then(routes::question::get_questions); 52 | 53 | let update_question = warp::put() 54 | .and(warp::path("questions")) 55 | .and(warp::path::param::()) 56 | .and(warp::path::end()) 57 | .and(routes::authentication::auth()) 58 | .and(store_filter.clone()) 59 | .and(warp::body::json()) 60 | .and_then(routes::question::update_question); 61 | 62 | let delete_question = warp::delete() 63 | .and(warp::path("questions")) 64 | .and(warp::path::param::()) 65 | .and(warp::path::end()) 66 | .and(routes::authentication::auth()) 67 | .and(store_filter.clone()) 68 | .and_then(routes::question::delete_question); 69 | 70 | let add_question = warp::post() 71 | .and(warp::path("questions")) 72 | .and(warp::path::end()) 73 | .and(routes::authentication::auth()) 74 | .and(store_filter.clone()) 75 | .and(warp::body::json()) 76 | .and_then(routes::question::add_question); 77 | 78 | let add_answer = warp::post() 79 | .and(warp::path("answers")) 80 | .and(warp::path::end()) 81 | .and(routes::authentication::auth()) 82 | .and(store_filter.clone()) 83 | .and(warp::body::form()) 84 | .and_then(routes::answer::add_answer); 85 | 86 | let registration = warp::post() 87 | .and(warp::path("registration")) 88 | .and(warp::path::end()) 89 | .and(store_filter.clone()) 90 | .and(warp::body::json()) 91 | .and_then(routes::authentication::register); 92 | 93 | let login = warp::post() 94 | .and(warp::path("login")) 95 | .and(warp::path::end()) 96 | .and(store_filter.clone()) 97 | .and(warp::body::json()) 98 | .and_then(routes::authentication::login); 99 | 100 | let routes = get_questions 101 | .or(update_question) 102 | .or(add_question) 103 | .or(delete_question) 104 | .or(add_answer) 105 | .or(registration) 106 | .or(login) 107 | .with(cors) 108 | .with(warp::trace::request()) 109 | .recover(return_error); 110 | 111 | warp::serve(routes).run(([127, 0, 0, 1], 3030)).await; 112 | } 113 | -------------------------------------------------------------------------------- /ch_09/src/profanity.rs: -------------------------------------------------------------------------------- 1 | use reqwest_middleware::ClientBuilder; 2 | use reqwest_retry::{ 3 | policies::ExponentialBackoff, RetryTransientMiddleware, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Deserialize, Serialize, Debug, Clone)] 8 | pub struct APIResponse { 9 | message: String, 10 | } 11 | 12 | #[derive(Deserialize, Serialize, Debug, Clone)] 13 | struct BadWord { 14 | original: String, 15 | word: String, 16 | deviations: i64, 17 | info: i64, 18 | #[serde(rename = "replacedLen")] 19 | replaced_len: i64, 20 | } 21 | 22 | #[derive(Deserialize, Serialize, Debug, Clone)] 23 | struct BadWordsResponse { 24 | content: String, 25 | bad_words_total: i64, 26 | bad_words_list: Vec, 27 | censored_content: String, 28 | } 29 | 30 | pub async fn check_profanity( 31 | content: String, 32 | ) -> Result { 33 | let retry_policy = 34 | ExponentialBackoff::builder().build_with_max_retries(3); 35 | let client = ClientBuilder::new(reqwest::Client::new()) 36 | // Trace HTTP requests. See the tracing crate to make use of these traces. 37 | // Retry failed requests. 38 | .with(RetryTransientMiddleware::new_with_policy(retry_policy)) 39 | .build(); 40 | 41 | let res = client 42 | .post("https://api.apilayer.com/bad_words?censor_character=*") 43 | .header("apikey", "API_KEY") 44 | .body(content) 45 | .send() 46 | .await 47 | .map_err(|e| handle_errors::Error::MiddlewareReqwestAPIError(e))?; 48 | 49 | if !res.status().is_success() { 50 | if res.status().is_client_error() { 51 | let err = transform_error(res).await; 52 | return Err(handle_errors::Error::ClientError(err)); 53 | } else { 54 | let err = transform_error(res).await; 55 | return Err(handle_errors::Error::ServerError(err)); 56 | } 57 | } 58 | 59 | match res.json::().await { 60 | Ok(res) => Ok(res.censored_content), 61 | Err(e) => Err(handle_errors::Error::ReqwestAPIError(e)), 62 | } 63 | } 64 | 65 | async fn transform_error( 66 | res: reqwest::Response, 67 | ) -> handle_errors::APILayerError { 68 | handle_errors::APILayerError { 69 | status: res.status().as_u16(), 70 | message: res.json::().await.unwrap().message, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /ch_09/src/routes/answer.rs: -------------------------------------------------------------------------------- 1 | use warp::http::StatusCode; 2 | 3 | use crate::profanity::check_profanity; 4 | use crate::store::Store; 5 | use crate::types::account::Session; 6 | use crate::types::answer::NewAnswer; 7 | 8 | pub async fn add_answer( 9 | session: Session, 10 | store: Store, 11 | new_answer: NewAnswer, 12 | ) -> Result { 13 | let account_id = session.account_id; 14 | let content = match check_profanity(new_answer.content).await { 15 | Ok(res) => res, 16 | Err(e) => return Err(warp::reject::custom(e)), 17 | }; 18 | 19 | let answer = NewAnswer { 20 | content, 21 | question_id: new_answer.question_id, 22 | }; 23 | 24 | match store.add_answer(answer, account_id).await { 25 | Ok(_) => { 26 | Ok(warp::reply::with_status("Answer added", StatusCode::OK)) 27 | } 28 | Err(e) => Err(warp::reject::custom(e)), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ch_09/src/routes/authentication.rs: -------------------------------------------------------------------------------- 1 | use argon2::{self, Config}; 2 | use chrono::prelude::*; 3 | use rand::Rng; 4 | use std::future; 5 | use warp::{http::StatusCode, Filter}; 6 | 7 | use crate::store::Store; 8 | use crate::types::account::{Account, AccountId, Session}; 9 | 10 | pub async fn register( 11 | store: Store, 12 | account: Account, 13 | ) -> Result { 14 | let hashed_password = hash_password(account.password.as_bytes()); 15 | 16 | let account = Account { 17 | id: account.id, 18 | email: account.email, 19 | password: hashed_password, 20 | }; 21 | 22 | match store.add_account(account).await { 23 | Ok(_) => { 24 | Ok(warp::reply::with_status("Account added", StatusCode::OK)) 25 | } 26 | Err(e) => Err(warp::reject::custom(e)), 27 | } 28 | } 29 | 30 | pub async fn login( 31 | store: Store, 32 | login: Account, 33 | ) -> Result { 34 | match store.get_account(login.email).await { 35 | Ok(account) => match verify_password( 36 | &account.password, 37 | login.password.as_bytes(), 38 | ) { 39 | Ok(verified) => { 40 | if verified { 41 | Ok(warp::reply::json(&issue_token( 42 | account.id.expect("id not found"), 43 | ))) 44 | } else { 45 | Err(warp::reject::custom( 46 | handle_errors::Error::WrongPassword, 47 | )) 48 | } 49 | } 50 | Err(e) => Err(warp::reject::custom( 51 | handle_errors::Error::ArgonLibraryError(e), 52 | )), 53 | }, 54 | Err(e) => Err(warp::reject::custom(e)), 55 | } 56 | } 57 | 58 | pub fn verify_token( 59 | token: String, 60 | ) -> Result { 61 | let token = paseto::tokens::validate_local_token( 62 | &token, 63 | None, 64 | &"RANDOM WORDS WINTER MACINTOSH PC".as_bytes(), 65 | &paseto::tokens::TimeBackend::Chrono, 66 | ) 67 | .map_err(|_| handle_errors::Error::CannotDecryptToken)?; 68 | 69 | serde_json::from_value::(token) 70 | .map_err(|_| handle_errors::Error::CannotDecryptToken) 71 | } 72 | 73 | fn hash_password(password: &[u8]) -> String { 74 | let salt = rand::thread_rng().gen::<[u8; 32]>(); 75 | let config = Config::default(); 76 | argon2::hash_encoded(password, &salt, &config).unwrap() 77 | } 78 | 79 | fn verify_password( 80 | hash: &str, 81 | password: &[u8], 82 | ) -> Result { 83 | argon2::verify_encoded(hash, password) 84 | } 85 | 86 | fn issue_token(account_id: AccountId) -> String { 87 | let current_date_time = Utc::now(); 88 | let dt = current_date_time + chrono::Duration::days(1); 89 | 90 | paseto::tokens::PasetoBuilder::new() 91 | .set_encryption_key(&Vec::from( 92 | "RANDOM WORDS WINTER MACINTOSH PC".as_bytes(), 93 | )) 94 | .set_expiration(&dt) 95 | .set_not_before(&Utc::now()) 96 | .set_claim("account_id", serde_json::json!(account_id)) 97 | .build() 98 | .expect("Failed to construct paseto token w/ builder!") 99 | } 100 | 101 | pub fn auth( 102 | ) -> impl Filter + Clone { 103 | warp::header::("Authorization").and_then(|token: String| { 104 | let token = match verify_token(token) { 105 | Ok(t) => t, 106 | Err(_) => return future::ready(Err(warp::reject::reject())), 107 | }; 108 | 109 | future::ready(Ok(token)) 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /ch_09/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod authentication; 3 | pub mod question; 4 | -------------------------------------------------------------------------------- /ch_09/src/routes/question.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use tracing::{event, instrument, Level}; 4 | use warp::http::StatusCode; 5 | 6 | use crate::profanity::check_profanity; 7 | use crate::store::Store; 8 | use crate::types::account::Session; 9 | use crate::types::pagination::{extract_pagination, Pagination}; 10 | use crate::types::question::{NewQuestion, Question}; 11 | 12 | #[instrument] 13 | pub async fn get_questions( 14 | params: HashMap, 15 | store: Store, 16 | ) -> Result { 17 | event!(target: "practical_rust_book", Level::INFO, "querying questions"); 18 | let mut pagination = Pagination::default(); 19 | 20 | if !params.is_empty() { 21 | event!(Level::INFO, pagination = true); 22 | pagination = extract_pagination(params)?; 23 | } 24 | 25 | match store 26 | .get_questions(pagination.limit, pagination.offset) 27 | .await 28 | { 29 | Ok(res) => Ok(warp::reply::json(&res)), 30 | Err(e) => Err(warp::reject::custom(e)), 31 | } 32 | } 33 | 34 | pub async fn update_question( 35 | id: i32, 36 | session: Session, 37 | store: Store, 38 | question: Question, 39 | ) -> Result { 40 | let account_id = session.account_id; 41 | if store.is_question_owner(id, &account_id).await? { 42 | let title = check_profanity(question.title); 43 | let content = check_profanity(question.content); 44 | 45 | let (title, content) = tokio::join!(title, content); 46 | 47 | if title.is_ok() && content.is_ok() { 48 | let question = Question { 49 | id: question.id, 50 | title: title.unwrap(), 51 | content: content.unwrap(), 52 | tags: question.tags, 53 | }; 54 | match store.update_question(question, id, account_id).await { 55 | Ok(res) => Ok(warp::reply::json(&res)), 56 | Err(e) => Err(warp::reject::custom(e)), 57 | } 58 | } else { 59 | Err(warp::reject::custom( 60 | title.expect_err("Expected API call to have failed here"), 61 | )) 62 | } 63 | } else { 64 | Err(warp::reject::custom(handle_errors::Error::Unauthorized)) 65 | } 66 | } 67 | 68 | pub async fn delete_question( 69 | id: i32, 70 | session: Session, 71 | store: Store, 72 | ) -> Result { 73 | let account_id = session.account_id; 74 | if store.is_question_owner(id, &account_id).await? { 75 | match store.delete_question(id, account_id).await { 76 | Ok(_) => Ok(warp::reply::with_status( 77 | format!("Question {} deleted", id), 78 | StatusCode::OK, 79 | )), 80 | Err(e) => Err(warp::reject::custom(e)), 81 | } 82 | } else { 83 | Err(warp::reject::custom(handle_errors::Error::Unauthorized)) 84 | } 85 | } 86 | 87 | pub async fn add_question( 88 | session: Session, 89 | store: Store, 90 | new_question: NewQuestion, 91 | ) -> Result { 92 | let account_id = session.account_id; 93 | let title = match check_profanity(new_question.title).await { 94 | Ok(res) => res, 95 | Err(e) => return Err(warp::reject::custom(e)), 96 | }; 97 | 98 | let content = match check_profanity(new_question.content).await { 99 | Ok(res) => res, 100 | Err(e) => return Err(warp::reject::custom(e)), 101 | }; 102 | 103 | let question = NewQuestion { 104 | title, 105 | content, 106 | tags: new_question.tags, 107 | }; 108 | 109 | match store.add_question(question, account_id).await { 110 | Ok(question) => Ok(warp::reply::json(&question)), 111 | Err(e) => Err(warp::reject::custom(e)), 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /ch_09/src/types/account.rs: -------------------------------------------------------------------------------- 1 | use chrono::prelude::*; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize, Debug, Clone)] 5 | pub struct Session { 6 | pub exp: DateTime, 7 | pub account_id: AccountId, 8 | pub nbf: DateTime, 9 | } 10 | 11 | #[derive(Serialize, Deserialize, Debug, Clone)] 12 | pub struct Account { 13 | pub id: Option, 14 | pub email: String, 15 | pub password: String, 16 | } 17 | 18 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 19 | pub struct AccountId(pub i32); 20 | -------------------------------------------------------------------------------- /ch_09/src/types/answer.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::types::question::QuestionId; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone)] 6 | pub struct Answer { 7 | pub id: AnswerId, 8 | pub content: String, 9 | pub question_id: QuestionId, 10 | } 11 | 12 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 13 | pub struct AnswerId(pub i32); 14 | 15 | #[derive(Deserialize, Serialize, Debug, Clone)] 16 | pub struct NewAnswer { 17 | pub content: String, 18 | pub question_id: QuestionId, 19 | } 20 | -------------------------------------------------------------------------------- /ch_09/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | pub mod answer; 3 | pub mod pagination; 4 | pub mod question; 5 | -------------------------------------------------------------------------------- /ch_09/src/types/pagination.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use handle_errors::Error; 4 | 5 | /// Pagination struct which is getting extract 6 | /// from query params 7 | #[derive(Default, Debug)] 8 | pub struct Pagination { 9 | /// The index of the last item which has to be returned 10 | pub limit: Option, 11 | /// The index of the first item which has to be returned 12 | //TODO: Why i32? 13 | pub offset: i32, 14 | } 15 | 16 | /// Extract query parameters from the `/questions` route 17 | /// # Example query 18 | /// GET requests to this route can have a pagination attached so we just 19 | /// return the questions we need 20 | /// `/questions?start=1&end=10` 21 | /// # Example usage 22 | /// ```rust 23 | /// use std::collections::HashMap; 24 | /// 25 | /// let mut query = HashMap::new(); 26 | /// query.insert("limit".to_string(), "1".to_string()); 27 | /// query.insert("offset".to_string(), "10".to_string()); 28 | /// let p = pagination::extract_pagination(query).unwrap(); 29 | /// assert_eq!(p.limit, Some(1)); 30 | /// assert_eq!(p.offset, 10); 31 | /// ``` 32 | pub fn extract_pagination( 33 | params: HashMap, 34 | ) -> Result { 35 | // Could be improved in the future 36 | if params.contains_key("limit") && params.contains_key("offset") { 37 | return Ok(Pagination { 38 | // Takes the "limit" parameter in the query and tries to convert it to a number 39 | limit: Some( 40 | params 41 | .get("limit") 42 | .unwrap() 43 | .parse() 44 | .map_err(Error::ParseError)?, 45 | ), 46 | // Takes the "offset" parameter in the query and tries to convert it to a number 47 | offset: params 48 | .get("offset") 49 | .unwrap() 50 | .parse() 51 | .map_err(Error::ParseError)?, 52 | }); 53 | } 54 | 55 | Err(Error::MissingParameters) 56 | } 57 | -------------------------------------------------------------------------------- /ch_09/src/types/question.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize, Debug, Clone)] 4 | pub struct Question { 5 | pub id: QuestionId, 6 | pub title: String, 7 | pub content: String, 8 | pub tags: Option>, 9 | } 10 | 11 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 12 | pub struct QuestionId(pub i32); 13 | 14 | #[derive(Deserialize, Serialize, Debug, Clone)] 15 | pub struct NewQuestion { 16 | pub title: String, 17 | pub content: String, 18 | pub tags: Option>, 19 | } 20 | -------------------------------------------------------------------------------- /ch_10/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rust-web-dev" 3 | version = "1.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | serde = { version = "1.0", features = ["derive"] } 9 | serde_json = "1.0" 10 | tokio = { version = "1.1.1", features = ["full"] } 11 | handle-errors = { path = "handle-errors", version = "0.1.0" } 12 | tracing = { version = "0.1", features = ["log"] } 13 | tracing-subscriber = "0.2" 14 | sqlx = { version = "0.5", features = [ "runtime-tokio-rustls", "migrate", "postgres" ] } 15 | reqwest = { version = "0.11", features = ["json"] } 16 | reqwest-middleware = "0.1.1" 17 | reqwest-retry = "0.1.1" 18 | rand = "0.8" 19 | rust-argon2 = "1.0" 20 | paseto = "2.0" 21 | chrono = "0.4.19" 22 | dotenv = "0.15.0" 23 | clap = { version = "3.1.7", features = ["derive"] } 24 | proc-macro2 = "1.0.37" 25 | openssl = { version = "0.10.32", features = ["vendored"] } 26 | 27 | [build-dependencies] 28 | platforms = "2.0.0" -------------------------------------------------------------------------------- /ch_10/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:latest AS builder 2 | 3 | RUN rustup target add x86_64-unknown-linux-musl 4 | RUN apt -y update 5 | RUN apt install -y musl-tools musl-dev 6 | RUN apt-get install -y build-essential 7 | RUN apt install -y gcc-x86-64-linux-gnu 8 | 9 | WORKDIR /app 10 | 11 | COPY ./ . 12 | 13 | ENV RUSTFLAGS='-C linker=x86_64-linux-gnu-gcc' 14 | ENV CC='gcc' 15 | ENV CC_x86_64_unknown_linux_musl=x86_64-linux-gnu-gcc 16 | ENV CC_x86_64-unknown-linux-musl=x86_64-linux-gnu-gcc 17 | 18 | RUN cargo build --target x86_64-unknown-linux-musl --release 19 | 20 | FROM scratch 21 | 22 | WORKDIR /app 23 | 24 | COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rust-web-dev ./ 25 | COPY --from=builder /app/.env ./ 26 | 27 | CMD ["/app/rust-web-dev"] -------------------------------------------------------------------------------- /ch_10/build.rs: -------------------------------------------------------------------------------- 1 | use platforms::*; 2 | use std::{borrow::Cow, process::Command}; 3 | 4 | /// Generate the `cargo:` key output 5 | pub fn generate_cargo_keys() { 6 | let output = Command::new("git") 7 | .args(&["rev-parse", "--short", "HEAD"]) 8 | .output(); 9 | 10 | let commit = match output { 11 | Ok(o) if o.status.success() => { 12 | let sha = String::from_utf8_lossy(&o.stdout).trim().to_owned(); 13 | Cow::from(sha) 14 | } 15 | Ok(o) => { 16 | println!( 17 | "cargo:warning=Git command failed with status: {}", 18 | o.status 19 | ); 20 | Cow::from("unknown") 21 | } 22 | Err(err) => { 23 | println!( 24 | "cargo:warning=Failed to execute git command: {}", 25 | err 26 | ); 27 | Cow::from("unknown") 28 | } 29 | }; 30 | 31 | println!( 32 | "cargo:rustc-env=RUST_WEB_DEV_VERSION={}", 33 | get_version(&commit) 34 | ) 35 | } 36 | 37 | fn get_platform() -> String { 38 | let env_dash = if TARGET_ENV.is_some() { "-" } else { "" }; 39 | 40 | format!( 41 | "{}-{}{}{}", 42 | TARGET_ARCH.as_str(), 43 | TARGET_OS.as_str(), 44 | env_dash, 45 | TARGET_ENV.map(|x| x.as_str()).unwrap_or(""), 46 | ) 47 | } 48 | 49 | fn get_version(impl_commit: &str) -> String { 50 | let commit_dash = if impl_commit.is_empty() { "" } else { "-" }; 51 | 52 | format!( 53 | "{}{}{}-{}", 54 | std::env::var("CARGO_PKG_VERSION").unwrap_or_default(), 55 | commit_dash, 56 | impl_commit, 57 | get_platform(), 58 | ) 59 | } 60 | 61 | fn main() { 62 | generate_cargo_keys(); 63 | } 64 | -------------------------------------------------------------------------------- /ch_10/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | database: 4 | image: postgres 5 | restart: always 6 | env_file: 7 | - .env 8 | ports: 9 | - "5432:5432" 10 | volumes: 11 | - data:/var/lib/postgresql/data 12 | server: 13 | build: 14 | context: . 15 | dockerfile: Dockerfile 16 | env_file: .env 17 | depends_on: 18 | - database 19 | networks: 20 | - default 21 | ports: 22 | - "8080:8080" 23 | volumes: 24 | data: 25 | -------------------------------------------------------------------------------- /ch_10/handle-errors/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "handle-errors" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | tracing = { version = "0.1", features = ["log"] } 9 | reqwest = "0.11" 10 | reqwest-middleware = "0.1.1" 11 | sqlx = { version = "0.5", features = [ "postgres" ] } 12 | rust-argon2 = "1.0" -------------------------------------------------------------------------------- /ch_10/handle-errors/src/lib.rs: -------------------------------------------------------------------------------- 1 | use warp::{ 2 | filters::{body::BodyDeserializeError, cors::CorsForbidden}, 3 | http::StatusCode, 4 | reject::Reject, 5 | Rejection, Reply, 6 | }; 7 | use tracing::{event, Level, instrument}; 8 | use argon2::Error as ArgonError; 9 | use reqwest::Error as ReqwestError; 10 | use reqwest_middleware::Error as MiddlewareReqwestError; 11 | 12 | 13 | #[derive(Debug)] 14 | pub enum Error { 15 | ParseError(std::num::ParseIntError), 16 | MissingParameters, 17 | WrongPassword, 18 | CannotDecryptToken, 19 | Unauthorized, 20 | ArgonLibraryError(ArgonError), 21 | DatabaseQueryError(sqlx::Error), 22 | MigrationError(sqlx::migrate::MigrateError), 23 | ReqwestAPIError(ReqwestError), 24 | MiddlewareReqwestAPIError(MiddlewareReqwestError), 25 | ClientError(APILayerError), 26 | ServerError(APILayerError) 27 | } 28 | 29 | #[derive(Debug, Clone)] 30 | pub struct APILayerError { 31 | pub status: u16, 32 | pub message: String, 33 | } 34 | 35 | impl std::fmt::Display for APILayerError { 36 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 37 | write!(f, "Status: {}, Message: {}", self.status, self.message) 38 | } 39 | } 40 | 41 | impl std::fmt::Display for Error { 42 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 43 | match &*self { 44 | Error::ParseError(ref err) => write!(f, "Cannot parse parameter: {}", err), 45 | Error::MissingParameters => write!(f, "Missing parameter"), 46 | Error::WrongPassword => write!(f, "Wrong password"), 47 | Error::CannotDecryptToken => write!(f, "Cannot decrypt error"), 48 | Error::Unauthorized => write!(f, "No permission to change the underlying resource"), 49 | Error::ArgonLibraryError(_) => write!(f, "Cannot verifiy password"), 50 | Error::DatabaseQueryError(_) => write!(f, "Cannot update, invalid data"), 51 | Error::MigrationError(_) => write!(f, "Cannot migrate data"), 52 | Error::ReqwestAPIError(err) => write!(f, "External API error: {}", err), 53 | Error::MiddlewareReqwestAPIError(err) => write!(f, "External API error: {}", err), 54 | Error::ClientError(err) => write!(f, "External Client error: {}", err), 55 | Error::ServerError(err) => write!(f, "External Server error: {}", err), 56 | } 57 | } 58 | } 59 | 60 | impl Reject for Error {} 61 | impl Reject for APILayerError {} 62 | 63 | const DUPLICATE_KEY: u32 = 23505; 64 | 65 | #[instrument] 66 | pub async fn return_error(r: Rejection) -> Result { 67 | if let Some(crate::Error::DatabaseQueryError(e)) = r.find() { 68 | event!(Level::ERROR, "Database query error"); 69 | 70 | match e { 71 | sqlx::Error::Database(err) => { 72 | if err.code().unwrap().parse::().unwrap() == DUPLICATE_KEY { 73 | Ok(warp::reply::with_status( 74 | "Account already exsists".to_string(), 75 | StatusCode::UNPROCESSABLE_ENTITY, 76 | )) 77 | } else { 78 | Ok(warp::reply::with_status( 79 | "Cannot update data".to_string(), 80 | StatusCode::UNPROCESSABLE_ENTITY, 81 | )) 82 | } 83 | }, 84 | _ => { 85 | Ok(warp::reply::with_status( 86 | "Cannot update data".to_string(), 87 | StatusCode::UNPROCESSABLE_ENTITY, 88 | )) 89 | } 90 | } 91 | } else if let Some(crate::Error::ReqwestAPIError(e)) = r.find() { 92 | event!(Level::ERROR, "{}", e); 93 | Ok(warp::reply::with_status( 94 | "Internal Server Error".to_string(), 95 | StatusCode::INTERNAL_SERVER_ERROR, 96 | )) 97 | } else if let Some(crate::Error::Unauthorized) = r.find() { 98 | event!(Level::ERROR, "Not matching account id"); 99 | Ok(warp::reply::with_status( 100 | "No permission to change underlying resource".to_string(), 101 | StatusCode::UNAUTHORIZED, 102 | )) 103 | } else if let Some(crate::Error::WrongPassword) = r.find() { 104 | event!(Level::ERROR, "Entered wrong password"); 105 | Ok(warp::reply::with_status( 106 | "Wrong E-Mail/Password combination".to_string(), 107 | StatusCode::UNAUTHORIZED, 108 | )) 109 | } else if let Some(crate::Error::MiddlewareReqwestAPIError(e)) = r.find() { 110 | event!(Level::ERROR, "{}", e); 111 | Ok(warp::reply::with_status( 112 | "Internal Server Error".to_string(), 113 | StatusCode::INTERNAL_SERVER_ERROR, 114 | )) 115 | } else if let Some(crate::Error::ClientError(e)) = r.find() { 116 | event!(Level::ERROR, "{}", e); 117 | Ok(warp::reply::with_status( 118 | "Internal Server Error".to_string(), 119 | StatusCode::INTERNAL_SERVER_ERROR, 120 | )) 121 | } else if let Some(crate::Error::ServerError(e)) = r.find() { 122 | event!(Level::ERROR, "{}", e); 123 | Ok(warp::reply::with_status( 124 | "Internal Server Error".to_string(), 125 | StatusCode::INTERNAL_SERVER_ERROR, 126 | )) 127 | } else if let Some(error) = r.find::() { 128 | event!(Level::ERROR, "CORS forbidden error: {}", error); 129 | Ok(warp::reply::with_status( 130 | error.to_string(), 131 | StatusCode::FORBIDDEN, 132 | )) 133 | } else if let Some(error) = r.find::() { 134 | event!(Level::ERROR, "Cannot deserizalize request body: {}", error); 135 | Ok(warp::reply::with_status( 136 | error.to_string(), 137 | StatusCode::UNPROCESSABLE_ENTITY, 138 | )) 139 | } else if let Some(error) = r.find::() { 140 | event!(Level::ERROR, "{}", error); 141 | Ok(warp::reply::with_status( 142 | error.to_string(), 143 | StatusCode::UNPROCESSABLE_ENTITY, 144 | )) 145 | } else { 146 | event!(Level::WARN, "Requested route was not found"); 147 | Ok(warp::reply::with_status( 148 | "Route not found".to_string(), 149 | StatusCode::NOT_FOUND, 150 | )) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /ch_10/migrations/20220509150516_questions_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS questions; -------------------------------------------------------------------------------- /ch_10/migrations/20220509150516_questions_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS questions ( 2 | id serial PRIMARY KEY, 3 | title VARCHAR (255) NOT NULL, 4 | content TEXT NOT NULL, 5 | tags TEXT [], 6 | created_on TIMESTAMP NOT NULL DEFAULT NOW() 7 | ); 8 | -------------------------------------------------------------------------------- /ch_10/migrations/20220514145724_answers_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS answers; 2 | -------------------------------------------------------------------------------- /ch_10/migrations/20220514145724_answers_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS answers ( 2 | id serial PRIMARY KEY, 3 | content TEXT NOT NULL, 4 | created_on TIMESTAMP NOT NULL DEFAULT NOW(), 5 | corresponding_question integer REFERENCES questions 6 | ); 7 | -------------------------------------------------------------------------------- /ch_10/migrations/20220523174842_create_accounts_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS accounts; -------------------------------------------------------------------------------- /ch_10/migrations/20220523174842_create_accounts_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS accounts ( 2 | id serial NOT NULL, 3 | email VARCHAR(255) NOT NULL PRIMARY KEY, 4 | password VARCHAR(255) NOT NULL 5 | ); -------------------------------------------------------------------------------- /ch_10/migrations/20220523175814_extend_questions_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions 2 | DROP COLUMN account_id; -------------------------------------------------------------------------------- /ch_10/migrations/20220523175814_extend_questions_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions 2 | ADD COLUMN account_id serial; -------------------------------------------------------------------------------- /ch_10/migrations/20220523175821_extend_answers_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE answers 2 | DROP COLUMN account_id; 3 | -------------------------------------------------------------------------------- /ch_10/migrations/20220523175821_extend_answers_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE answers 2 | ADD COLUMN account_id serial; 3 | -------------------------------------------------------------------------------- /ch_10/rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 75 -------------------------------------------------------------------------------- /ch_10/src/config.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use dotenv; 3 | use std::env; 4 | 5 | /// Q&A web service API 6 | #[derive(Parser, Debug)] 7 | #[clap(author, version, about, long_about = None)] 8 | pub struct Config { 9 | /// Which errors we want to log (info, warn or error) 10 | #[clap(short, long, default_value = "warn")] 11 | pub log_level: String, 12 | /// Which PORT the server is listening to 13 | #[clap(short, long, default_value = "8080")] 14 | pub port: u16, 15 | /// Database user 16 | #[clap(long, default_value = "username")] 17 | pub db_user: String, 18 | /// Database user 19 | #[clap(long)] 20 | pub db_password: String, 21 | /// URL for the postgres database 22 | #[clap(long, default_value = "localhost")] 23 | pub db_host: String, 24 | /// PORT number for the database connection 25 | #[clap(long, default_value = "5432")] 26 | pub db_port: u16, 27 | /// Database name 28 | #[clap(long, default_value = "rustwebdev")] 29 | pub db_name: String, 30 | } 31 | 32 | impl Config { 33 | pub fn new() -> Result { 34 | dotenv::dotenv().ok(); 35 | let config = Config::parse(); 36 | 37 | if let Err(_) = env::var("BAD_WORDS_API_KEY") { 38 | panic!("BadWords API key not set"); 39 | } 40 | 41 | if let Err(_) = env::var("PASETO_KEY") { 42 | panic!("PASETO_KEY not set"); 43 | } 44 | 45 | let port = std::env::var("PORT") 46 | .ok() 47 | .map(|val| val.parse::()) 48 | .unwrap_or(Ok(config.port)) 49 | .map_err(|e| handle_errors::Error::ParseError(e))?; 50 | 51 | let db_user = 52 | env::var("POSTGRES_USER").unwrap_or(config.db_user.to_owned()); 53 | let db_password = env::var("POSTGRES_PASSWORD").unwrap(); 54 | let db_host = 55 | env::var("POSTGRES_HOST").unwrap_or(config.db_host.to_owned()); 56 | let db_port = env::var("POSTGRES_PORT") 57 | .unwrap_or(config.db_port.to_string()); 58 | let db_name = 59 | env::var("POSTGRES_DB").unwrap_or(config.db_name.to_owned()); 60 | 61 | Ok(Config { 62 | log_level: config.log_level, 63 | port, 64 | db_user, 65 | db_password, 66 | db_host, 67 | db_port: db_port 68 | .parse::() 69 | .map_err(|e| handle_errors::Error::ParseError(e))?, 70 | db_name, 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /ch_10/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all)] 2 | 3 | use handle_errors::return_error; 4 | 5 | use tracing_subscriber::fmt::format::FmtSpan; 6 | use warp::{http::Method, Filter}; 7 | 8 | mod config; 9 | mod profanity; 10 | mod routes; 11 | mod store; 12 | mod types; 13 | 14 | #[tokio::main] 15 | async fn main() -> Result<(), handle_errors::Error> { 16 | let config = config::Config::new().expect("Config can't be set"); 17 | 18 | let log_filter = format!( 19 | "handle_errors={},rust_web_dev={},warp={}", 20 | config.log_level, config.log_level, config.log_level 21 | ); 22 | 23 | let store = store::Store::new(&format!( 24 | "postgres://{}:{}@{}:{}/{}", 25 | config.db_user, 26 | config.db_password, 27 | config.db_host, 28 | config.db_port, 29 | config.db_name 30 | )) 31 | .await 32 | .map_err(|e| handle_errors::Error::DatabaseQueryError(e))?; 33 | 34 | sqlx::migrate!() 35 | .run(&store.clone().connection) 36 | .await 37 | .map_err(|e| handle_errors::Error::MigrationError(e))?; 38 | 39 | let store_filter = warp::any().map(move || store.clone()); 40 | 41 | tracing_subscriber::fmt() 42 | // Use the filter we built above to determine which traces to record. 43 | .with_env_filter(log_filter) 44 | // Record an event when each span closes. This can be used to time our 45 | // routes' durations! 46 | .with_span_events(FmtSpan::CLOSE) 47 | .init(); 48 | 49 | let cors = warp::cors() 50 | .allow_any_origin() 51 | .allow_header("content-type") 52 | .allow_methods(&[ 53 | Method::PUT, 54 | Method::DELETE, 55 | Method::GET, 56 | Method::POST, 57 | ]); 58 | 59 | let get_questions = warp::get() 60 | .and(warp::path("questions")) 61 | .and(warp::path::end()) 62 | .and(warp::query()) 63 | .and(store_filter.clone()) 64 | .and_then(routes::question::get_questions); 65 | 66 | let update_question = warp::put() 67 | .and(warp::path("questions")) 68 | .and(warp::path::param::()) 69 | .and(warp::path::end()) 70 | .and(routes::authentication::auth()) 71 | .and(store_filter.clone()) 72 | .and(warp::body::json()) 73 | .and_then(routes::question::update_question); 74 | 75 | let delete_question = warp::delete() 76 | .and(warp::path("questions")) 77 | .and(warp::path::param::()) 78 | .and(warp::path::end()) 79 | .and(routes::authentication::auth()) 80 | .and(store_filter.clone()) 81 | .and_then(routes::question::delete_question); 82 | 83 | let add_question = warp::post() 84 | .and(warp::path("questions")) 85 | .and(warp::path::end()) 86 | .and(routes::authentication::auth()) 87 | .and(store_filter.clone()) 88 | .and(warp::body::json()) 89 | .and_then(routes::question::add_question); 90 | 91 | let add_answer = warp::post() 92 | .and(warp::path("answers")) 93 | .and(warp::path::end()) 94 | .and(routes::authentication::auth()) 95 | .and(store_filter.clone()) 96 | .and(warp::body::form()) 97 | .and_then(routes::answer::add_answer); 98 | 99 | let registration = warp::post() 100 | .and(warp::path("registration")) 101 | .and(warp::path::end()) 102 | .and(store_filter.clone()) 103 | .and(warp::body::json()) 104 | .and_then(routes::authentication::register); 105 | 106 | let login = warp::post() 107 | .and(warp::path("login")) 108 | .and(warp::path::end()) 109 | .and(store_filter.clone()) 110 | .and(warp::body::json()) 111 | .and_then(routes::authentication::login); 112 | 113 | let routes = get_questions 114 | .or(update_question) 115 | .or(add_question) 116 | .or(delete_question) 117 | .or(add_answer) 118 | .or(registration) 119 | .or(login) 120 | .with(cors) 121 | .with(warp::trace::request()) 122 | .recover(return_error); 123 | 124 | tracing::info!( 125 | "Q&A service build ID {}", 126 | env!("RUST_WEB_DEV_VERSION") 127 | ); 128 | 129 | warp::serve(routes).run(([0, 0, 0, 0], config.port)).await; 130 | 131 | Ok(()) 132 | } 133 | -------------------------------------------------------------------------------- /ch_10/src/profanity.rs: -------------------------------------------------------------------------------- 1 | use reqwest_middleware::ClientBuilder; 2 | use reqwest_retry::{ 3 | policies::ExponentialBackoff, RetryTransientMiddleware, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | use std::env; 7 | 8 | #[derive(Deserialize, Serialize, Debug, Clone)] 9 | pub struct APIResponse { 10 | message: String, 11 | } 12 | 13 | #[derive(Deserialize, Serialize, Debug, Clone)] 14 | struct BadWord { 15 | original: String, 16 | word: String, 17 | deviations: i64, 18 | info: i64, 19 | #[serde(rename = "replacedLen")] 20 | replaced_len: i64, 21 | } 22 | 23 | #[derive(Deserialize, Serialize, Debug, Clone)] 24 | struct BadWordsResponse { 25 | content: String, 26 | bad_words_total: i64, 27 | bad_words_list: Vec, 28 | censored_content: String, 29 | } 30 | 31 | pub async fn check_profanity( 32 | content: String, 33 | ) -> Result { 34 | // We are already checking if the ENV VARIABLE is set inside main.rs, so safe to unwrap here 35 | let api_key = env::var("BAD_WORDS_API_KEY").unwrap(); 36 | 37 | let retry_policy = 38 | ExponentialBackoff::builder().build_with_max_retries(3); 39 | let client = ClientBuilder::new(reqwest::Client::new()) 40 | // Trace HTTP requests. See the tracing crate to make use of these traces. 41 | // Retry failed requests. 42 | .with(RetryTransientMiddleware::new_with_policy(retry_policy)) 43 | .build(); 44 | 45 | let res = client 46 | .post("https://api.apilayer.com/ 47 | bad_words?censor_character={*}") 48 | .header("apikey", api_key) 49 | .body(content) 50 | .send() 51 | .await 52 | .map_err(handle_errors::Error::MiddlewareReqwestAPIError)?; 53 | 54 | if !res.status().is_success() { 55 | if res.status().is_client_error() { 56 | let err = transform_error(res).await; 57 | return Err(handle_errors::Error::ClientError(err)); 58 | } else { 59 | let err = transform_error(res).await; 60 | return Err(handle_errors::Error::ServerError(err)); 61 | } 62 | } 63 | 64 | match res.json::().await { 65 | Ok(res) => Ok(res.censored_content), 66 | Err(e) => Err(handle_errors::Error::ReqwestAPIError(e)), 67 | } 68 | } 69 | 70 | async fn transform_error( 71 | res: reqwest::Response, 72 | ) -> handle_errors::APILayerError { 73 | handle_errors::APILayerError { 74 | status: res.status().as_u16(), 75 | message: res.json::().await.unwrap().message, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /ch_10/src/routes/answer.rs: -------------------------------------------------------------------------------- 1 | use warp::http::StatusCode; 2 | 3 | use crate::profanity::check_profanity; 4 | use crate::store::Store; 5 | use crate::types::account::Session; 6 | use crate::types::answer::NewAnswer; 7 | 8 | pub async fn add_answer( 9 | session: Session, 10 | store: Store, 11 | new_answer: NewAnswer, 12 | ) -> Result { 13 | let account_id = session.account_id; 14 | let content = match check_profanity(new_answer.content).await { 15 | Ok(res) => res, 16 | Err(e) => return Err(warp::reject::custom(e)), 17 | }; 18 | 19 | let answer = NewAnswer { 20 | content, 21 | question_id: new_answer.question_id, 22 | }; 23 | 24 | match store.add_answer(answer, account_id).await { 25 | Ok(_) => { 26 | Ok(warp::reply::with_status("Answer added", StatusCode::OK)) 27 | } 28 | Err(e) => Err(warp::reject::custom(e)), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ch_10/src/routes/authentication.rs: -------------------------------------------------------------------------------- 1 | use argon2::{self, Config}; 2 | use chrono::prelude::*; 3 | use rand::Rng; 4 | use std::{env, future}; 5 | use warp::{http::StatusCode, Filter}; 6 | 7 | use crate::store::Store; 8 | use crate::types::account::{Account, AccountId, Session}; 9 | 10 | pub async fn register( 11 | store: Store, 12 | account: Account, 13 | ) -> Result { 14 | let hashed_password = hash_password(account.password.as_bytes()); 15 | 16 | let account = Account { 17 | id: account.id, 18 | email: account.email, 19 | password: hashed_password, 20 | }; 21 | 22 | match store.add_account(account).await { 23 | Ok(_) => { 24 | Ok(warp::reply::with_status("Account added", StatusCode::OK)) 25 | } 26 | Err(e) => Err(warp::reject::custom(e)), 27 | } 28 | } 29 | 30 | pub async fn login( 31 | store: Store, 32 | login: Account, 33 | ) -> Result { 34 | match store.get_account(login.email).await { 35 | Ok(account) => match verify_password( 36 | &account.password, 37 | login.password.as_bytes(), 38 | ) { 39 | Ok(verified) => { 40 | if verified { 41 | Ok(warp::reply::json(&issue_token( 42 | account.id.expect("id not found"), 43 | ))) 44 | } else { 45 | Err(warp::reject::custom( 46 | handle_errors::Error::WrongPassword, 47 | )) 48 | } 49 | } 50 | Err(e) => Err(warp::reject::custom( 51 | handle_errors::Error::ArgonLibraryError(e), 52 | )), 53 | }, 54 | Err(e) => Err(warp::reject::custom(e)), 55 | } 56 | } 57 | 58 | pub fn verify_token( 59 | token: String, 60 | ) -> Result { 61 | let key = env::var("PASETO_KEY").unwrap(); 62 | let token = paseto::tokens::validate_local_token( 63 | &token, 64 | None, 65 | key.as_bytes(), 66 | &paseto::tokens::TimeBackend::Chrono, 67 | ) 68 | .map_err(|_| handle_errors::Error::CannotDecryptToken)?; 69 | 70 | serde_json::from_value::(token) 71 | .map_err(|_| handle_errors::Error::CannotDecryptToken) 72 | } 73 | 74 | fn hash_password(password: &[u8]) -> String { 75 | let salt = rand::thread_rng().gen::<[u8; 32]>(); 76 | let config = Config::default(); 77 | argon2::hash_encoded(password, &salt, &config).unwrap() 78 | } 79 | 80 | fn verify_password( 81 | hash: &str, 82 | password: &[u8], 83 | ) -> Result { 84 | argon2::verify_encoded(hash, password) 85 | } 86 | 87 | fn issue_token(account_id: AccountId) -> String { 88 | let key = env::var("PASETO_KEY").unwrap(); 89 | 90 | let current_date_time = Utc::now(); 91 | let dt = current_date_time + chrono::Duration::days(1); 92 | 93 | paseto::tokens::PasetoBuilder::new() 94 | .set_encryption_key(&Vec::from(key.as_bytes())) 95 | .set_expiration(&dt) 96 | .set_not_before(&Utc::now()) 97 | .set_claim("account_id", serde_json::json!(account_id)) 98 | .build() 99 | .expect("Failed to construct paseto token w/ builder!") 100 | } 101 | 102 | pub fn auth( 103 | ) -> impl Filter + Clone { 104 | warp::header::("Authorization").and_then(|token: String| { 105 | let token = match verify_token(token) { 106 | Ok(t) => t, 107 | Err(_) => return future::ready(Err(warp::reject::reject())), 108 | }; 109 | 110 | future::ready(Ok(token)) 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /ch_10/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod authentication; 3 | pub mod question; 4 | -------------------------------------------------------------------------------- /ch_10/src/routes/question.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use tracing::{event, instrument, Level}; 4 | use warp::http::StatusCode; 5 | 6 | use crate::profanity::check_profanity; 7 | use crate::store::Store; 8 | use crate::types::account::Session; 9 | use crate::types::pagination::{extract_pagination, Pagination}; 10 | use crate::types::question::{NewQuestion, Question}; 11 | 12 | #[instrument] 13 | pub async fn get_questions( 14 | params: HashMap, 15 | store: Store, 16 | ) -> Result { 17 | event!(target: "practical_rust_book", Level::INFO, "querying questions"); 18 | let mut pagination = Pagination::default(); 19 | 20 | if !params.is_empty() { 21 | event!(Level::INFO, pagination = true); 22 | pagination = extract_pagination(params)?; 23 | } 24 | 25 | match store 26 | .get_questions(pagination.limit, pagination.offset) 27 | .await 28 | { 29 | Ok(res) => Ok(warp::reply::json(&res)), 30 | Err(e) => Err(warp::reject::custom(e)), 31 | } 32 | } 33 | 34 | pub async fn update_question( 35 | id: i32, 36 | session: Session, 37 | store: Store, 38 | question: Question, 39 | ) -> Result { 40 | let account_id = session.account_id; 41 | if store.is_question_owner(id, &account_id).await? { 42 | let title = check_profanity(question.title); 43 | let content = check_profanity(question.content); 44 | 45 | let (title, content) = tokio::join!(title, content); 46 | 47 | if title.is_ok() && content.is_ok() { 48 | let question = Question { 49 | id: question.id, 50 | title: title.unwrap(), 51 | content: content.unwrap(), 52 | tags: question.tags, 53 | }; 54 | match store.update_question(question, id, account_id).await { 55 | Ok(res) => Ok(warp::reply::json(&res)), 56 | Err(e) => Err(warp::reject::custom(e)), 57 | } 58 | } else { 59 | Err(warp::reject::custom( 60 | title.expect_err("Expected API call to have failed here"), 61 | )) 62 | } 63 | } else { 64 | Err(warp::reject::custom(handle_errors::Error::Unauthorized)) 65 | } 66 | } 67 | 68 | pub async fn delete_question( 69 | id: i32, 70 | session: Session, 71 | store: Store, 72 | ) -> Result { 73 | let account_id = session.account_id; 74 | if store.is_question_owner(id, &account_id).await? { 75 | match store.delete_question(id, account_id).await { 76 | Ok(_) => Ok(warp::reply::with_status( 77 | format!("Question {} deleted", id), 78 | StatusCode::OK, 79 | )), 80 | Err(e) => Err(warp::reject::custom(e)), 81 | } 82 | } else { 83 | Err(warp::reject::custom(handle_errors::Error::Unauthorized)) 84 | } 85 | } 86 | 87 | pub async fn add_question( 88 | session: Session, 89 | store: Store, 90 | new_question: NewQuestion, 91 | ) -> Result { 92 | let account_id = session.account_id; 93 | let title = match check_profanity(new_question.title).await { 94 | Ok(res) => res, 95 | Err(e) => return Err(warp::reject::custom(e)), 96 | }; 97 | 98 | let content = match check_profanity(new_question.content).await { 99 | Ok(res) => res, 100 | Err(e) => return Err(warp::reject::custom(e)), 101 | }; 102 | 103 | let question = NewQuestion { 104 | title, 105 | content, 106 | tags: new_question.tags, 107 | }; 108 | 109 | match store.add_question(question, account_id).await { 110 | Ok(question) => Ok(warp::reply::json(&question)), 111 | Err(e) => Err(warp::reject::custom(e)), 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /ch_10/src/types/account.rs: -------------------------------------------------------------------------------- 1 | use chrono::prelude::*; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize, Debug, Clone)] 5 | pub struct Session { 6 | pub exp: DateTime, 7 | pub account_id: AccountId, 8 | pub nbf: DateTime, 9 | } 10 | 11 | #[derive(Serialize, Deserialize, Debug, Clone)] 12 | pub struct Account { 13 | pub id: Option, 14 | pub email: String, 15 | pub password: String, 16 | } 17 | 18 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 19 | pub struct AccountId(pub i32); 20 | -------------------------------------------------------------------------------- /ch_10/src/types/answer.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::types::question::QuestionId; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone)] 6 | pub struct Answer { 7 | pub id: AnswerId, 8 | pub content: String, 9 | pub question_id: QuestionId, 10 | } 11 | 12 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 13 | pub struct AnswerId(pub i32); 14 | 15 | #[derive(Deserialize, Serialize, Debug, Clone)] 16 | pub struct NewAnswer { 17 | pub content: String, 18 | pub question_id: QuestionId, 19 | } 20 | -------------------------------------------------------------------------------- /ch_10/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | pub mod answer; 3 | pub mod pagination; 4 | pub mod question; 5 | -------------------------------------------------------------------------------- /ch_10/src/types/pagination.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use handle_errors::Error; 4 | 5 | /// Pagination struct which is getting extract 6 | /// from query params 7 | #[derive(Default, Debug)] 8 | pub struct Pagination { 9 | /// The index of the last item which has to be returned 10 | pub limit: Option, 11 | /// The index of the first item which has to be returned 12 | //TODO: Why i32? 13 | pub offset: i32, 14 | } 15 | 16 | /// Extract query parameters from the `/questions` route 17 | /// # Example query 18 | /// GET requests to this route can have a pagination attached so we just 19 | /// return the questions we need 20 | /// `/questions?start=1&end=10` 21 | /// # Example usage 22 | /// ```rust 23 | /// use std::collections::HashMap; 24 | /// 25 | /// let mut query = HashMap::new(); 26 | /// query.insert("limit".to_string(), "1".to_string()); 27 | /// query.insert("offset".to_string(), "10".to_string()); 28 | /// let p = pagination::extract_pagination(query).unwrap(); 29 | /// assert_eq!(p.limit, Some(1)); 30 | /// assert_eq!(p.offset, 10); 31 | /// ``` 32 | pub fn extract_pagination( 33 | params: HashMap, 34 | ) -> Result { 35 | // Could be improved in the future 36 | if params.contains_key("limit") && params.contains_key("offset") { 37 | return Ok(Pagination { 38 | // Takes the "limit" parameter in the query and tries to convert it to a number 39 | limit: Some( 40 | params 41 | .get("limit") 42 | .unwrap() 43 | .parse() 44 | .map_err(Error::ParseError)?, 45 | ), 46 | // Takes the "offset" parameter in the query and tries to convert it to a number 47 | offset: params 48 | .get("offset") 49 | .unwrap() 50 | .parse() 51 | .map_err(Error::ParseError)?, 52 | }); 53 | } 54 | 55 | Err(Error::MissingParameters) 56 | } 57 | -------------------------------------------------------------------------------- /ch_10/src/types/question.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize, Debug, Clone)] 4 | pub struct Question { 5 | pub id: QuestionId, 6 | pub title: String, 7 | pub content: String, 8 | pub tags: Option>, 9 | } 10 | 11 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 12 | pub struct QuestionId(pub i32); 13 | 14 | #[derive(Deserialize, Serialize, Debug, Clone)] 15 | pub struct NewQuestion { 16 | pub title: String, 17 | pub content: String, 18 | pub tags: Option>, 19 | } 20 | -------------------------------------------------------------------------------- /ch_11/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rust-web-dev" 3 | version = "1.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | handle-errors = { path = "handle-errors", version = "0.1.0" } 8 | mock-server = { path = "mock-server", version = " 0.1.0" } 9 | warp = "0.3" 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_json = "1.0" 12 | tokio = { version = "1.1.1", features = ["full"] } 13 | tracing = { version = "0.1", features = ["log"] } 14 | tracing-subscriber = "0.2" 15 | sqlx = { version = "0.5", features = [ "runtime-tokio-rustls", "migrate", "postgres" ] } 16 | reqwest = { version = "0.11", features = ["json"] } 17 | reqwest-middleware = "0.1.1" 18 | reqwest-retry = "0.1.1" 19 | rand = "0.8" 20 | rust-argon2 = "1.0" 21 | paseto = "2.0" 22 | chrono = "0.4.19" 23 | dotenv = "0.15.0" 24 | clap = { version = "3.1.7", features = ["derive"] } 25 | proc-macro2 = "1.0.37" 26 | openssl = { version = "0.10.32", features = ["vendored"] } 27 | 28 | [build-dependencies] 29 | platforms = "2.0.0" -------------------------------------------------------------------------------- /ch_11/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:latest AS builder 2 | 3 | RUN rustup target add x86_64-unknown-linux-musl 4 | RUN apt -y update 5 | RUN apt install -y musl-tools musl-dev 6 | RUN apt-get install -y build-essential 7 | RUN apt install -y gcc-x86-64-linux-gnu 8 | 9 | WORKDIR /app 10 | 11 | COPY ./ . 12 | 13 | ENV RUSTFLAGS='-C linker=x86_64-linux-gnu-gcc' 14 | ENV CC='gcc' 15 | ENV CC_x86_64_unknown_linux_musl=x86_64-linux-gnu-gcc 16 | ENV CC_x86_64-unknown-linux-musl=x86_64-linux-gnu-gcc 17 | 18 | RUN cargo build --target x86_64-unknown-linux-musl --release 19 | 20 | FROM scratch 21 | 22 | WORKDIR /app 23 | 24 | COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rust-web-dev ./ 25 | COPY --from=builder /app/.env ./ 26 | 27 | CMD ["/app/rust-web-dev"] -------------------------------------------------------------------------------- /ch_11/build.rs: -------------------------------------------------------------------------------- 1 | use platforms::*; 2 | use std::{borrow::Cow, process::Command}; 3 | 4 | /// Generate the `cargo:` key output 5 | pub fn generate_cargo_keys() { 6 | let output = Command::new("git") 7 | .args(&["rev-parse", "--short", "HEAD"]) 8 | .output(); 9 | 10 | let commit = match output { 11 | Ok(o) if o.status.success() => { 12 | let sha = String::from_utf8_lossy(&o.stdout).trim().to_owned(); 13 | Cow::from(sha) 14 | } 15 | Ok(o) => { 16 | println!("cargo:warning=Git command failed with status: {}", o.status); 17 | Cow::from("unknown") 18 | } 19 | Err(err) => { 20 | println!("cargo:warning=Failed to execute git command: {}", err); 21 | Cow::from("unknown") 22 | } 23 | }; 24 | 25 | println!( 26 | "cargo:rustc-env=RUST_WEB_DEV_VERSION={}", 27 | get_version(&commit) 28 | ) 29 | } 30 | 31 | fn get_platform() -> String { 32 | let env_dash = if TARGET_ENV.is_some() { "-" } else { "" }; 33 | 34 | format!( 35 | "{}-{}{}{}", 36 | TARGET_ARCH.as_str(), 37 | TARGET_OS.as_str(), 38 | env_dash, 39 | TARGET_ENV.map(|x| x.as_str()).unwrap_or(""), 40 | ) 41 | } 42 | 43 | fn get_version(impl_commit: &str) -> String { 44 | let commit_dash = if impl_commit.is_empty() { "" } else { "-" }; 45 | 46 | format!( 47 | "{}{}{}-{}", 48 | std::env::var("CARGO_PKG_VERSION").unwrap_or_default(), 49 | commit_dash, 50 | impl_commit, 51 | get_platform(), 52 | ) 53 | } 54 | 55 | fn main() { 56 | generate_cargo_keys(); 57 | } 58 | -------------------------------------------------------------------------------- /ch_11/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | database: 4 | image: postgres 5 | restart: always 6 | env_file: 7 | - .env 8 | ports: 9 | - "5432:5432" 10 | volumes: 11 | - data:/var/lib/postgresql/data 12 | server: 13 | build: 14 | context: . 15 | dockerfile: Dockerfile 16 | env_file: .env 17 | depends_on: 18 | - database 19 | networks: 20 | - default 21 | ports: 22 | - "8080:8080" 23 | volumes: 24 | data: 25 | -------------------------------------------------------------------------------- /ch_11/handle-errors/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "handle-errors" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | tracing = { version = "0.1", features = ["log"] } 9 | reqwest = "0.11" 10 | reqwest-middleware = "0.1.1" 11 | sqlx = { version = "0.5", features = [ "postgres" ] } 12 | rust-argon2 = "1.0" -------------------------------------------------------------------------------- /ch_11/handle-errors/src/lib.rs: -------------------------------------------------------------------------------- 1 | use warp::{ 2 | filters::{body::BodyDeserializeError, cors::CorsForbidden}, 3 | http::StatusCode, 4 | reject::Reject, 5 | Rejection, Reply, 6 | }; 7 | use tracing::{event, Level, instrument}; 8 | use argon2::Error as ArgonError; 9 | use reqwest::Error as ReqwestError; 10 | use reqwest_middleware::Error as MiddlewareReqwestError; 11 | 12 | 13 | #[derive(Debug)] 14 | pub enum Error { 15 | ParseError(std::num::ParseIntError), 16 | MissingParameters, 17 | WrongPassword, 18 | CannotDecryptToken, 19 | Unauthorized, 20 | ArgonLibraryError(ArgonError), 21 | DatabaseQueryError(sqlx::Error), 22 | MigrationError(sqlx::migrate::MigrateError), 23 | ReqwestAPIError(ReqwestError), 24 | MiddlewareReqwestAPIError(MiddlewareReqwestError), 25 | ClientError(APILayerError), 26 | ServerError(APILayerError) 27 | } 28 | 29 | #[derive(Debug, Clone)] 30 | pub struct APILayerError { 31 | pub status: u16, 32 | pub message: String, 33 | } 34 | 35 | impl std::fmt::Display for APILayerError { 36 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 37 | write!(f, "Status: {}, Message: {}", self.status, self.message) 38 | } 39 | } 40 | 41 | impl std::fmt::Display for Error { 42 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 43 | match &*self { 44 | Error::ParseError(ref err) => write!(f, "Cannot parse parameter: {}", err), 45 | Error::MissingParameters => write!(f, "Missing parameter"), 46 | Error::WrongPassword => write!(f, "Wrong password"), 47 | Error::CannotDecryptToken => write!(f, "Cannot decrypt error"), 48 | Error::Unauthorized => write!(f, "No permission to change the underlying resource"), 49 | Error::ArgonLibraryError(_) => write!(f, "Cannot verifiy password"), 50 | Error::DatabaseQueryError(_) => write!(f, "Cannot update, invalid data"), 51 | Error::MigrationError(_) => write!(f, "Cannot migrate data"), 52 | Error::ReqwestAPIError(err) => write!(f, "External API error: {}", err), 53 | Error::MiddlewareReqwestAPIError(err) => write!(f, "External API error: {}", err), 54 | Error::ClientError(err) => write!(f, "External Client error: {}", err), 55 | Error::ServerError(err) => write!(f, "External Server error: {}", err), 56 | } 57 | } 58 | } 59 | 60 | impl Reject for Error {} 61 | impl Reject for APILayerError {} 62 | 63 | const DUPLICATE_KEY: u32 = 23505; 64 | 65 | #[instrument] 66 | pub async fn return_error(r: Rejection) -> Result { 67 | if let Some(crate::Error::DatabaseQueryError(e)) = r.find() { 68 | event!(Level::ERROR, "Database query error"); 69 | 70 | match e { 71 | sqlx::Error::Database(err) => { 72 | if err.code().unwrap().parse::().unwrap() == DUPLICATE_KEY { 73 | Ok(warp::reply::with_status( 74 | "Account already exsists".to_string(), 75 | StatusCode::UNPROCESSABLE_ENTITY, 76 | )) 77 | } else { 78 | Ok(warp::reply::with_status( 79 | "Cannot update data".to_string(), 80 | StatusCode::UNPROCESSABLE_ENTITY, 81 | )) 82 | } 83 | }, 84 | _ => { 85 | Ok(warp::reply::with_status( 86 | "Cannot update data".to_string(), 87 | StatusCode::UNPROCESSABLE_ENTITY, 88 | )) 89 | } 90 | } 91 | } else if let Some(crate::Error::ReqwestAPIError(e)) = r.find() { 92 | event!(Level::ERROR, "{}", e); 93 | Ok(warp::reply::with_status( 94 | "Internal Server Error".to_string(), 95 | StatusCode::INTERNAL_SERVER_ERROR, 96 | )) 97 | } else if let Some(crate::Error::Unauthorized) = r.find() { 98 | event!(Level::ERROR, "Not matching account id"); 99 | Ok(warp::reply::with_status( 100 | "No permission to change underlying resource".to_string(), 101 | StatusCode::UNAUTHORIZED, 102 | )) 103 | } else if let Some(crate::Error::WrongPassword) = r.find() { 104 | event!(Level::ERROR, "Entered wrong password"); 105 | Ok(warp::reply::with_status( 106 | "Wrong E-Mail/Password combination".to_string(), 107 | StatusCode::UNAUTHORIZED, 108 | )) 109 | } else if let Some(crate::Error::MiddlewareReqwestAPIError(e)) = r.find() { 110 | event!(Level::ERROR, "{}", e); 111 | Ok(warp::reply::with_status( 112 | "Internal Server Error".to_string(), 113 | StatusCode::INTERNAL_SERVER_ERROR, 114 | )) 115 | } else if let Some(crate::Error::ClientError(e)) = r.find() { 116 | event!(Level::ERROR, "{}", e); 117 | Ok(warp::reply::with_status( 118 | "Internal Server Error".to_string(), 119 | StatusCode::INTERNAL_SERVER_ERROR, 120 | )) 121 | } else if let Some(crate::Error::ServerError(e)) = r.find() { 122 | event!(Level::ERROR, "{}", e); 123 | Ok(warp::reply::with_status( 124 | "Internal Server Error".to_string(), 125 | StatusCode::INTERNAL_SERVER_ERROR, 126 | )) 127 | } else if let Some(error) = r.find::() { 128 | event!(Level::ERROR, "CORS forbidden error: {}", error); 129 | Ok(warp::reply::with_status( 130 | error.to_string(), 131 | StatusCode::FORBIDDEN, 132 | )) 133 | } else if let Some(error) = r.find::() { 134 | event!(Level::ERROR, "Cannot deserizalize request body: {}", error); 135 | Ok(warp::reply::with_status( 136 | error.to_string(), 137 | StatusCode::UNPROCESSABLE_ENTITY, 138 | )) 139 | } else if let Some(error) = r.find::() { 140 | event!(Level::ERROR, "{}", error); 141 | Ok(warp::reply::with_status( 142 | error.to_string(), 143 | StatusCode::UNPROCESSABLE_ENTITY, 144 | )) 145 | } else { 146 | event!(Level::WARN, "Requested route was not found"); 147 | Ok(warp::reply::with_status( 148 | "Route not found".to_string(), 149 | StatusCode::NOT_FOUND, 150 | )) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /ch_11/integration-tests/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "integration-tests" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | 7 | [dependencies] 8 | rust-web-dev = { path = "../", version = "1.0.0" } 9 | dotenv = "0.15.0" 10 | tokio = { version = "1.1.1", features = ["full"] } 11 | reqwest = { version = "0.11", features = ["json"] } 12 | serde = { version = "1.0", features = ["derive"] } 13 | serde_json = "1.0" 14 | futures-util = "0.3" 15 | -------------------------------------------------------------------------------- /ch_11/integration-tests/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | use std::io::{self, Write}; 3 | 4 | use futures_util::future::FutureExt; 5 | 6 | use rust_web_dev::{config, handle_errors, oneshot, setup_store}; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::Value; 9 | 10 | #[derive(Serialize, Deserialize, Debug, Clone)] 11 | struct User { 12 | email: String, 13 | password: String, 14 | } 15 | 16 | #[derive(Serialize, Deserialize, Debug, Clone)] 17 | struct Question { 18 | title: String, 19 | content: String, 20 | } 21 | 22 | #[derive(Serialize, Deserialize, Debug, Clone)] 23 | struct QuestionAnswer { 24 | id: i32, 25 | title: String, 26 | content: String, 27 | tags: Option>, 28 | } 29 | 30 | #[derive(Serialize, Deserialize, Debug, Clone)] 31 | struct Token(String); 32 | 33 | #[tokio::main] 34 | async fn main() -> Result<(), handle_errors::Error> { 35 | dotenv::dotenv().ok(); 36 | let config = config::Config::new().expect("Config can't be set"); 37 | 38 | let s = Command::new("sqlx") 39 | .arg("database") 40 | .arg("drop") 41 | .arg("--database-url") 42 | .arg(format!("postgres://{}:{}/{}", config.db_host, config.db_port, config.db_name)) 43 | .arg("-y") 44 | .output() 45 | .expect("sqlx command failed to start"); 46 | 47 | io::stdout().write_all(&s.stderr).unwrap(); 48 | 49 | let s = Command::new("sqlx") 50 | .arg("database") 51 | .arg("create") 52 | .arg("--database-url") 53 | .arg(format!("postgres://{}:{}/{}", config.db_host, config.db_port, config.db_name)) 54 | .output() 55 | .expect("sqlx command failed to start"); 56 | 57 | // Exdcute DB commands to drop and create a new test database 58 | io::stdout().write_all(&s.stderr).unwrap(); 59 | 60 | // set up a new store instance with a db connection pool 61 | let store = setup_store(&config).await?; 62 | 63 | // start the server and listen for a sender signal to shut it down 64 | let handler = oneshot(store).await; 65 | 66 | // create a test user to use throughout the tests 67 | let u = User { 68 | email: "test@email.com".to_string(), 69 | password: "password".to_string(), 70 | }; 71 | 72 | let token; 73 | 74 | print!("Running register_new_user..."); 75 | let result = std::panic::AssertUnwindSafe(register_new_user(&u)).catch_unwind().await; 76 | match result { 77 | Ok(_) => println!("✓"), 78 | Err(_) => { 79 | let _ = handler.sender.send(1); 80 | std::process::exit(1); 81 | } 82 | } 83 | 84 | print!("Running login..."); 85 | match std::panic::AssertUnwindSafe(login(u)).catch_unwind().await { 86 | Ok(t) => { 87 | token = t; 88 | println!("✓"); 89 | }, 90 | Err(_) => { 91 | let _ = handler.sender.send(1); 92 | std::process::exit(1); 93 | } 94 | } 95 | 96 | print!("Running post_question..."); 97 | match std::panic::AssertUnwindSafe(post_question(token)).catch_unwind().await { 98 | Ok(_) => println!("✓"), 99 | Err(_) => { 100 | let _ = handler.sender.send(1); 101 | std::process::exit(1); 102 | } 103 | } 104 | 105 | let _ = handler.sender.send(1); 106 | 107 | Ok(()) 108 | } 109 | 110 | async fn register_new_user(user: &User) { 111 | let client = reqwest::Client::new(); 112 | let res = client 113 | .post("http://localhost:3030/registration") 114 | .json(&user) 115 | .send() 116 | .await 117 | .unwrap() 118 | .json::() 119 | .await; 120 | 121 | assert_eq!(res.unwrap(), "Account added".to_string()); 122 | 123 | } 124 | 125 | async fn login(user: User) -> Token { 126 | let client = reqwest::Client::new(); 127 | let res = client 128 | .post("http://localhost:3030/login") 129 | .json(&user) 130 | .send() 131 | .await 132 | .unwrap(); 133 | 134 | assert_eq!(res.status(), 200); 135 | 136 | res 137 | .json::() 138 | .await 139 | .unwrap() 140 | } 141 | 142 | async fn post_question(token: Token) { 143 | let q = Question { 144 | title: "First Question".to_string(), 145 | content: "How can I test?".to_string(), 146 | }; 147 | 148 | let client = reqwest::Client::new(); 149 | let res = client 150 | .post("http://localhost:3030/questions") 151 | .header("Authorization", token.0) 152 | .json(&q) 153 | .send() 154 | .await 155 | .unwrap() 156 | .json::() 157 | .await 158 | .unwrap(); 159 | 160 | assert_eq!(res.id, 1); 161 | assert_eq!(res.title, q.title); 162 | } 163 | -------------------------------------------------------------------------------- /ch_11/migrations/20220509150516_questions_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS questions; -------------------------------------------------------------------------------- /ch_11/migrations/20220509150516_questions_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS questions ( 2 | id serial PRIMARY KEY, 3 | title VARCHAR (255) NOT NULL, 4 | content TEXT NOT NULL, 5 | tags TEXT [], 6 | created_on TIMESTAMP NOT NULL DEFAULT NOW() 7 | ); 8 | -------------------------------------------------------------------------------- /ch_11/migrations/20220514145724_answers_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS answers; 2 | -------------------------------------------------------------------------------- /ch_11/migrations/20220514145724_answers_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS answers ( 2 | id serial PRIMARY KEY, 3 | content TEXT NOT NULL, 4 | created_on TIMESTAMP NOT NULL DEFAULT NOW(), 5 | corresponding_question integer REFERENCES questions 6 | ); 7 | -------------------------------------------------------------------------------- /ch_11/migrations/20220523174842_create_accounts_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS accounts; -------------------------------------------------------------------------------- /ch_11/migrations/20220523174842_create_accounts_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS accounts ( 2 | id serial NOT NULL, 3 | email VARCHAR(255) NOT NULL PRIMARY KEY, 4 | password VARCHAR(255) NOT NULL 5 | ); -------------------------------------------------------------------------------- /ch_11/migrations/20220523175814_extend_questions_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions 2 | DROP COLUMN account_id; -------------------------------------------------------------------------------- /ch_11/migrations/20220523175814_extend_questions_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions 2 | ADD COLUMN account_id serial; -------------------------------------------------------------------------------- /ch_11/migrations/20220523175821_extend_answers_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE answers 2 | DROP COLUMN account_id; 3 | -------------------------------------------------------------------------------- /ch_11/migrations/20220523175821_extend_answers_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE answers 2 | ADD COLUMN account_id serial; 3 | -------------------------------------------------------------------------------- /ch_11/mock-server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mock-server" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tokio = { version = "1.1.1", features = ["full"] } 8 | warp = "0.3" 9 | serde_json = "1.0" 10 | bytes = "1.1.0" -------------------------------------------------------------------------------- /ch_11/mock-server/src/lib.rs: -------------------------------------------------------------------------------- 1 | use serde_json::json; 2 | use std::net::SocketAddr; 3 | use tokio::sync::{oneshot, oneshot::Sender}; 4 | use warp::{http, Filter, Reply}; 5 | use bytes::Bytes; 6 | use std::collections::HashMap; 7 | 8 | #[derive(Clone, Debug)] 9 | pub struct MockServer { 10 | socket: SocketAddr, 11 | } 12 | 13 | pub struct OneshotHandler { 14 | pub sender: Sender, 15 | } 16 | 17 | impl MockServer { 18 | pub fn new(bind_addr: SocketAddr) -> MockServer { 19 | MockServer { 20 | socket: bind_addr, 21 | } 22 | } 23 | 24 | async fn check_profanity( 25 | _: (), 26 | content: Bytes, 27 | ) -> Result { 28 | let content = String::from_utf8(content.to_vec()).expect("Invalid UTF-8"); 29 | if content.contains("shitty") { 30 | Ok(warp::reply::with_status( 31 | warp::reply::json(&json!({ 32 | "bad_words_list": [ 33 | { 34 | "deviations": 0, 35 | "end": 16, 36 | "info": 2, 37 | "original": "shitty", 38 | "replacedLen": 6, 39 | "start": 10, 40 | "word": "shitty" 41 | } 42 | ], 43 | "bad_words_total": 1, 44 | "censored_content": "this is a ****** sentence", 45 | "content": "this is a shitty sentence" 46 | })), 47 | http::StatusCode::OK)) 48 | } else { 49 | Ok(warp::reply::with_status( 50 | warp::reply::json(&json!({ 51 | "bad_words_list": [], 52 | "bad_words_total": 0, 53 | "censored_content": "", 54 | "content": "this is a sentence" 55 | })), 56 | http::StatusCode::OK, 57 | )) 58 | } 59 | } 60 | 61 | fn build_routes(&self) -> impl Filter + Clone { 62 | warp::post() 63 | .and(warp::path("bad_words")) 64 | .and(warp::query()) 65 | .map(|_: HashMap| ()) 66 | .and(warp::path::end()) 67 | .and(warp::body::bytes()) 68 | .and_then(Self::check_profanity) 69 | } 70 | 71 | pub fn oneshot(&self) -> OneshotHandler { 72 | let (tx, rx) = oneshot::channel::(); 73 | let routes = Self::build_routes(&self); 74 | 75 | let (_, server) = warp::serve(routes).bind_with_graceful_shutdown(self.socket, async { 76 | rx.await.ok(); 77 | }); 78 | 79 | tokio::task::spawn(server); 80 | 81 | OneshotHandler { 82 | sender: tx, 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /ch_11/src/bin/server.rs: -------------------------------------------------------------------------------- 1 | use rust_web_dev::config; 2 | use rust_web_dev::{run, setup_store}; 3 | 4 | #[tokio::main] 5 | async fn main() -> Result<(), handle_errors::Error> { 6 | dotenv::dotenv().ok(); 7 | 8 | let config = config::Config::new().expect("Config can't be set"); 9 | let store = setup_store(&config).await?; 10 | 11 | tracing::info!("Q&A service build ID {}", env!("RUST_WEB_DEV_VERSION")); 12 | 13 | run(config, store).await; 14 | 15 | Ok(()) 16 | } 17 | -------------------------------------------------------------------------------- /ch_11/src/config.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use std::env; 3 | 4 | /// Q&A web service API 5 | #[derive(Parser, Debug, PartialEq)] 6 | #[clap(author, version, about, long_about = None)] 7 | pub struct Config { 8 | /// Which errors we want to log (info, warn or error) 9 | #[clap(short, long, default_value = "warn")] 10 | pub log_level: String, 11 | /// Which PORT the server is listening to 12 | #[clap(short, long, default_value = "8080")] 13 | pub port: u16, 14 | /// Database user 15 | #[clap(long, default_value = "username")] 16 | pub db_user: String, 17 | /// Database user 18 | #[clap(long, default_value = "password")] 19 | pub db_password: String, 20 | /// URL for the postgres database 21 | #[clap(long, default_value = "localhost")] 22 | pub db_host: String, 23 | /// PORT number for the database connection 24 | #[clap(long, default_value = "5432")] 25 | pub db_port: u16, 26 | /// Database name 27 | #[clap(long, default_value = "rustwebdev")] 28 | pub db_name: String, 29 | } 30 | 31 | impl Config { 32 | pub fn new() -> Result { 33 | let config = Config::parse(); 34 | 35 | if env::var("BAD_WORDS_API_KEY").is_err() { 36 | panic!("BadWords API key not set"); 37 | } 38 | 39 | if env::var("PASETO_KEY").is_err() { 40 | panic!("PASETO_KEY not set"); 41 | } 42 | 43 | let port = std::env::var("PORT") 44 | .ok() 45 | .map(|val| val.parse::()) 46 | .unwrap_or(Ok(config.port)) 47 | .map_err(handle_errors::Error::ParseError)?; 48 | 49 | let db_user = env::var("POSTGRES_USER").unwrap_or_else(|_| config.db_user.to_owned()); 50 | let db_password = env::var("POSTGRES_PASSWORD").unwrap(); 51 | let db_host = env::var("POSTGRES_HOST").unwrap_or_else(|_| config.db_host.to_owned()); 52 | let db_port = env::var("POSTGRES_PORT").unwrap_or_else(|_| config.db_port.to_string()); 53 | let db_name = env::var("POSTGRES_DB").unwrap_or_else(|_| config.db_name.to_owned()); 54 | 55 | Ok(Config { 56 | log_level: config.log_level, 57 | port, 58 | db_user, 59 | db_password, 60 | db_host, 61 | db_port: db_port 62 | .parse::() 63 | .map_err(handle_errors::Error::ParseError)?, 64 | db_name, 65 | }) 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | mod config_tests { 71 | use super::*; 72 | 73 | fn set_env() { 74 | env::set_var("BAD_WORDS_API_KEY", "API_KEY"); 75 | env::set_var("PASETO_KEY", "RANDOM WORDS WINTER MACINTOSH PC"); 76 | env::set_var("POSTGRES_USER", "user"); 77 | env::set_var("POSTGRES_PASSWORD", "pass"); 78 | env::set_var("POSTGRES_HOST", "localhost"); 79 | env::set_var("POSTGRES_PORT", "5432"); 80 | env::set_var("POSTGRES_DB", "rustwebdev"); 81 | } 82 | 83 | #[test] 84 | fn unset_and_set_api_key() { 85 | // ENV VARIABLES ARE NOT SET 86 | let result = std::panic::catch_unwind(|| Config::new()); 87 | assert!(result.is_err()); 88 | 89 | // NOW WE SET THEM 90 | set_env(); 91 | 92 | let expected = Config { 93 | log_level: "warn".to_string(), 94 | port: 8080, 95 | db_user: "user".to_string(), 96 | db_password: "pass".to_string(), 97 | db_host: "localhost".to_string(), 98 | db_port: 5432, 99 | db_name: "rustwebdev".to_string(), 100 | }; 101 | 102 | let config = Config::new().unwrap(); 103 | 104 | assert_eq!(config, expected); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /ch_11/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all)] 2 | 3 | pub use handle_errors; 4 | 5 | use tokio::sync::{oneshot, oneshot::Sender}; 6 | use tracing_subscriber::fmt::format::FmtSpan; 7 | use warp::{http::Method, Filter, Reply}; 8 | 9 | pub mod config; 10 | mod profanity; 11 | mod routes; 12 | mod store; 13 | pub mod types; 14 | 15 | pub struct OneshotHandler { 16 | pub sender: Sender, 17 | } 18 | 19 | async fn build_routes(store: store::Store) -> impl Filter + Clone { 20 | let store_filter = warp::any().map(move || store.clone()); 21 | 22 | let cors = warp::cors() 23 | .allow_any_origin() 24 | .allow_header("content-type") 25 | .allow_methods(&[Method::PUT, Method::DELETE, Method::GET, Method::POST]); 26 | 27 | let get_questions = warp::get() 28 | .and(warp::path("questions")) 29 | .and(warp::path::end()) 30 | .and(warp::query()) 31 | .and(store_filter.clone()) 32 | .and_then(routes::question::get_questions); 33 | 34 | let update_question = warp::put() 35 | .and(warp::path("questions")) 36 | .and(warp::path::param::()) 37 | .and(warp::path::end()) 38 | .and(routes::authentication::auth()) 39 | .and(store_filter.clone()) 40 | .and(warp::body::json()) 41 | .and_then(routes::question::update_question); 42 | 43 | let delete_question = warp::delete() 44 | .and(warp::path("questions")) 45 | .and(warp::path::param::()) 46 | .and(warp::path::end()) 47 | .and(routes::authentication::auth()) 48 | .and(store_filter.clone()) 49 | .and_then(routes::question::delete_question); 50 | 51 | let add_question = warp::post() 52 | .and(warp::path("questions")) 53 | .and(warp::path::end()) 54 | .and(routes::authentication::auth()) 55 | .and(store_filter.clone()) 56 | .and(warp::body::json()) 57 | .and_then(routes::question::add_question); 58 | 59 | let add_answer = warp::post() 60 | .and(warp::path("answers")) 61 | .and(warp::path::end()) 62 | .and(routes::authentication::auth()) 63 | .and(store_filter.clone()) 64 | .and(warp::body::form()) 65 | .and_then(routes::answer::add_answer); 66 | 67 | let registration = warp::post() 68 | .and(warp::path("registration")) 69 | .and(warp::path::end()) 70 | .and(store_filter.clone()) 71 | .and(warp::body::json()) 72 | .and_then(routes::authentication::register); 73 | 74 | let login = warp::post() 75 | .and(warp::path("login")) 76 | .and(warp::path::end()) 77 | .and(store_filter.clone()) 78 | .and(warp::body::json()) 79 | .and_then(routes::authentication::login); 80 | 81 | get_questions 82 | .or(update_question) 83 | .or(add_question) 84 | .or(delete_question) 85 | .or(add_answer) 86 | .or(registration) 87 | .or(login) 88 | .with(cors) 89 | .with(warp::trace::request()) 90 | .recover(handle_errors::return_error) 91 | } 92 | 93 | pub async fn setup_store(config: &config::Config) -> Result { 94 | let store = store::Store::new(&format!( 95 | "postgres://{}:{}@{}:{}/{}", 96 | config.db_user, config.db_password, config.db_host, config.db_port, config.db_name 97 | )) 98 | .await 99 | .map_err(handle_errors::Error::DatabaseQueryError)?; 100 | 101 | sqlx::migrate!() 102 | .run(&store.clone().connection) 103 | .await 104 | .map_err(handle_errors::Error::MigrationError)?; 105 | 106 | let log_filter = format!( 107 | "handle_errors={},rust_web_dev={},warp={}", 108 | config.log_level, config.log_level, config.log_level 109 | ); 110 | 111 | tracing_subscriber::fmt() 112 | // Use the filter we built above to determine which traces to record. 113 | .with_env_filter(log_filter) 114 | // Record an event when each span closes. This can be used to time our 115 | // routes' durations! 116 | .with_span_events(FmtSpan::CLOSE) 117 | .init(); 118 | 119 | Ok(store) 120 | } 121 | 122 | pub async fn run(config: config::Config, store: store::Store) { 123 | let routes = build_routes(store).await; 124 | warp::serve(routes).run(([0, 0, 0, 0], config.port)).await; 125 | } 126 | 127 | pub async fn oneshot(store: store::Store) -> OneshotHandler { 128 | let routes = build_routes(store).await; 129 | let (tx, rx) = oneshot::channel::(); 130 | 131 | let socket: std::net::SocketAddr = "127.0.0.1:3030" 132 | .to_string() 133 | .parse() 134 | .expect("Not a valid address"); 135 | 136 | let (_, server) = warp::serve(routes).bind_with_graceful_shutdown(socket, async { 137 | rx.await.ok(); 138 | }); 139 | 140 | tokio::task::spawn(server); 141 | 142 | OneshotHandler { sender: tx } 143 | } -------------------------------------------------------------------------------- /ch_11/src/profanity.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use serde::{Deserialize, Serialize}; 3 | use reqwest_middleware::ClientBuilder; 4 | use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff}; 5 | 6 | #[derive(Deserialize, Serialize, Debug, Clone)] 7 | pub struct APIResponse { 8 | message: String 9 | } 10 | 11 | #[derive(Deserialize, Serialize, Debug, Clone)] 12 | struct BadWord { 13 | original: String, 14 | word: String, 15 | deviations: i64, 16 | info: i64, 17 | #[serde(rename = "replacedLen")] 18 | replaced_len: i64, 19 | } 20 | 21 | #[derive(Deserialize, Serialize, Debug, Clone)] 22 | struct BadWordsResponse { 23 | content: String, 24 | bad_words_total: i64, 25 | bad_words_list: Vec, 26 | censored_content: String, 27 | } 28 | 29 | pub async fn check_profanity(content: String) -> Result { 30 | // We are already checking if the ENV VARIABLE is set inside main.rs, so safe to unwrap here 31 | let api_key = env::var("BAD_WORDS_API_KEY").expect("BAD WORDS API KEY NOT SET"); 32 | let api_layer_url = env::var("API_LAYER_URL").expect("APILAYER URL NOT SET"); 33 | 34 | let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3); 35 | let client = ClientBuilder::new(reqwest::Client::new()) 36 | // Trace HTTP requests. See the tracing crate to make use of these traces. 37 | // Retry failed requests. 38 | .with(RetryTransientMiddleware::new_with_policy(retry_policy)) 39 | .build(); 40 | 41 | let res = client 42 | .post(format!( 43 | "{}/bad_words?censor_character=*", 44 | api_layer_url 45 | )) 46 | .header("apikey", api_key) 47 | .body(content) 48 | .send() 49 | .await 50 | .map_err(handle_errors::Error::MiddlewareReqwestAPIError)?; 51 | 52 | if !res.status().is_success() { 53 | if res.status().is_client_error() { 54 | let err = transform_error(res).await; 55 | return Err(handle_errors::Error::ClientError(err)); 56 | } else { 57 | let err = transform_error(res).await; 58 | return Err(handle_errors::Error::ServerError(err)); 59 | } 60 | } 61 | 62 | match res.json::() 63 | .await { 64 | Ok(res) => Ok(res.censored_content), 65 | Err(e) => Err(handle_errors::Error::ReqwestAPIError(e)), 66 | } 67 | } 68 | 69 | async fn transform_error(res: reqwest::Response) -> handle_errors::APILayerError { 70 | handle_errors::APILayerError { 71 | status: res.status().as_u16(), 72 | message: res.json::().await.unwrap().message, 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod profanity_tests { 78 | use super::{check_profanity, env}; 79 | 80 | use mock_server::{MockServer, OneshotHandler}; 81 | 82 | #[tokio::test] 83 | async fn run() { 84 | let handler = run_mock(); 85 | censor_profane_words().await; 86 | no_profane_words().await; 87 | let _ = handler.sender.send(1); 88 | } 89 | 90 | fn run_mock() -> OneshotHandler { 91 | env::set_var("API_LAYER_URL", "http://127.0.0.1:3030"); 92 | env::set_var("BAD_WORDS_API_KEY", "YES"); 93 | 94 | let socket = "127.0.0.1:3030" 95 | .to_string() 96 | .parse() 97 | .expect("Not a valid address"); 98 | let mock = MockServer::new(socket); 99 | 100 | mock.oneshot() 101 | } 102 | 103 | async fn censor_profane_words() { 104 | let content = "This is a shitty sentence".to_string(); 105 | let censored_content = check_profanity(content).await; 106 | assert_eq!(censored_content.unwrap(), "this is a ****** sentence"); 107 | } 108 | 109 | async fn no_profane_words() { 110 | let content = "this is a sentence".to_string(); 111 | let censored_content = check_profanity(content).await; 112 | assert_eq!(censored_content.unwrap(), ""); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /ch_11/src/routes/answer.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use warp::http::StatusCode; 3 | 4 | use crate::profanity::check_profanity; 5 | use crate::store::Store; 6 | use crate::types::{account::Session, answer::Answer}; 7 | 8 | pub async fn add_answer( 9 | session: Session, 10 | store: Store, 11 | params: HashMap, 12 | ) -> Result { 13 | let account_id = session.account_id; 14 | let content = match check_profanity(params.get("content").unwrap().to_string()).await { 15 | Ok(res) => res, 16 | Err(e) => return Err(warp::reject::custom(e)), 17 | }; 18 | 19 | let answer = Answer { 20 | content, 21 | question_id: params.get("questionId").unwrap().parse().unwrap(), 22 | }; 23 | 24 | match store.add_answer(answer, account_id).await { 25 | Ok(_) => Ok(warp::reply::with_status("Answer added", StatusCode::OK)), 26 | Err(e) => Err(warp::reject::custom(e)), 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ch_11/src/routes/authentication.rs: -------------------------------------------------------------------------------- 1 | use argon2::{self, Config}; 2 | use chrono::prelude::*; 3 | use rand::Rng; 4 | use std::{env, future}; 5 | use warp::Filter; 6 | 7 | use crate::store::Store; 8 | use crate::types::account::{Account, AccountId, Session}; 9 | 10 | pub async fn register(store: Store, account: Account) -> Result { 11 | let hashed_password = hash_password(account.password.as_bytes()); 12 | 13 | let account = Account { 14 | id: account.id, 15 | email: account.email, 16 | password: hashed_password, 17 | }; 18 | 19 | match store.add_account(account).await { 20 | Ok(_) => Ok(warp::reply::json(&"Account added".to_string())), 21 | Err(e) => Err(warp::reject::custom(e)), 22 | } 23 | } 24 | 25 | pub async fn login(store: Store, login: Account) -> Result { 26 | match store.get_account(login.email).await { 27 | Ok(account) => match verify_password(&account.password, login.password.as_bytes()) { 28 | Ok(verified) => { 29 | if verified { 30 | Ok(warp::reply::json(&issue_token( 31 | account.id.expect("id not found"), 32 | ))) 33 | } else { 34 | Err(warp::reject::custom(handle_errors::Error::WrongPassword)) 35 | } 36 | } 37 | Err(e) => Err(warp::reject::custom( 38 | handle_errors::Error::ArgonLibraryError(e), 39 | )), 40 | }, 41 | Err(_) => Err(warp::reject::custom(handle_errors::Error::WrongPassword)), 42 | } 43 | } 44 | 45 | fn hash_password(password: &[u8]) -> String { 46 | let salt = rand::thread_rng().gen::<[u8; 32]>(); 47 | let config = Config::default(); 48 | argon2::hash_encoded(password, &salt, &config).unwrap() 49 | } 50 | 51 | fn verify_password(hash: &str, password: &[u8]) -> Result { 52 | argon2::verify_encoded(hash, password) 53 | } 54 | 55 | pub fn verify_token(token: String) -> Result { 56 | let key = env::var("PASETO_KEY").unwrap(); 57 | let token = paseto::tokens::validate_local_token( 58 | &token, 59 | None, 60 | key.as_bytes(), 61 | &paseto::tokens::TimeBackend::Chrono, 62 | ) 63 | .map_err(|_| handle_errors::Error::CannotDecryptToken)?; 64 | serde_json::from_value::(token).map_err(|_| handle_errors::Error::CannotDecryptToken) 65 | } 66 | 67 | fn issue_token(account_id: AccountId) -> String { 68 | let key = env::var("PASETO_KEY").unwrap(); 69 | 70 | let current_date_time = Utc::now(); 71 | let dt = current_date_time + chrono::Duration::days(1); 72 | 73 | paseto::tokens::PasetoBuilder::new() 74 | .set_encryption_key(&Vec::from(key.as_bytes())) 75 | .set_expiration(&dt) 76 | .set_claim("account_id", serde_json::json!(account_id)) 77 | .build() 78 | .expect("Failed to construct paseto token w/ builder!") 79 | } 80 | 81 | pub fn auth() -> impl Filter + Clone { 82 | warp::header::("Authorization").and_then(|token: String| { 83 | let token = match verify_token(token) { 84 | Ok(t) => t, 85 | Err(_) => { 86 | return future::ready(Err(warp::reject::custom( 87 | handle_errors::Error::Unauthorized, 88 | ))) 89 | } 90 | }; 91 | 92 | future::ready(Ok(token)) 93 | }) 94 | } 95 | 96 | #[cfg(test)] 97 | mod authentication_tests { 98 | use super::{auth, env, issue_token, AccountId}; 99 | 100 | #[tokio::test] 101 | async fn post_questions_auth() { 102 | env::set_var("PASETO_KEY", "RANDOM WORDS WINTER MACINTOSH PC"); 103 | let token = issue_token(AccountId(3)); 104 | 105 | let filter = auth(); 106 | 107 | let res = warp::test::request() 108 | .header("Authorization", token) 109 | .filter(&filter); 110 | 111 | assert_eq!(res.await.unwrap().account_id, AccountId(3)); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /ch_11/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod answer; 2 | pub mod authentication; 3 | pub mod question; 4 | -------------------------------------------------------------------------------- /ch_11/src/routes/question.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use tracing::{event, instrument, Level}; 4 | use warp::http::StatusCode; 5 | 6 | use crate::profanity::check_profanity; 7 | use crate::store::Store; 8 | use crate::types::account::Session; 9 | use crate::types::pagination::{extract_pagination, Pagination}; 10 | use crate::types::question::{NewQuestion, Question}; 11 | 12 | #[instrument] 13 | pub async fn get_questions( 14 | params: HashMap, 15 | store: Store, 16 | ) -> Result { 17 | event!(target: "practical_rust_book", Level::INFO, "querying questions"); 18 | let mut pagination = Pagination::default(); 19 | 20 | if !params.is_empty() { 21 | event!(Level::INFO, pagination = true); 22 | pagination = extract_pagination(params)?; 23 | } 24 | 25 | match store 26 | .get_questions(pagination.limit, pagination.offset) 27 | .await 28 | { 29 | Ok(res) => Ok(warp::reply::json(&res)), 30 | Err(e) => Err(warp::reject::custom(e)), 31 | } 32 | } 33 | 34 | pub async fn update_question( 35 | id: i32, 36 | session: Session, 37 | store: Store, 38 | question: Question, 39 | ) -> Result { 40 | let account_id = session.account_id; 41 | if store.is_question_owner(id, &account_id).await? { 42 | let title = check_profanity(question.title); 43 | let content = check_profanity(question.content); 44 | 45 | let (title, content) = tokio::join!(title, content); 46 | 47 | if title.is_ok() && content.is_ok() { 48 | let question = Question { 49 | id: question.id, 50 | title: title.unwrap(), 51 | content: content.unwrap(), 52 | tags: question.tags, 53 | }; 54 | match store.update_question(question, id, account_id).await { 55 | Ok(res) => Ok(warp::reply::json(&res)), 56 | Err(e) => Err(warp::reject::custom(e)), 57 | } 58 | } else { 59 | Err(warp::reject::custom( 60 | title.expect_err("Expected API call to have failed here"), 61 | )) 62 | } 63 | } else { 64 | Err(warp::reject::custom(handle_errors::Error::Unauthorized)) 65 | } 66 | } 67 | 68 | pub async fn delete_question( 69 | id: i32, 70 | session: Session, 71 | store: Store, 72 | ) -> Result { 73 | let account_id = session.account_id; 74 | if store.is_question_owner(id, &account_id).await? { 75 | match store.delete_question(id, account_id).await { 76 | Ok(_) => Ok(warp::reply::with_status( 77 | format!("Question {} deleted", id), 78 | StatusCode::OK, 79 | )), 80 | Err(e) => Err(warp::reject::custom(e)), 81 | } 82 | } else { 83 | Err(warp::reject::custom(handle_errors::Error::Unauthorized)) 84 | } 85 | } 86 | 87 | pub async fn add_question( 88 | session: Session, 89 | store: Store, 90 | new_question: NewQuestion, 91 | ) -> Result { 92 | let account_id = session.account_id; 93 | let title = match check_profanity(new_question.title).await { 94 | Ok(res) => res, 95 | Err(e) => return Err(warp::reject::custom(e)), 96 | }; 97 | 98 | let content = match check_profanity(new_question.content).await { 99 | Ok(res) => res, 100 | Err(e) => return Err(warp::reject::custom(e)), 101 | }; 102 | 103 | let question = NewQuestion { 104 | title, 105 | content, 106 | tags: new_question.tags, 107 | }; 108 | 109 | match store.add_question(question, account_id).await { 110 | Ok(question) => Ok(warp::reply::json(&question)), 111 | Err(e) => Err(warp::reject::custom(e)), 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /ch_11/src/types/account.rs: -------------------------------------------------------------------------------- 1 | use chrono::prelude::*; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 5 | pub struct Session { 6 | pub exp: DateTime, 7 | pub account_id: AccountId, 8 | } 9 | 10 | #[derive(Serialize, Deserialize, Debug, Clone)] 11 | pub struct Account { 12 | pub id: Option, 13 | pub email: String, 14 | pub password: String, 15 | } 16 | 17 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 18 | pub struct AccountId(pub i32); 19 | -------------------------------------------------------------------------------- /ch_11/src/types/answer.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Debug, Clone)] 4 | pub struct Answer { 5 | pub content: String, 6 | pub question_id: i32, 7 | } 8 | -------------------------------------------------------------------------------- /ch_11/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | pub mod answer; 3 | pub mod pagination; 4 | pub mod question; 5 | -------------------------------------------------------------------------------- /ch_11/src/types/pagination.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use handle_errors::Error; 4 | 5 | /// Pagination struct which is getting extract 6 | /// from query params 7 | #[derive(Default, Debug, PartialEq)] 8 | pub struct Pagination { 9 | /// The index of the last item which has to be returned 10 | pub limit: Option, 11 | /// The index of the first item which has to be returned 12 | //TODO: Why i32? 13 | pub offset: i32, 14 | } 15 | 16 | /// Extract query parameters from the `/questions` route 17 | /// # Example query 18 | /// GET requests to this route can have a pagination attached so we just 19 | /// return the questions we need 20 | /// `/questions?start=1&end=10` 21 | /// # Example usage 22 | /// ```rust 23 | /// use std::collections::HashMap; 24 | /// use rust_web_dev::types::pagination::extract_pagination; 25 | /// 26 | /// let mut query = HashMap::new(); 27 | /// query.insert("limit".to_string(), "1".to_string()); 28 | /// query.insert("offset".to_string(), "10".to_string()); 29 | /// let p = extract_pagination(query).unwrap(); 30 | /// assert_eq!(p.limit, Some(1)); 31 | /// assert_eq!(p.offset, 10); 32 | /// ``` 33 | pub fn extract_pagination(params: HashMap) -> Result { 34 | // Could be improved in the future 35 | if params.contains_key("limit") && params.contains_key("offset") { 36 | return Ok(Pagination { 37 | // Takes the "limit" parameter in the query and tries to convert it to a number 38 | limit: Some( 39 | params 40 | .get("limit") 41 | .unwrap() 42 | .parse() 43 | .map_err(Error::ParseError)?, 44 | ), 45 | // Takes the "offset" parameter in the query and tries to convert it to a number 46 | offset: params 47 | .get("offset") 48 | .unwrap() 49 | .parse() 50 | .map_err(Error::ParseError)?, 51 | }); 52 | } 53 | 54 | Err(Error::MissingParameters) 55 | } 56 | 57 | #[cfg(test)] 58 | mod pagination_tests { 59 | use super::{extract_pagination, Error, HashMap, Pagination}; 60 | 61 | #[test] 62 | fn valid_pagination() { 63 | let mut params = HashMap::new(); 64 | params.insert(String::from("limit"), String::from("1")); 65 | params.insert(String::from("offset"), String::from("1")); 66 | let pagination_result = extract_pagination(params); 67 | let expected = Pagination { 68 | limit: Some(1), 69 | offset: 1, 70 | }; 71 | assert_eq!(pagination_result.unwrap(), expected); 72 | } 73 | 74 | #[test] 75 | fn missing_offset_parameter() { 76 | let mut params = HashMap::new(); 77 | params.insert(String::from("limit"), String::from("1")); 78 | 79 | let pagination_result = format!("{}", extract_pagination(params).unwrap_err()); 80 | let expected = format!("{}", Error::MissingParameters); 81 | 82 | assert_eq!(pagination_result, expected); 83 | } 84 | 85 | #[test] 86 | fn missing_limit_paramater() { 87 | let mut params = HashMap::new(); 88 | params.insert(String::from("offset"), String::from("1")); 89 | 90 | let pagination_result = format!("{}", extract_pagination(params).unwrap_err()); 91 | let expected = format!("{}", Error::MissingParameters); 92 | 93 | assert_eq!(pagination_result, expected); 94 | } 95 | 96 | #[test] 97 | fn wrong_offset_type() { 98 | let mut params = HashMap::new(); 99 | params.insert(String::from("limit"), String::from("1")); 100 | params.insert(String::from("offset"), String::from("NOT_A_NUMBER")); 101 | let pagination_result = format!("{}", extract_pagination(params).unwrap_err()); 102 | 103 | let expected = String::from("Cannot parse parameter: invalid digit found in string"); 104 | 105 | assert_eq!(pagination_result, expected); 106 | } 107 | 108 | #[test] 109 | fn wrong_limit_type() { 110 | let mut params = HashMap::new(); 111 | params.insert(String::from("limit"), String::from("NOT_A_NUMBER")); 112 | params.insert(String::from("offset"), String::from("1")); 113 | let pagination_result = format!("{}", extract_pagination(params).unwrap_err()); 114 | 115 | let expected = String::from("Cannot parse parameter: invalid digit found in string"); 116 | 117 | assert_eq!(pagination_result, expected); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /ch_11/src/types/question.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize, Debug, Clone)] 4 | pub struct Question { 5 | pub id: QuestionId, 6 | pub title: String, 7 | pub content: String, 8 | pub tags: Option>, 9 | } 10 | 11 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] 12 | pub struct QuestionId(pub i32); 13 | 14 | #[derive(Deserialize, Serialize, Debug, Clone)] 15 | pub struct NewQuestion { 16 | pub title: String, 17 | pub content: String, 18 | pub tags: Option>, 19 | } 20 | --------------------------------------------------------------------------------