Skip to content

Commit

Permalink
feat: add shuttle example (#96)
Browse files Browse the repository at this point in the history
* feat: add shuttle example

* docs: link to the example in readme.md

* chore: remove unused dependency

* fix: re-add shuttle-axum dev dependency
  • Loading branch information
JosephLenton authored Aug 20, 2024
1 parent 588c192 commit bc442ea
Show file tree
Hide file tree
Showing 8 changed files with 363 additions and 5 deletions.
7 changes: 3 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "axum-test"
authors = ["Joseph Lenton <josephlenton@gmail.com>"]
version = "15.5.0"
version = "15.5.1"
rust-version = "1.75"
edition = "2021"
license = "MIT"
Expand Down Expand Up @@ -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"
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
19 changes: 19 additions & 0 deletions examples/example-shuttle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<div align="center">
<h1>
Example REST Todo<br/>
</h1>

<h3>
an example application with tests
</h3>

<br/>
</div>

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.
323 changes: 323 additions & 0 deletions examples/example-shuttle/main.rs
Original file line number Diff line number Diff line change
@@ -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<RwLock<AppState>>;

// This my poor mans in memory DB.
#[derive(Debug)]
pub struct AppState {
user_todos: HashMap<u32, Vec<Todo>>,
}

#[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<Todo>,
}

#[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<u32> {
cookies
.get(&USER_ID_COOKIE_NAME)
.map(|c| c.value().to_string().parse::<u32>().ok())
.flatten()
.ok_or_else(|| anyhow!("id not found"))
}

pub async fn route_post_user_login(
State(ref mut state): State<SharedAppState>,
mut cookies: CookieJar,
Json(_body): Json<LoginRequest>,
) -> 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<SharedAppState>,
mut cookies: CookieJar,
Json(todo): Json<Todo>,
) -> StdResult<Json<u32>, 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<SharedAppState>,
mut cookies: CookieJar,
) -> StdResult<Json<Vec<Todo>>, 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::<u32>();
assert_eq!(num_todos, 1);

let num_todos = server
.put(&"/todo")
.json(&json!({
"name": "afternoon",
"content": "buy shoes",
}))
.await
.json::<u32>();
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::<Vec<Todo>>();

let expected_todos: Vec<Todo> = 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)
}
}
5 changes: 5 additions & 0 deletions examples/example-todo/main.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
5 changes: 5 additions & 0 deletions examples/example-websocket-chat/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit bc442ea

Please sign in to comment.