From bc442ea50a446ee02aabce4244733dc4fbd29beb Mon Sep 17 00:00:00 2001 From: Joseph Lenton Date: Tue, 20 Aug 2024 15:29:19 +0200 Subject: [PATCH] feat: add shuttle example (#96) * feat: add shuttle example * docs: link to the example in readme.md * chore: remove unused dependency * fix: re-add shuttle-axum dev dependency --- Cargo.toml | 7 +- README.md | 3 +- examples/example-shuttle/README.md | 19 ++ examples/example-shuttle/main.rs | 323 +++++++++++++++++++ examples/example-todo/main.rs | 5 + examples/example-websocket-chat/main.rs | 5 + examples/example-websocket-ping-pong/main.rs | 5 + test.sh | 1 + 8 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 examples/example-shuttle/README.md create mode 100644 examples/example-shuttle/main.rs diff --git a/Cargo.toml b/Cargo.toml index 82ba1a4..80f8877 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "axum-test" authors = ["Joseph Lenton "] -version = "15.5.0" +version = "15.5.1" rust-version = "1.75" edition = "2021" license = "MIT" @@ -77,7 +77,6 @@ futures-util = "0.3" local-ip-address = "0.6" regex = "1.10" serde-email = { version = "3.0", features = ["serde"] } +shuttle-axum = "0.47" +shuttle-runtime = "0.47" tokio = { version = "1.39", features = ["rt", "rt-multi-thread", "time", "macros"] } - -[[example]] -name = "example-todo" diff --git a/README.md b/README.md index fd64914..1797656 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ Here are a list of all features so far that can be enabled: You can find examples of writing tests in the [/examples folder](/examples/). These include tests for: - * [a REST Todo application](/examples/example-todo) + * [a simple REST Todo application](/examples/example-todo) + * [the REST Todo application using Shuttle](/examples/example-shuttle) * [a WebSocket ping pong application](/examples/example-websocket-ping-pong) which sends requests up and down * [a simple WebSocket chat application](/examples/example-websocket-chat) diff --git a/examples/example-shuttle/README.md b/examples/example-shuttle/README.md new file mode 100644 index 0000000..6135dd6 --- /dev/null +++ b/examples/example-shuttle/README.md @@ -0,0 +1,19 @@ +
+

+ Example REST Todo
+

+ +

+ an example application with tests +

+ +
+
+ +This is a very simple todo application. It aims to show ... + + * How to write some basic tests against end points. + * How to test for some tests to be expecting success, and some to be expecting failure. + * How to take cookies into account (like logging in). + +It's primarily to provide some code samples using axum-test. diff --git a/examples/example-shuttle/main.rs b/examples/example-shuttle/main.rs new file mode 100644 index 0000000..e5244e3 --- /dev/null +++ b/examples/example-shuttle/main.rs @@ -0,0 +1,323 @@ +//! +//! This is an example Todo Application, wrapped with Shuttle. +//! To show some simple tests when using Shuttle + Axum. +//! +//! ```bash +//! # To run it's tests: +//! cargo test --example=example-shuttle --features shuttle +//! ``` +//! +//! The app includes the end points for ... +//! +//! - POST /login ... this takes an email, and returns a session cookie. +//! - PUT /todo ... once logged in, one can store todos. +//! - GET /todo ... once logged in, you can retrieve all todos you have stored. +//! +//! At the bottom of this file are a series of tests for these endpoints. +//! + +use ::anyhow::anyhow; +use ::anyhow::Result; +use ::axum::extract::Json; +use ::axum::extract::State; +use ::axum::routing::get; +use ::axum::routing::post; +use ::axum::routing::put; +use ::axum::Router; +use ::axum_extra::extract::cookie::Cookie; +use ::axum_extra::extract::cookie::CookieJar; +use ::http::StatusCode; +use ::serde::Deserialize; +use ::serde::Serialize; +use ::serde_email::Email; +use ::std::collections::HashMap; +use ::std::result::Result as StdResult; +use ::std::sync::Arc; +use ::std::sync::RwLock; + +#[cfg(test)] +use ::axum_test::TestServer; +#[cfg(test)] +use ::axum_test::TestServerConfig; + +/// Main to start Shuttle application +#[shuttle_runtime::main] +async fn main() -> ::shuttle_axum::ShuttleAxum { + new_app() +} + +/// The Shuttle application itself +fn new_app() -> ::shuttle_axum::ShuttleAxum { + let state = AppState { + user_todos: HashMap::new(), + }; + let shared_state = Arc::new(RwLock::new(state)); + + let app = Router::new() + .route(&"/login", post(route_post_user_login)) + .route(&"/todo", get(route_get_user_todos)) + .route(&"/todo", put(route_put_user_todos)) + .with_state(shared_state); + + Ok(app.into()) +} + +/// A TestServer that runs the Shuttle application +#[cfg(test)] +fn new_test_app() -> TestServer { + TestServerConfig::builder() + // Preserve cookies across requests + // for the session cookie to work. + .save_cookies() + .expect_success_by_default() + .mock_transport() + .build_server(new_app()) // <- here the application is passed in + .unwrap() +} + +const USER_ID_COOKIE_NAME: &'static str = &"example-shuttle-user-id"; + +type SharedAppState = Arc>; + +// This my poor mans in memory DB. +#[derive(Debug)] +pub struct AppState { + user_todos: HashMap>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct Todo { + name: String, + content: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct LoginRequest { + user: Email, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AllTodos { + todos: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NumTodos { + num: u32, +} + +// Note you should never do something like this in a real application +// for session cookies. It's really bad. Like _seriously_ bad. +// +// This is done like this here to keep the code shorter. That's all. +fn get_user_id_from_cookie(cookies: &CookieJar) -> Result { + cookies + .get(&USER_ID_COOKIE_NAME) + .map(|c| c.value().to_string().parse::().ok()) + .flatten() + .ok_or_else(|| anyhow!("id not found")) +} + +pub async fn route_post_user_login( + State(ref mut state): State, + mut cookies: CookieJar, + Json(_body): Json, +) -> CookieJar { + let mut lock = state.write().unwrap(); + let user_todos = &mut lock.user_todos; + let user_id = user_todos.len() as u32; + user_todos.insert(user_id, vec![]); + + let really_insecure_login_cookie = Cookie::new(USER_ID_COOKIE_NAME, user_id.to_string()); + cookies = cookies.add(really_insecure_login_cookie); + + cookies +} + +pub async fn route_put_user_todos( + State(ref mut state): State, + mut cookies: CookieJar, + Json(todo): Json, +) -> StdResult, StatusCode> { + let user_id = get_user_id_from_cookie(&mut cookies).map_err(|_| StatusCode::UNAUTHORIZED)?; + + let mut lock = state.write().unwrap(); + let todos = lock.user_todos.get_mut(&user_id).unwrap(); + + todos.push(todo); + let num_todos = todos.len() as u32; + + Ok(Json(num_todos)) +} + +pub async fn route_get_user_todos( + State(ref state): State, + mut cookies: CookieJar, +) -> StdResult>, StatusCode> { + let user_id = get_user_id_from_cookie(&mut cookies).map_err(|_| StatusCode::UNAUTHORIZED)?; + + let lock = state.read().unwrap(); + let todos = lock.user_todos[&user_id].clone(); + + Ok(Json(todos)) +} + +#[cfg(test)] +mod test_post_login { + use super::*; + + use ::serde_json::json; + + #[tokio::test] + async fn it_should_create_session_on_login() { + let server = new_test_app(); + + let response = server + .post(&"/login") + .json(&json!({ + "user": "my-login@example.com", + })) + .await; + + let session_cookie = response.cookie(&USER_ID_COOKIE_NAME); + assert_ne!(session_cookie.value(), ""); + } + + #[tokio::test] + async fn it_should_not_login_using_non_email() { + let server = new_test_app(); + + let response = server + .post(&"/login") + .json(&json!({ + "user": "blah blah blah", + })) + .expect_failure() + .await; + + // There should not be a session created. + let cookie = response.maybe_cookie(&USER_ID_COOKIE_NAME); + assert!(cookie.is_none()); + } +} + +#[cfg(test)] +mod test_route_put_user_todos { + use super::*; + + use ::serde_json::json; + + #[tokio::test] + async fn it_should_not_store_todos_without_login() { + let server = new_test_app(); + + let response = server + .put(&"/todo") + .json(&json!({ + "name": "shopping", + "content": "buy eggs", + })) + .expect_failure() + .await; + + assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn it_should_return_number_of_todos_as_more_are_pushed() { + let server = new_test_app(); + + server + .post(&"/login") + .json(&json!({ + "user": "my-login@example.com", + })) + .await; + + let num_todos = server + .put(&"/todo") + .json(&json!({ + "name": "shopping", + "content": "buy eggs", + })) + .await + .json::(); + assert_eq!(num_todos, 1); + + let num_todos = server + .put(&"/todo") + .json(&json!({ + "name": "afternoon", + "content": "buy shoes", + })) + .await + .json::(); + assert_eq!(num_todos, 2); + } +} + +#[cfg(test)] +mod test_route_get_user_todos { + use super::*; + + use ::serde_json::json; + + #[tokio::test] + async fn it_should_not_return_todos_if_logged_out() { + let server = new_test_app(); + + let response = server + .put(&"/todo") + .json(&json!({ + "name": "shopping", + "content": "buy eggs", + })) + .expect_failure() + .await; + + assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn it_should_return_all_todos_when_logged_in() { + let server = new_test_app(); + + server + .post(&"/login") + .json(&json!({ + "user": "my-login@example.com", + })) + .await; + + // Push two todos. + server + .put(&"/todo") + .json(&json!({ + "name": "shopping", + "content": "buy eggs", + })) + .await; + server + .put(&"/todo") + .json(&json!({ + "name": "afternoon", + "content": "buy shoes", + })) + .await; + + // Get all todos out from the server. + let todos = server.get(&"/todo").await.json::>(); + + let expected_todos: Vec = vec![ + Todo { + name: "shopping".to_string(), + content: "buy eggs".to_string(), + }, + Todo { + name: "afternoon".to_string(), + content: "buy shoes".to_string(), + }, + ]; + assert_eq!(todos, expected_todos) + } +} diff --git a/examples/example-todo/main.rs b/examples/example-todo/main.rs index e4e9598..a24a5cb 100644 --- a/examples/example-todo/main.rs +++ b/examples/example-todo/main.rs @@ -1,6 +1,11 @@ //! //! This is an example Todo Application to show some simple tests. //! +//! ```bash +//! # To run it's tests: +//! cargo test --example=example-todo +//! ``` +//! //! The app includes the end points for ... //! //! - POST /login ... this takes an email, and returns a session cookie. diff --git a/examples/example-websocket-chat/main.rs b/examples/example-websocket-chat/main.rs index ebc7234..abdae9c 100644 --- a/examples/example-websocket-chat/main.rs +++ b/examples/example-websocket-chat/main.rs @@ -3,6 +3,11 @@ //! //! At the bottom of this file are a series of tests for using websockets. //! +//! ```bash +//! # To run it's tests: +//! cargo test --example=example-websocket-chat --features ws +//! ``` +//! use ::anyhow::Result; use ::axum::extract::ws::WebSocket; diff --git a/examples/example-websocket-ping-pong/main.rs b/examples/example-websocket-ping-pong/main.rs index 81a227f..77eb8b4 100644 --- a/examples/example-websocket-ping-pong/main.rs +++ b/examples/example-websocket-ping-pong/main.rs @@ -4,6 +4,11 @@ //! //! At the bottom of this file are a series of tests for using websockets. //! +//! ```bash +//! # To run it's tests: +//! cargo test --example=example-websocket-ping-pong --features ws +//! ``` +//! use ::anyhow::Result; use ::axum::extract::ws::WebSocket; diff --git a/test.sh b/test.sh index 9c2e319..b00e435 100755 --- a/test.sh +++ b/test.sh @@ -3,6 +3,7 @@ set -e cargo check +cargo test --example=example-shuttle --features shuttle cargo test --example=example-todo cargo test --example=example-websocket-ping-pong --features ws cargo test --example=example-websocket-chat --features ws