From c3ba5ad5ebe1d5bb28ed0a340af93e8547b1c5bc Mon Sep 17 00:00:00 2001 From: Yigit Sever Date: Wed, 7 Apr 2021 01:08:31 +0300 Subject: Initial commit --- src/custom_filters.rs | 21 +++ src/handlers.rs | 122 +++++++++++++++++ src/main.rs | 26 ++++ src/routes.rs | 359 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/schema.rs | 223 +++++++++++++++++++++++++++++++ src/validators.rs | 44 +++++++ 6 files changed, 795 insertions(+) create mode 100644 src/custom_filters.rs create mode 100644 src/handlers.rs create mode 100644 src/main.rs create mode 100644 src/routes.rs create mode 100644 src/schema.rs create mode 100644 src/validators.rs (limited to 'src') diff --git a/src/custom_filters.rs b/src/custom_filters.rs new file mode 100644 index 0000000..86a78d4 --- /dev/null +++ b/src/custom_filters.rs @@ -0,0 +1,21 @@ +// Common filters ment to be shared between many endpoints + +use std::convert::Infallible; +use warp::{Filter, Rejection}; + +use crate::schema::{Db, Transaction}; // `Block` coming later + +// Database context for routes +pub fn with_db(db: Db) -> impl Filter + Clone { + warp::any().map(move || db.clone()) +} + +// Optional query params to allow pagination +// pub fn list_options() -> impl Filter + Clone { +// warp::query::() +// } + +// Accept only JSON body and reject big payloads +pub fn json_body() -> impl Filter + Clone { + warp::body::content_length_limit(1024 * 32).and(warp::body::json()) +} diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..51c7b63 --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,122 @@ +// API handlers, the ends of each filter chain + +use log::debug; +use std::convert::Infallible; +use warp::{http::StatusCode, reply}; + +use crate::schema::{Db, Transaction}; // `Block` coming later + +// PROPOSE Transaction +// POST /transaction +pub async fn propose_transaction( + new_transaction: Transaction, + db: Db, +) -> Result { + debug!("new transaction request {:?}", new_transaction); + + let mut transactions = db.lock().await; + + transactions.push(new_transaction); + + Ok(StatusCode::CREATED) +} + +// GET Transaction List +// GET /transaction +// Returns JSON array of transactions +// Cannot fail? +pub async fn list_transactions(db: Db) -> Result { + debug!("list all transactions"); + + let transactions = db.lock().await; + + let transactions: Vec = transactions.clone().into_iter().collect(); + + Ok(reply::with_status( + reply::json(&transactions), + StatusCode::OK, + )) +} + +// PROPOSE Block +// POST /block + +// `GET /games` +// Returns JSON array of todos +// Allows pagination, for example: `GET /games?offset=10&limit=5` +// pub async fn list_games(options: ListOptions, db: Db) -> Result { +// debug!("list all games"); + +// let games = db.lock().await; +// let games: Vec = games +// .clone() +// .into_iter() +// .skip(options.offset.unwrap_or(0)) +// .take(options.limit.unwrap_or(std::usize::MAX)) +// .collect(); + +// Ok(warp::reply::json(&games)) +// } + +// `POST /games` +// Create new game entry with JSON body +// pub async fn create_game(new_game: Game, db: Db) -> Result { +// debug!("create new game: {:?}", new_game); + +// let mut games = db.lock().await; + +// match games.iter().find(|game| game.id == new_game.id) { +// Some(game) => { +// debug!("game of given id already exists: {}", game.id); + +// Ok(StatusCode::BAD_REQUEST) +// } +// None => { +// games.push(new_game); +// Ok(StatusCode::CREATED) +// } +// } +// } + +// `PUT /games/:id` +// pub async fn update_game(id: u64, updated_game: Game, db: Db) -> Result { +// debug!("update existing game: id={}, game={:?}", id, updated_game); + +// let mut games = db.lock().await; + +// match games.iter_mut().find(|game| game.id == id) { +// Some(game) => { +// *game = updated_game; + +// Ok(StatusCode::OK) +// } +// None => { +// debug!("game of given id not found"); + +// Ok(StatusCode::NOT_FOUND) +// } +// } +// } + +// `DELETE /games/:id` +// pub async fn delete_game(id: u64, db: Db) -> Result { +// debug!("delete game: id={}", id); + +// let mut games = db.lock().await; + +// let len = games.len(); + +// // Removes all games with given id +// games.retain(|game| game.id != id); + +// // If games length was smaller that means specyfic game was found and removed +// let deleted = games.len() != len; + +// if deleted { +// Ok(StatusCode::NO_CONTENT) +// } else { +// debug!("game of given id not found"); + +// Ok(StatusCode::NOT_FOUND) +// } +// } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bcd4173 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,26 @@ +use std::env; +use warp::Filter; + +mod custom_filters; +mod handlers; +mod routes; +mod schema; +// mod validators; + +#[tokio::main] +async fn main() { + // Show debug logs by default by setting `RUST_LOG=restful_rust=debug` + if env::var_os("RUST_LOG").is_none() { + env::set_var("RUST_LOG", "restful_rust=debug"); + } + pretty_env_logger::init(); + + let db = schema::ledger(); // 1. we need this to return a _simple_ db + + let api = routes::consensus_routes(db); + + let routes = api.with(warp::log("restful_rust")); + + // Start the server + warp::serve(routes).run(([127, 0, 0, 1], 8080)).await; +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..fc4426a --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,359 @@ +use warp::{Filter, Rejection, Reply}; + +use crate::custom_filters; +use crate::handlers; +use crate::schema::Db; + +// Root, all routes combined +pub fn consensus_routes(db: Db) -> impl Filter + Clone { + transaction_list(db.clone()).or(transaction_propose(db.clone())) +} + +// GET /transaction +pub fn transaction_list(db: Db) -> impl Filter + Clone { + warp::path!("transaction") + .and(warp::get()) + .and(custom_filters::with_db(db)) + .and_then(handlers::list_transactions) +} + +// POST /transaction +pub fn transaction_propose(db: Db) -> impl Filter + Clone { + warp::path!("transaction") + .and(warp::post()) + .and(custom_filters::json_body()) + .and(custom_filters::with_db(db)) + .and_then(handlers::propose_transaction) +} + +/////////////////////////// +// below are not mine. // +/////////////////////////// + +// Root, all routes combined +//pub fn games_routes(db: Db) -> impl Filter + Clone { +// games_list(db.clone()) +// .or(games_create(db.clone())) +// .or(games_update(db.clone())) +// .or(games_delete(db)) +//} + +//// `GET /games?offset=3&limit=5` +//pub fn games_list(db: Db) -> impl Filter + Clone { +// warp::path!("games") +// .and(warp::get()) +// .and(custom_filters::list_options()) +// .and(custom_filters::with_db(db)) +// .and_then(handlers::list_games) +//} + +//// `POST /games` +//pub fn games_create(db: Db) -> impl Filter + Clone { +// warp::path!("games") +// .and(warp::post()) +// .and(custom_filters::json_body()) +// .and(custom_filters::with_db(db)) +// .and_then(handlers::create_game) +//} + +//// `PUT /games/:id` +//pub fn games_update(db: Db) -> impl Filter + Clone { +// warp::path!("games" / u64) +// .and(warp::put()) +// .and(custom_filters::json_body()) +// .and(custom_filters::with_db(db)) +// .and_then(handlers::update_game) +//} + +//// `DELETE /games/:id` +//pub fn games_delete(db: Db) -> impl Filter + Clone { +// warp::path!("games" / u64) +// .and(warp::delete()) +// .and(custom_filters::with_db(db)) +// .and_then(handlers::delete_game) +//} + +//////////////////////////////// +//// tests below, it's fine // +//////////////////////////////// + +///////////////////////////////////// +// of course I'll write tests... // +///////////////////////////////////// + +// TODO: write tests <07-04-21, yigit> // + +//#[cfg(test)] +//mod tests { +// use super::*; + +// use chrono::prelude::*; +// use std::sync::Arc; +// use tokio::sync::Mutex; +// use warp::http::StatusCode; + +// use crate::schema::{Game, Genre}; + +// // Mocked dataset for each test + +// fn mocked_db() -> Db { +// Arc::new(Mutex::new(vec![ +// Game { +// id: 1, +// title: String::from("Crappy title"), +// rating: 35, +// genre: Genre::RolePlaying, +// description: Some(String::from("Test description...")), +// release_date: NaiveDate::from_ymd(2011, 9, 22).and_hms(0, 0, 0), +// }, +// Game { +// id: 2, +// title: String::from("Decent game"), +// rating: 84, +// genre: Genre::Strategy, +// description: None, +// release_date: NaiveDate::from_ymd(2014, 3, 11).and_hms(0, 0, 0), +// }, +// ])) +// } + +// fn mocked_game() -> Game { +// Game { +// id: 3, +// title: String::from("Another game"), +// rating: 65, +// description: None, +// genre: Genre::Strategy, +// release_date: NaiveDate::from_ymd(2016, 3, 11).and_hms(0, 0, 0), +// } +// } + +// #[tokio::test] +// async fn get_list_of_games_200() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request().method("GET").path("/games").reply(&filter).await; + +// assert_eq!(res.status(), StatusCode::OK); + +// let expected_json_body = r#"[{"id":1,"title":"Crappy title","rating":35,"genre":"ROLE_PLAYING","description":"Test description...","releaseDate":"2011-09-22T00:00:00"},{"id":2,"title":"Decent game","rating":84,"genre":"STRATEGY","description":null,"releaseDate":"2014-03-11T00:00:00"}]"#; +// assert_eq!(res.body(), expected_json_body); +// } + +// #[tokio::test] +// async fn get_list_of_games_with_options_200() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("GET") +// .path("/games?offset=1&limit=5") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::OK); + +// let expected_json_body = r#"[{"id":2,"title":"Decent game","rating":84,"genre":"STRATEGY","description":null,"releaseDate":"2014-03-11T00:00:00"}]"#; +// assert_eq!(res.body(), expected_json_body); +// } + +// #[tokio::test] +// async fn get_empty_list_with_offset_overshot_200() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("GET") +// .path("/games?offset=5&limit=5") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::OK); + +// let expected_json_body = r#"[]"#; +// assert_eq!(res.body(), expected_json_body); +// } + +// #[tokio::test] +// async fn get_incorrect_options_400() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("GET") +// .path("/games?offset=a&limit=b") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::BAD_REQUEST); +// } + +// #[tokio::test] +// async fn get_wrong_path_405() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("GET") +// .path("/games/42") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED); +// } + +// #[tokio::test] +// async fn post_json_201() { +// let db = mocked_db(); +// let filter = games_routes(db.clone()); + +// let res = warp::test::request() +// .method("POST") +// .json(&mocked_game()) +// .path("/games") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::CREATED); +// assert_eq!(db.lock().await.len(), 3); +// } + +// #[tokio::test] +// async fn post_too_long_content_413() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("POST") +// .header("content-length", 1024 * 36) +// .path("/games") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE); +// } + +// #[tokio::test] +// async fn post_wrong_payload_400() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("POST") +// .body(&r#"{"id":4}"#) +// .path("/games") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::BAD_REQUEST); +// } + +// #[tokio::test] +// async fn post_wrong_path_405() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("POST") +// .path("/games/42") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED); +// } + +// #[tokio::test] +// async fn put_json_200() { +// let db = mocked_db(); +// let filter = games_routes(db.clone()); + +// let res = warp::test::request() +// .method("PUT") +// .json(&mocked_game()) +// .path("/games/2") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::OK); + +// let db = db.lock().await; +// let ref title = db[1].title; +// assert_eq!(title, "Another game"); +// } + +// #[tokio::test] +// async fn put_wrong_id_404() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("PUT") +// .json(&mocked_game()) +// .path("/games/42") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::NOT_FOUND); +// } + +// #[tokio::test] +// async fn put_wrong_payload_400() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("PUT") +// .header("content-length", 1024 * 16) +// .body(&r#"{"id":2"#) +// .path("/games/2") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::BAD_REQUEST); +// } + +// #[tokio::test] +// async fn put_too_long_content_413() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("PUT") +// .header("content-length", 1024 * 36) +// .path("/games/2") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE); +// } + +// #[tokio::test] +// async fn delete_wrong_id_404() { +// let db = mocked_db(); +// let filter = games_routes(db); + +// let res = warp::test::request() +// .method("DELETE") +// .path("/games/42") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::NOT_FOUND); +// } + +// #[tokio::test] +// async fn delete_game_204() { +// let db = mocked_db(); +// let filter = games_routes(db.clone()); + +// let res = warp::test::request() +// .method("DELETE") +// .path("/games/1") +// .reply(&filter) +// .await; + +// assert_eq!(res.status(), StatusCode::NO_CONTENT); +// assert_eq!(db.lock().await.len(), 1); +// } +//} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..ea36a70 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,223 @@ +// Common types used across API + +use chrono::{NaiveDate, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::Mutex; + +// use crate::validators; + +pub fn ledger() -> Db { + // TODO: there was something simpler in one of the other tutorials? <07-04-21, yigit> // + + Arc::new(Mutex::new(vec![ + Transaction { + source: String::from("Myself"), + target: String::from("Nobody"), + amount: 4, + timestamp: NaiveDate::from_ymd(2021, 4, 7).and_hms(00, 17, 00), + }, + ])) +} + + +// For presentation purposes keep mocked data in in-memory structure +// In real life scenario connection with regular database would be established + +pub type Db = Arc>>; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct Transaction { + pub source: String, + pub target: String, + pub amount: i32, + pub timestamp: NaiveDateTime, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct Block { + pub transaction_list: Vec, // [Transaction; N] + pub nonce: i32, + pub timestamp: NaiveDateTime, + pub hash: String, // future proof'd baby +} + +// #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +// #[serde(rename_all = "camelCase")] +// pub struct Game { +// pub id: u64, +// pub title: String, +// #[serde(with = "validators::validate_game_rating")] +// pub rating: u8, +// pub genre: Genre, +// pub description: Option, +// pub release_date: NaiveDateTime, +// } + +// #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +// #[serde(rename_all = "SCREAMING_SNAKE_CASE")] +// pub enum Genre { +// RolePlaying, +// Strategy, +// Shooter, +// } + +// #[derive(Deserialize, Debug, PartialEq)] +// pub struct ListOptions { +// pub offset: Option, +// pub limit: Option, +// } + +// pub fn example_db() -> Db { +// Arc::new(Mutex::new( +// vec![ +// Game { +// id: 1, +// title: String::from("Dark Souls"), +// rating: 91, +// genre: Genre::RolePlaying, +// description: Some(String::from("Takes place in the fictional kingdom of Lordran, where players assume the role of a cursed undead character who begins a pilgrimage to discover the fate of their kind.")), +// release_date: NaiveDate::from_ymd(2011, 9, 22).and_hms(0, 0, 0), +// }, +// Game { +// id: 2, +// title: String::from("Dark Souls 2"), +// rating: 87, +// genre: Genre::RolePlaying, +// description: None, +// release_date: NaiveDate::from_ymd(2014, 3, 11).and_hms(0, 0, 0), +// }, +// Game { +// id: 3, +// title: String::from("Dark Souls 3"), +// rating: 89, +// genre: Genre::RolePlaying, +// description: Some(String::from("The latest chapter in the series with its trademark sword and sorcery combat and rewarding action RPG gameplay.")), +// release_date: NaiveDate::from_ymd(2016, 3, 24).and_hms(0, 0, 0), +// }, +// ] +// )) +// } + +// #[cfg(test)] +// mod tests { +// use super::*; + +// use serde_json::error::Error; +// use serde_test::{assert_tokens, Token}; + +// #[test] +// fn game_serialize_correctly() { +// let game = Game { +// id: 1, +// title: String::from("Test"), +// rating: 90, +// genre: Genre::Shooter, +// description: None, +// release_date: NaiveDate::from_ymd(2019, 11, 12).and_hms(0, 0, 0), +// }; + +// assert_tokens( +// &game, +// &[ +// Token::Struct { +// name: "Game", +// len: 6, +// }, +// Token::String("id"), +// Token::U64(1), +// Token::String("title"), +// Token::String("Test"), +// Token::String("rating"), +// Token::U8(90), +// Token::String("genre"), +// Token::UnitVariant { +// name: "Genre", +// variant: "SHOOTER", +// }, +// Token::String("description"), +// Token::None, +// Token::String("releaseDate"), +// Token::String("2019-11-12T00:00:00"), +// Token::StructEnd, +// ], +// ); +// } + +// #[test] +// fn game_deserialize_correctly() { +// let data = r#"{"id":3,"title":"Another game","rating":65,"genre":"STRATEGY","description":null,"releaseDate":"2016-03-11T00:00:00"}"#; +// let game: Game = serde_json::from_str(data).unwrap(); +// let expected_game = Game { +// id: 3, +// title: String::from("Another game"), +// rating: 65, +// genre: Genre::Strategy, +// description: None, +// release_date: NaiveDate::from_ymd(2016, 3, 11).and_hms(0, 0, 0), +// }; + +// assert_eq!(game, expected_game); +// } + +// #[test] +// fn game_error_when_wrong_rating_passed() { +// let data = r#"{"id":3,"title":"Another game","rating":120,"genre":"STRATEGY","description":null,"releaseDate":"2016-03-11T00:00:00"}"#; +// let err: Error = serde_json::from_str::(data).unwrap_err(); + +// assert_eq!(err.is_data(), true); +// } + +// #[test] +// fn genre_serialize_correctly() { +// let genre = Genre::Shooter; +// assert_tokens( +// &genre, +// &[Token::UnitVariant { +// name: "Genre", +// variant: "SHOOTER", +// }], +// ); + +// let genre = Genre::RolePlaying; +// assert_tokens( +// &genre, +// &[Token::UnitVariant { +// name: "Genre", +// variant: "ROLE_PLAYING", +// }], +// ); + +// let genre = Genre::Strategy; +// assert_tokens( +// &genre, +// &[Token::UnitVariant { +// name: "Genre", +// variant: "STRATEGY", +// }], +// ); +// } + +// #[test] +// fn genre_deserialize_correctly() { +// let data = r#""SHOOTER""#; +// let genre: Genre = serde_json::from_str(data).unwrap(); +// let expected_genre = Genre::Shooter; + +// assert_eq!(genre, expected_genre); + +// let data = r#""ROLE_PLAYING""#; +// let genre: Genre = serde_json::from_str(data).unwrap(); +// let expected_genre = Genre::RolePlaying; + +// assert_eq!(genre, expected_genre); +// } + +// #[test] +// fn genre_error_when_wrong_rating_passed() { +// let data = r#""SPORT""#; +// let err: Error = serde_json::from_str::(data).unwrap_err(); + +// assert_eq!(err.is_data(), true); +// } +// } diff --git a/src/validators.rs b/src/validators.rs new file mode 100644 index 0000000..dbebee8 --- /dev/null +++ b/src/validators.rs @@ -0,0 +1,44 @@ +// Custom validators incoming data + +use log::error; +use serde::de::{Deserializer, Error as DeserializerError, Unexpected}; +use serde::ser::{Error as SerializerError, Serializer}; +use serde::Deserialize; + +pub mod validate_game_rating { + use super::*; + + const ERROR_MESSAGE: &str = "rating must be a number between 0 and 100"; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = u8::deserialize(deserializer)?; + + if value > 100 { + error!("{}", ERROR_MESSAGE); + + return Err(DeserializerError::invalid_value( + Unexpected::Unsigned(u64::from(value)), + &ERROR_MESSAGE, + )); + } + + Ok(value) + } + + #[allow(clippy::trivially_copy_pass_by_ref)] + pub fn serialize(value: &u8, serializer: S) -> Result + where + S: Serializer, + { + if *value > 100 { + error!("{}", ERROR_MESSAGE); + + return Err(SerializerError::custom(ERROR_MESSAGE)); + } + + serializer.serialize_u8(*value) + } +} -- cgit v1.2.3-70-g09d2