Vendor dependencies

This commit is contained in:
2026-08-01 16:11:49 +03:00
parent 7f139a0241
commit 6b5e7f0f8b
29706 changed files with 9575646 additions and 0 deletions
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "2b967e4204ad4a57484e545410d54f676de17828"
},
"path_in_vcs": ""
}
@@ -0,0 +1,8 @@
# Changes
*
# Comments
Any other business.
+3095
View File
File diff suppressed because it is too large Load Diff
+276
View File
@@ -0,0 +1,276 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
rust-version = "1.83"
name = "axum-test"
version = "17.3.0"
authors = ["Joseph Lenton <josephlenton@gmail.com>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "For spinning up and testing Axum servers"
documentation = "https://docs.rs/axum-test"
readme = "README.md"
keywords = [
"testing",
"test",
"axum",
]
categories = [
"web-programming::http-server",
"development-tools::testing",
]
license = "MIT"
repository = "https://github.com/JosephLenton/axum-test"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = [
"--cfg",
"docsrs",
]
[lib]
name = "axum_test"
path = "src/lib.rs"
[[example]]
name = "example-shuttle"
path = "examples/example-shuttle/main.rs"
required-features = ["shuttle"]
[[example]]
name = "example-todo"
path = "examples/example-todo/main.rs"
[[example]]
name = "example-websocket-chat"
path = "examples/example-websocket-chat/main.rs"
required-features = ["ws"]
[[example]]
name = "example-websocket-ping-pong"
path = "examples/example-websocket-ping-pong/main.rs"
required-features = ["ws"]
[dependencies.anyhow]
version = "1.0"
[dependencies.assert-json-diff]
version = "2.0"
[dependencies.auto-future]
version = "1.0"
[dependencies.axum]
version = "0.8.3"
features = []
[dependencies.axum-extra]
version = "0.10"
features = ["typed-routing"]
optional = true
[dependencies.base64]
version = "0.22"
optional = true
[dependencies.bytes]
version = "1.10"
[dependencies.bytesize]
version = "2.0"
[dependencies.cookie]
version = "0.18"
[dependencies.futures-util]
version = "0.3"
optional = true
[dependencies.http]
version = "1.3"
[dependencies.http-body-util]
version = "0.1"
[dependencies.hyper]
version = "1.6"
features = ["http1"]
[dependencies.hyper-util]
version = "0.1"
features = [
"client",
"http1",
"client-legacy",
]
[dependencies.mime]
version = "0.3"
[dependencies.pretty_assertions]
version = "1.4"
optional = true
[dependencies.reqwest]
version = "0.12"
features = [
"cookies",
"json",
"stream",
"multipart",
"rustls-tls",
]
optional = true
[dependencies.reserve-port]
version = "2.2"
[dependencies.rmp-serde]
version = "1.3"
optional = true
[dependencies.rust-multipart-rfc7578_2]
version = "0.8"
[dependencies.serde]
version = "1.0"
[dependencies.serde_json]
version = "1.0"
[dependencies.serde_urlencoded]
version = "0.7"
[dependencies.serde_yaml]
version = "0.9"
optional = true
[dependencies.shuttle-axum]
version = "0.53"
optional = true
[dependencies.smallvec]
version = "1.13"
[dependencies.tokio]
version = "1.43"
features = ["rt"]
[dependencies.tokio-tungstenite]
version = "0.26"
optional = true
[dependencies.tower]
version = "0.5"
features = [
"util",
"make",
]
[dependencies.url]
version = "2.5"
[dependencies.uuid]
version = "1.12"
features = ["v4"]
optional = true
[dev-dependencies.axum]
version = "0.8"
features = [
"multipart",
"tokio",
"ws",
]
[dev-dependencies.axum-extra]
version = "0.10"
features = [
"cookie",
"typed-routing",
"query",
]
[dev-dependencies.axum-msgpack]
version = "0.5"
[dev-dependencies.axum-yaml]
version = "0.5"
[dev-dependencies.futures-util]
version = "0.3"
[dev-dependencies.local-ip-address]
version = "0.6"
[dev-dependencies.rand]
version = "0.9"
features = ["small_rng"]
[dev-dependencies.regex]
version = "1.11"
[dev-dependencies.serde-email]
version = "3.1"
features = ["serde"]
[dev-dependencies.shuttle-axum]
version = "0.53"
[dev-dependencies.shuttle-runtime]
version = "0.53"
[dev-dependencies.tokio]
version = "1.43"
features = [
"rt",
"rt-multi-thread",
"sync",
"time",
"macros",
]
[dev-dependencies.tower-http]
version = "0.6"
features = ["normalize-path"]
[features]
all = [
"pretty-assertions",
"yaml",
"msgpack",
"reqwest",
"shuttle",
"typed-routing",
"ws",
]
default = ["pretty-assertions"]
msgpack = ["dep:rmp-serde"]
pretty-assertions = ["dep:pretty_assertions"]
reqwest = ["dep:reqwest"]
shuttle = ["dep:shuttle-axum"]
typed-routing = ["dep:axum-extra"]
ws = [
"axum/ws",
"tokio/time",
"dep:uuid",
"dep:base64",
"dep:tokio-tungstenite",
"dep:futures-util",
]
yaml = ["dep:serde_yaml"]
+107
View File
@@ -0,0 +1,107 @@
[package]
name = "axum-test"
authors = ["Joseph Lenton <josephlenton@gmail.com>"]
version = "17.3.0"
rust-version = "1.83"
edition = "2021"
license = "MIT"
description = "For spinning up and testing Axum servers"
keywords = ["testing", "test", "axum"]
categories = ["web-programming::http-server", "development-tools::testing"]
repository = "https://github.com/JosephLenton/axum-test"
documentation = "https://docs.rs/axum-test"
readme = "README.md"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[[example]]
name = "example-shuttle"
path = "examples/example-shuttle/main.rs"
required-features = ["shuttle"]
[[example]]
name = "example-websocket-chat"
path = "examples/example-websocket-chat/main.rs"
required-features = ["ws"]
[[example]]
name = "example-websocket-ping-pong"
path = "examples/example-websocket-ping-pong/main.rs"
required-features = ["ws"]
[features]
default = ["pretty-assertions"]
all = ["pretty-assertions", "yaml", "msgpack", "reqwest", "shuttle", "typed-routing", "ws"]
pretty-assertions = ["dep:pretty_assertions"]
yaml = ["dep:serde_yaml"]
msgpack = ["dep:rmp-serde"]
shuttle = ["dep:shuttle-axum"]
typed-routing = ["dep:axum-extra"]
ws = ["axum/ws", "tokio/time", "dep:uuid", "dep:base64", "dep:tokio-tungstenite", "dep:futures-util"]
reqwest = ["dep:reqwest"]
[dependencies]
auto-future = "1.0"
assert-json-diff = "2.0"
axum = { version = "0.8.3", features = [] }
anyhow = "1.0"
bytes = "1.10"
bytesize = "2.0"
cookie = "0.18"
http = "1.3"
http-body-util = "0.1"
hyper-util = { version = "0.1", features = ["client", "http1", "client-legacy"] }
hyper = { version = "1.6", features = ["http1"] }
mime = "0.3"
rust-multipart-rfc7578_2 = "0.8"
reserve-port = "2.2"
serde = { version = "1.0" }
serde_json = "1.0"
serde_urlencoded = "0.7"
smallvec = "1.13"
tokio = { version = "1.43", features = ["rt"] }
tower = { version = "0.5", features = ["util", "make"] }
url = "2.5"
# Pretty Assertions
pretty_assertions = { version = "1.4", optional = true }
# Yaml
serde_yaml = { version = "0.9", optional = true }
# Shuttle
shuttle-axum = { version = "0.53", optional = true }
# MsgPack
rmp-serde = { version = "1.3", optional = true }
# Typed Routing
axum-extra = { version = "0.10", features = ["typed-routing"], optional = true }
# WebSockets
uuid = { version = "1.12", optional = true, features = ["v4"]}
base64 = { version = "0.22", optional = true }
futures-util = { version = "0.3", optional = true }
tokio-tungstenite = { version = "0.26", optional = true }
# Reqwest
reqwest = { version = "0.12", optional = true, features = ["cookies", "json", "stream", "multipart", "rustls-tls"] }
[dev-dependencies]
axum = { version = "0.8", features = ["multipart", "tokio", "ws"] }
axum-extra = { version = "0.10", features = ["cookie", "typed-routing", "query"] }
axum-msgpack = "0.5"
axum-yaml = "0.5"
futures-util = "0.3"
local-ip-address = "0.6"
rand = { version = "0.9", features = ["small_rng"] }
regex = "1.11"
serde-email = { version = "3.1", features = ["serde"] }
shuttle-axum = "0.53"
shuttle-runtime = "0.53"
tokio = { version = "1.43", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tower-http = { version = "0.6", features = ["normalize-path"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Joseph Lenton
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+114
View File
@@ -0,0 +1,114 @@
<div align="center">
<h1>
Axum Test
</h1>
<h3>
Easy E2E testing for Axum<br/>
including REST, WebSockets, and more
</h3>
[![crate](https://img.shields.io/crates/v/axum-test.svg)](https://crates.io/crates/axum-test)
[![docs](https://docs.rs/axum-test/badge.svg)](https://docs.rs/axum-test)
<br/>
</div>
This runs your application locally, allowing you to query against it with requests.
Decode the responses, and assert what is returned.
```rust
use axum::Router;
use axum::routing::get;
use axum_test::TestServer;
#[tokio::test]
async fn it_should_ping_pong() {
// Build an application with a route.
let app = Router::new()
.route(&"/ping", get(|| async { "pong!" }));
// Run the application for testing.
let server = TestServer::new(app).unwrap();
// Get the request.
let response = server
.get("/ping")
.await;
// Assertions.
response.assert_status_ok();
response.assert_text("pong!");
}
```
A `TestServer` enables you to run an Axum service with a mocked network,
or on a random port with real network reqeusts.
In both cases allowing you to run multiple servers, across multiple tests, all in parallel.
## Crate Features
| Feature | On by default | |
|---------------------|---------------|-----------------------------------------------------------------------------------------------------------------------------------|
| `all` | _off_ | Turns on all features. |
| `pretty-assertions` | **on** | Uses the [pretty assertions crate](https://crates.io/crates/pretty_assertions) on response `assert_*` methods. |
| `yaml` | _off_ | Enables support for sending, receiving, and asserting, [yaml content](https://yaml.org/). |
| `msgpack` | _off_ | Enables support for sending, receiving, and asserting, [msgpack content](https://msgpack.org/index.html). |
| `shuttle` | _off_ | Enables support for building a `TestServer` an [`shuttle_axum::AxumService`](https://docs.rs/shuttle-axum/latest/shuttle_axum/struct.AxumService.html), for use with [Shuttle.rs](https://shuttle.rs). |
| `typed-routing` | _off_ | Enables support for using `TypedPath` in requests. See [axum-extra](https://crates.io/crates/axum-extra) for details. |
| `ws` | _off_ | Enables WebSocket support. See [TestWebSocket](https://docs.rs/axum-test/latest/axum_test/struct.TestWebSocket.html) for details. |
| `reqwest` | _off_ | Enables the `TestServer` being able to create [Reqwest](https://docs.rs/axum-test/latest/axum_test/struct.TestWebSocket.html) requests for querying. |
## Axum Compatability
The current version of Axum Test requires at least Axum v0.8.3.
Here is a list of compatability with prior versions:
| Axum Version | Axum Test Version |
|-----------------|-------------------|
| 0.8.3+ (latest) | 17.3+ (latest) |
| 0.8.0 | 17 |
| 0.7.6 to 0.7.9 | 16 |
| 0.7.0 to 0.7.5 | 14, 15 |
| 0.6 | 13.4.1 |
## Examples
You can find examples of writing tests in the [/examples folder](/examples/).
These include tests for:
* [a simple REST Todo application](/examples/example-todo), and [the same 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)
## Request Building Features
Querying your application on the `TestServer` supports all of the common request building you would expect.
- Serializing and deserializing Json, Form, Yaml, and others, using Serde
- Assertions on the Json, text, Yaml, etc, that is returned.
- Cookie, query, and header setting and reading
- Status code reading and assertions
### Also includes
- WebSockets testing support
- Saving returned cookies for use on future requests
- Setting headers, query, and cookies, globally for all requests or on per request basis
- Can run requests using a real web server, or with mocked HTTP
- Automatic status assertions for expecting requests to succeed (to help catch bugs in tests sooner)
- Prettified assertion output
- Typed Routing from Axum Extra
- Reqwest integration
## Contributions
A big thanks to all of these who have helped!
<a href="https://github.com/josephlenton/axum-test/graphs/contributors">
<img src="https://contrib.rocks/image?repo=josephlenton/axum-test" />
</a>
Made with [contrib.rocks](https://contrib.rocks).
@@ -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.
@@ -0,0 +1,321 @@
//!
//! 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;
/// 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 {
TestServer::builder()
// Preserve cookies across requests
// for the session cookie to work.
.save_cookies()
.expect_success_by_default()
.mock_transport()
.build(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)
}
}
@@ -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.
@@ -0,0 +1,337 @@
//!
//! 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.
//! - 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::serve::serve;
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::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::result::Result as StdResult;
use std::sync::Arc;
use std::sync::RwLock;
use tokio::net::TcpListener;
#[cfg(test)]
use axum_test::TestServer;
const PORT: u16 = 8080;
const USER_ID_COOKIE_NAME: &'static str = &"example-todo-user-id";
#[tokio::main]
async fn main() {
let result: Result<()> = {
let app = new_app();
// Start!
let ip_address = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
let address = SocketAddr::new(ip_address, PORT);
let listener = TcpListener::bind(address).await.unwrap();
serve(listener, app.into_make_service()).await.unwrap();
Ok(())
};
match &result {
Err(err) => eprintln!("{}", err),
_ => {}
};
}
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))
}
pub(crate) fn new_app() -> Router {
let state = AppState {
user_todos: HashMap::new(),
};
let shared_state = Arc::new(RwLock::new(state));
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)
}
#[cfg(test)]
fn new_test_app() -> TestServer {
let app = new_app();
TestServer::builder()
// Preserve cookies across requests
// for the session cookie to work.
.save_cookies()
.expect_success_by_default()
.mock_transport()
.build(app)
.unwrap()
}
#[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)
}
}
@@ -0,0 +1,18 @@
<div align="center">
<h1>
Example WebSockets Chat<br/>
</h1>
<h3>
a simple chat application with tests
</h3>
<br/>
</div>
This is a very simple application using WebSockets. It aims to show ...
* How to write a very basic chat application,
* and include tests which send and receive data.
It's primarily to provide some code samples using axum-test.
@@ -0,0 +1,242 @@
//!
//! This is an example Todo Application using Web Sockets for communication.
//!
//! 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::Message;
use axum::extract::ws::WebSocket;
use axum::extract::Path;
use axum::extract::State;
use axum::extract::WebSocketUpgrade;
use axum::response::Response;
use axum::routing::get;
use axum::serve::serve;
use axum::Router;
use futures_util::SinkExt;
use futures_util::StreamExt;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::RwLock;
#[cfg(test)]
use axum_test::TestServer;
const PORT: u16 = 8080;
#[tokio::main]
async fn main() {
let result: Result<()> = {
let app = new_app();
// Start!
let ip_address = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
let address = SocketAddr::new(ip_address, PORT);
let listener = TcpListener::bind(address).await.unwrap();
serve(listener, app.into_make_service()).await.unwrap();
Ok(())
};
match &result {
Err(err) => eprintln!("{}", err),
_ => {}
};
}
type SharedAppState = Arc<RwLock<AppState>>;
/// This my poor mans chat system.
///
/// It holds a map of User ID to Messages.
#[derive(Debug)]
pub struct AppState {
user_messages: HashMap<String, Vec<ChatReceivedMessage>>,
}
#[derive(Deserialize, Serialize, Debug, PartialEq)]
pub struct ChatSendMessage {
pub to: String,
pub message: String,
}
#[derive(Deserialize, Serialize, Debug, PartialEq)]
pub struct ChatReceivedMessage {
pub from: String,
pub message: String,
}
pub async fn route_get_websocket_chat(
State(state): State<SharedAppState>,
Path(username): Path<String>,
ws: WebSocketUpgrade,
) -> Response {
ws.on_upgrade(move |socket| handle_chat(socket, username, state.clone()))
}
async fn handle_chat(socket: WebSocket, username: String, state: SharedAppState) {
let (mut sender, mut receiver) = socket.split();
// Spawn a task that will push several messages to the client (does not matter what client does)
let send_state = state.clone();
let send_username = username.clone();
let mut send_task = tokio::spawn(async move {
loop {
let mut state_locked = send_state.write().await;
let maybe_messages = state_locked.user_messages.get_mut(&send_username);
if let Some(messages) = maybe_messages {
while let Some(message) = messages.pop() {
let json_text = serde_json::to_string(&message)
.expect("Failed to build JSON message for sending");
sender
.send(Message::Text(json_text.into()))
.await
.expect("Failed to send message to socket");
}
}
::tokio::time::sleep(Duration::from_millis(10)).await;
}
});
// This second task will receive messages from client and print them on server console
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = receiver.next().await {
let raw_text = message
.into_text()
.expect("Failed to read text from incoming message");
let decoded = serde_json::from_str::<ChatSendMessage>(&raw_text)
.expect("Failed to decode incoming JSON message");
let mut state_locked = state.write().await;
let maybe_messages = state_locked.user_messages.entry(decoded.to);
maybe_messages.or_default().push(ChatReceivedMessage {
from: username.clone(),
message: decoded.message,
});
}
});
// If any one of the tasks exit, abort the other.
tokio::select! {
rv_a = (&mut send_task) => {
match rv_a {
Ok(_) => println!("Messages sent"),
Err(a) => println!("Error sending messages {a:?}")
}
recv_task.abort();
},
rv_b = (&mut recv_task) => {
match rv_b {
Ok(_) => println!("Received messages"),
Err(b) => println!("Error receiving messages {b:?}")
}
send_task.abort();
}
}
}
pub(crate) fn new_app() -> Router {
let state = AppState {
user_messages: HashMap::new(),
};
let shared_state = Arc::new(RwLock::new(state));
Router::new()
.route(&"/ws-chat/{name}", get(route_get_websocket_chat))
.with_state(shared_state)
}
#[cfg(test)]
fn new_test_app() -> TestServer {
let app = new_app();
TestServer::builder()
.http_transport() // Important! It must be a HTTP Transport here.
.build(app)
.unwrap()
}
#[cfg(test)]
mod test_websockets_chat {
use super::*;
#[tokio::test]
async fn it_should_start_a_websocket_connection() {
let server = new_test_app();
let response = server.get_websocket(&"/ws-chat/john").await;
response.assert_status_switching_protocols();
}
#[tokio::test]
async fn it_should_send_messages_back_and_forth() {
let server = new_test_app();
let mut alice_chat = server
.get_websocket(&"/ws-chat/alice")
.await
.into_websocket()
.await;
let mut bob_chat = server
.get_websocket(&"/ws-chat/bob")
.await
.into_websocket()
.await;
bob_chat
.send_json(&ChatSendMessage {
to: "alice".to_string(),
message: "How are you Alice?".to_string(),
})
.await;
alice_chat
.assert_receive_json(&ChatReceivedMessage {
from: "bob".to_string(),
message: "How are you Alice?".to_string(),
})
.await;
alice_chat
.send_json(&ChatSendMessage {
to: "bob".to_string(),
message: "I am good".to_string(),
})
.await;
alice_chat
.send_json(&ChatSendMessage {
to: "bob".to_string(),
message: "How are you?".to_string(),
})
.await;
bob_chat
.assert_receive_json(&ChatReceivedMessage {
from: "alice".to_string(),
message: "I am good".to_string(),
})
.await;
bob_chat
.assert_receive_json(&ChatReceivedMessage {
from: "alice".to_string(),
message: "How are you?".to_string(),
})
.await;
}
}
@@ -0,0 +1,18 @@
<div align="center">
<h1>
Example WebSockets Ping Pong<br/>
</h1>
<h3>
an example websocket application with tests
</h3>
<br/>
</div>
This is a very simple application using WebSockets. It aims to show ...
* How to write a basic test that starts a WebSocket connection.
* A basic ping pong test, where data is pushed up and down.
It's primarily to provide some code samples using axum-test.
@@ -0,0 +1,135 @@
//!
//! This is a simple WebSocket example Application.
//! You send it data, and it will send it back.
//!
//! 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;
use axum::extract::WebSocketUpgrade;
use axum::response::Response;
use axum::routing::get;
use axum::serve::serve;
use axum::Router;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use tokio::net::TcpListener;
#[cfg(test)]
use axum_test::TestServer;
const PORT: u16 = 8080;
#[tokio::main]
async fn main() {
let result: Result<()> = {
let app = new_app();
// Start!
let ip_address = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
let address = SocketAddr::new(ip_address, PORT);
let listener = TcpListener::bind(address).await.unwrap();
serve(listener, app.into_make_service()).await.unwrap();
Ok(())
};
match &result {
Err(err) => eprintln!("{}", err),
_ => {}
};
}
pub async fn route_get_websocket_ping_pong(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(move |socket| handle_ping_pong(socket))
}
async fn handle_ping_pong(mut socket: WebSocket) {
while let Some(msg) = socket.recv().await {
let msg = if let Ok(msg) = msg {
msg
} else {
// client disconnected
return;
};
if socket.send(msg).await.is_err() {
// client disconnected
return;
}
}
}
pub(crate) fn new_app() -> Router {
Router::new().route(&"/ws-ping-pong", get(route_get_websocket_ping_pong))
}
#[cfg(test)]
fn new_test_app() -> TestServer {
let app = new_app();
TestServer::builder()
.http_transport() // Important! It must be a HTTP Transport here.
.build(app)
.unwrap()
}
#[cfg(test)]
mod test_websockets_ping_pong {
use super::*;
use serde_json::json;
#[tokio::test]
async fn it_should_start_a_websocket_connection() {
let server = new_test_app();
let response = server.get_websocket(&"/ws-ping-pong").await;
response.assert_status_switching_protocols();
}
#[tokio::test]
async fn it_should_ping_pong_text() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket.send_text("Hello!").await;
websocket.assert_receive_text("Hello!").await;
}
#[tokio::test]
async fn it_should_ping_pong_json() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket
.send_json(&json!({
"hello": "world",
"numbers": [1, 2, 3],
}))
.await;
websocket
.assert_receive_json(&json!({
"hello": "world",
"numbers": [1, 2, 3],
}))
.await;
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "Joe",
"age": 20
}
+1
View File
@@ -0,0 +1 @@
hello!
+2
View File
@@ -0,0 +1,2 @@
name: Joe
age: 20
+1
View File
@@ -0,0 +1 @@
stable
@@ -0,0 +1,275 @@
use crate::TestResponse;
use bytesize::ByteSize;
use std::fmt::Display;
use std::fmt::Formatter;
use std::fmt::Result as FmtResult;
/// An arbituary limit to avoid printing gigabytes to the terminal.
const MAX_TEXT_PRINT_LEN: usize = 10_000;
#[derive(Debug)]
pub struct DebugResponseBody<'a>(pub &'a TestResponse);
impl Display for DebugResponseBody<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match self.0.maybe_content_type() {
Some(content_type) => {
match content_type.as_str() {
// Json
"application/json" | "text/json" => write_json(f, self.0),
// Msgpack
"application/msgpack" => write!(f, "<MsgPack>"),
// Yaml
#[cfg(feature = "yaml")]
"application/yaml" | "application/x-yaml" | "text/yaml" => {
write_yaml(f, self.0)
}
#[cfg(not(feature = "yaml"))]
"application/yaml" | "application/x-yaml" | "text/yaml" => {
write_text(f, &self.0.text())
}
// Text Content
s if s.starts_with("text/") => write_text(f, &self.0.text()),
// Byte Streams
"application/octet-stream" => {
let len = self.0.as_bytes().len();
write!(f, "<Bytes, with len {}>", ByteSize(len as u64))
}
// Unknown content type
_ => {
let len = self.0.as_bytes().len();
write!(
f,
"<Unknown content type, with len {}>",
ByteSize(len as u64)
)
}
}
}
// We just default to text
_ => write_text(f, &self.0.text()),
}
}
}
fn write_text(f: &mut Formatter<'_>, text: &str) -> FmtResult {
let len = text.len();
if len < MAX_TEXT_PRINT_LEN {
write!(f, "'{}'", text)
} else {
let text_start = text.chars().take(MAX_TEXT_PRINT_LEN);
write!(f, "'")?;
for c in text_start {
write!(f, "{c}")?;
}
write!(f, "...'")?;
Ok(())
}
}
fn write_json(f: &mut Formatter<'_>, response: &TestResponse) -> FmtResult {
let bytes = response.as_bytes();
let result = serde_json::from_slice::<serde_json::Value>(bytes);
match result {
Err(_) => {
write!(
f,
"!!! YOUR JSON IS MALFORMED !!!\nBody: '{}'",
response.text()
)
}
Ok(body) => {
let pretty_raw = serde_json::to_string_pretty(&body)
.expect("Failed to reserialise serde_json::Value of request body");
write!(f, "{pretty_raw}")
}
}
}
#[cfg(feature = "yaml")]
fn write_yaml(f: &mut Formatter<'_>, response: &TestResponse) -> FmtResult {
let response_bytes = response.as_bytes();
let result = serde_yaml::from_slice::<serde_yaml::Value>(response_bytes);
match result {
Err(_) => {
write!(
f,
"!!! YOUR YAML IS MALFORMED !!!\nBody: '{}'",
response.text()
)
}
Ok(body) => {
let pretty_raw = serde_yaml::to_string(&body)
.expect("Failed to reserialise serde_yaml::Value of request body");
write!(f, "{pretty_raw}")
}
}
}
#[cfg(test)]
mod test_fmt {
use super::*;
use crate::TestServer;
use axum::body::Body;
use axum::response::IntoResponse;
use axum::response::Response;
use axum::routing::get;
use axum::Json;
use axum::Router;
use http::header;
use http::HeaderValue;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct ExampleResponse {
name: String,
age: u32,
}
#[tokio::test]
async fn it_should_display_text_response_as_text() {
let router = Router::new().route("/text", get(|| async { "Blah blah" }));
let response = TestServer::new(router).unwrap().get("/text").await;
let debug_body = DebugResponseBody(&response);
let output = format!("{debug_body}");
assert_eq!(output, "'Blah blah'");
}
#[tokio::test]
async fn it_should_cutoff_very_long_text() {
let router = Router::new().route(
"/text",
get(|| async {
let max_len = MAX_TEXT_PRINT_LEN + 100;
(0..max_len).map(|_| "🦊").collect::<String>()
}),
);
let response = TestServer::new(router).unwrap().get("/text").await;
let debug_body = DebugResponseBody(&response);
let output = format!("{debug_body}");
let expected_content = (0..MAX_TEXT_PRINT_LEN).map(|_| "🦊").collect::<String>();
let expected = format!("'{expected_content}...'");
assert_eq!(output, expected);
}
#[tokio::test]
async fn it_should_pretty_print_json() {
let router = Router::new().route(
"/json",
get(|| async {
Json(ExampleResponse {
name: "Joe".to_string(),
age: 20,
})
}),
);
let response = TestServer::new(router).unwrap().get("/json").await;
let debug_body = DebugResponseBody(&response);
let output = format!("{debug_body}");
let expected = r###"{
"age": 20,
"name": "Joe"
}"###;
assert_eq!(output, expected);
}
#[tokio::test]
async fn it_should_warn_malformed_json() {
let router = Router::new().route(
"/json",
get(|| async {
let body = Body::new(r###"{ "name": "Joe" "###.to_string());
Response::builder()
.header(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
)
.body(body)
.unwrap()
.into_response()
}),
);
let response = TestServer::new(router).unwrap().get("/json").await;
let debug_body = DebugResponseBody(&response);
let output = format!("{debug_body}");
let expected = r###"!!! YOUR JSON IS MALFORMED !!!
Body: '{ "name": "Joe" '"###;
assert_eq!(output, expected);
}
#[cfg(feature = "yaml")]
#[tokio::test]
async fn it_should_pretty_print_yaml() {
use axum_yaml::Yaml;
let router = Router::new().route(
"/yaml",
get(|| async {
Yaml(ExampleResponse {
name: "Joe".to_string(),
age: 20,
})
}),
);
let response = TestServer::new(router).unwrap().get("/yaml").await;
let debug_body = DebugResponseBody(&response);
let output = format!("{debug_body}");
let expected = r###"name: Joe
age: 20
"###;
assert_eq!(output, expected);
}
#[cfg(feature = "yaml")]
#[tokio::test]
async fn it_should_warn_on_malformed_yaml() {
let router = Router::new().route(
"/yaml",
get(|| async {
let body = Body::new("🦊 🦊 🦊: : :🦊 🦊 🦊".to_string());
Response::builder()
.header(
header::CONTENT_TYPE,
HeaderValue::from_static("application/yaml"),
)
.body(body)
.unwrap()
.into_response()
}),
);
let response = TestServer::new(router).unwrap().get("/yaml").await;
let debug_body = DebugResponseBody(&response);
let output = format!("{debug_body}");
let expected = r###"!!! YOUR YAML IS MALFORMED !!!
Body: '🦊 🦊 🦊: : :🦊 🦊 🦊'"###;
assert_eq!(output, expected);
}
}
@@ -0,0 +1,39 @@
#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash)]
pub enum ExpectedState {
Success,
Failure,
None,
}
impl From<Option<bool>> for ExpectedState {
fn from(maybe_success: Option<bool>) -> Self {
match maybe_success {
None => Self::None,
Some(true) => Self::Success,
Some(false) => Self::Failure,
}
}
}
#[cfg(test)]
mod test_from {
use super::*;
#[test]
fn it_should_turn_none_to_none() {
let output = ExpectedState::from(None);
assert_eq!(output, ExpectedState::None);
}
#[test]
fn it_should_turn_true_to_success() {
let output = ExpectedState::from(Some(true));
assert_eq!(output, ExpectedState::Success);
}
#[test]
fn it_should_turn_false_to_failure() {
let output = ExpectedState::from(Some(false));
assert_eq!(output, ExpectedState::Failure);
}
}
@@ -0,0 +1,76 @@
use http::StatusCode;
use std::fmt::Write;
use std::ops::Bound;
use std::ops::RangeBounds;
pub fn format_status_code_range<R>(range: R) -> String
where
R: RangeBounds<StatusCode>,
{
let mut output = String::new();
let start = range.start_bound();
let end = range.end_bound();
match start {
Bound::Included(code) | Bound::Excluded(code) => {
write!(output, "{}", code.as_u16()).expect("Failed to build debug string");
}
Bound::Unbounded => {}
};
write!(output, "..").expect("Failed to build debug string");
match end {
Bound::Included(code) => {
write!(output, "={}", code.as_u16()).expect("Failed to build debug string");
}
Bound::Excluded(code) => {
write!(output, "{}", code.as_u16()).expect("Failed to build debug string");
}
Bound::Unbounded => {}
};
output
}
#[cfg(test)]
mod test_format_status_code_range {
use super::*;
#[test]
fn it_should_format_range() {
let output = format_status_code_range(StatusCode::OK..StatusCode::IM_USED);
assert_eq!(output, "200..226");
}
#[test]
fn it_should_format_range_inclusive() {
let output = format_status_code_range(StatusCode::OK..=StatusCode::IM_USED);
assert_eq!(output, "200..=226");
}
#[test]
fn it_should_format_range_from() {
let output = format_status_code_range(StatusCode::OK..);
assert_eq!(output, "200..");
}
#[test]
fn it_should_format_range_to() {
let output = format_status_code_range(..StatusCode::IM_USED);
assert_eq!(output, "..226");
}
#[test]
fn it_should_format_range_to_inclusive() {
let output = format_status_code_range(..=StatusCode::IM_USED);
assert_eq!(output, "..=226");
}
#[test]
fn it_should_format_range_full() {
let output = format_status_code_range(..);
assert_eq!(output, "..");
}
}
+34
View File
@@ -0,0 +1,34 @@
mod transport_layer;
pub use self::transport_layer::*;
#[cfg(feature = "ws")]
mod websockets;
#[cfg(feature = "ws")]
pub use self::websockets::*;
mod debug_response_body;
pub use self::debug_response_body::*;
mod expected_state;
pub use self::expected_state::*;
mod format_status_code_range;
pub use self::format_status_code_range::*;
mod status_code_formatter;
pub use self::status_code_formatter::*;
mod request_path_formatter;
pub use self::request_path_formatter::*;
mod query_params_store;
pub use self::query_params_store::*;
mod try_into_range_bounds;
pub use self::try_into_range_bounds::*;
mod starting_tcp_setup;
pub use self::starting_tcp_setup::*;
mod with_this_mut;
pub use self::with_this_mut::*;
@@ -0,0 +1,140 @@
use anyhow::Result;
use serde::Serialize;
use smallvec::SmallVec;
use std::fmt::Display;
use std::fmt::Formatter;
use std::fmt::Result as FmtResult;
#[derive(Debug, Clone, PartialEq)]
pub struct QueryParamsStore {
query_params: SmallVec<[String; 0]>,
}
impl QueryParamsStore {
pub fn new() -> Self {
Self {
query_params: SmallVec::new(),
}
}
pub fn add<V>(&mut self, query_params: V) -> Result<()>
where
V: Serialize,
{
let value_raw = ::serde_urlencoded::to_string(query_params)?;
self.add_raw(value_raw);
Ok(())
}
pub fn add_raw(&mut self, value_raw: String) {
self.query_params.push(value_raw);
}
pub fn clear(&mut self) {
self.query_params.clear();
}
pub fn is_empty(&self) -> bool {
self.query_params.is_empty()
}
pub fn has_content(&self) -> bool {
!self.is_empty()
}
}
impl Display for QueryParamsStore {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
let mut is_joining = false;
for query in &self.query_params {
if is_joining {
write!(f, "&")?;
}
write!(f, "{}", query)?;
is_joining = true;
}
Ok(())
}
}
#[cfg(test)]
mod test_add {
use super::*;
#[test]
fn it_should_add_multiple_key_values() {
let mut params = QueryParamsStore::new();
params
.add(&[("key", "value"), ("another", "value")])
.unwrap();
assert_eq!("key=value&another=value", params.to_string());
}
#[test]
fn it_should_add_multiple_calls() {
let mut params = QueryParamsStore::new();
params.add(&[("key", "value")]).unwrap();
params.add(&[("another", "value")]).unwrap();
assert_eq!("key=value&another=value", params.to_string());
}
#[test]
fn it_should_reject_raw_string() {
let mut params = QueryParamsStore::new();
let result = params.add("key=value");
assert!(result.is_err());
}
#[test]
fn it_should_add_query_param_strings_deserialized() {
let mut params = QueryParamsStore::new();
params.add(&[("key", "value&another=value")]).unwrap();
assert_eq!("key=value%26another%3Dvalue", params.to_string());
}
}
#[cfg(test)]
mod test_add_raw {
use crate::internals::QueryParamsStore;
#[test]
fn it_should_add_key_value_pairs_correctly() {
let mut params = QueryParamsStore::new();
params.add_raw("key=value".to_string());
params.add_raw("another=value".to_string());
assert_eq!("key=value&another=value", params.to_string());
}
#[test]
fn it_should_add_single_keys_correctly() {
let mut params = QueryParamsStore::new();
params.add_raw("key".to_string());
params.add_raw("another".to_string());
assert_eq!("key&another", params.to_string());
}
#[test]
fn it_should_add_query_param_strings_correctly() {
let mut params = QueryParamsStore::new();
params.add_raw("key=value&another=value".to_string());
params.add_raw("more=value".to_string());
assert_eq!("key=value&another=value&more=value", params.to_string());
}
}
@@ -0,0 +1,81 @@
use http::Method;
use std::fmt;
use crate::internals::QueryParamsStore;
#[derive(Debug, Clone, PartialEq)]
pub struct RequestPathFormatter<'a> {
method: &'a Method,
/// This is the path that the user requested.
user_requested_path: &'a str,
query_params: Option<&'a QueryParamsStore>,
}
impl<'a> RequestPathFormatter<'a> {
pub fn new(
method: &'a Method,
user_requested_path: &'a str,
query_params: Option<&'a QueryParamsStore>,
) -> Self {
Self {
method,
user_requested_path,
query_params,
}
}
}
impl fmt::Display for RequestPathFormatter<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let method = &self.method;
let user_requested_path = &self.user_requested_path;
match self.query_params {
None => {
write!(f, "{method} {user_requested_path}")
}
Some(query_params) => {
if query_params.is_empty() {
write!(f, "{method} {user_requested_path}")
} else {
write!(f, "{method} {user_requested_path}?{query_params}")
}
}
}
}
}
#[cfg(test)]
mod test_fmt {
use super::*;
#[test]
fn it_should_format_with_path_given() {
let query_params = QueryParamsStore::new();
let debug = RequestPathFormatter::new(&Method::GET, &"/donkeys", Some(&query_params));
let output = format!("{}", debug);
assert_eq!(output, "GET /donkeys");
}
#[test]
fn it_should_format_with_path_given_and_no_query_params() {
let debug = RequestPathFormatter::new(&Method::GET, &"/donkeys", None);
let output = format!("{}", debug);
assert_eq!(output, "GET /donkeys");
}
#[test]
fn it_should_format_with_path_given_and_query_params() {
let mut query_params = QueryParamsStore::new();
query_params.add_raw("value=123".to_string());
query_params.add_raw("another-value".to_string());
let debug = RequestPathFormatter::new(&Method::GET, &"/donkeys", Some(&query_params));
let output = format!("{}", debug);
assert_eq!(output, "GET /donkeys?value=123&another-value");
}
}
@@ -0,0 +1,110 @@
use anyhow::Context;
use anyhow::Result;
use reserve_port::ReservedPort;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::net::TcpListener as StdTcpListener;
use tokio::net::TcpListener as TokioTcpListener;
pub const DEFAULT_IP_ADDRESS: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
#[derive(Debug)]
pub struct StartingTcpSetup {
pub maybe_reserved_port: Option<ReservedPort>,
pub socket_addr: SocketAddr,
pub tcp_listener: TokioTcpListener,
}
impl StartingTcpSetup {
pub fn new(maybe_ip: Option<IpAddr>, maybe_port: Option<u16>) -> Result<Self> {
let ip = maybe_ip.unwrap_or(DEFAULT_IP_ADDRESS);
maybe_port
.map(|port| Self::new_with_port(ip, port))
.unwrap_or_else(|| Self::new_without_port(ip))
}
fn new_with_port(ip: IpAddr, port: u16) -> Result<Self> {
ReservedPort::reserve_port(port)?;
let socket_addr = SocketAddr::new(ip, port);
let std_tcp_listener = StdTcpListener::bind(socket_addr)
.context("Failed to create TCPListener for TestServer")?;
std_tcp_listener.set_nonblocking(true)?;
let tokio_tcp_listener = TokioTcpListener::from_std(std_tcp_listener)?;
Ok(Self {
maybe_reserved_port: None,
socket_addr,
tcp_listener: tokio_tcp_listener,
})
}
fn new_without_port(ip: IpAddr) -> Result<Self> {
let (reserved_port, std_tcp_listener) = ReservedPort::random_with_tcp(ip)?;
let socket_addr = SocketAddr::new(ip, reserved_port.port());
std_tcp_listener.set_nonblocking(true)?;
let tokio_tcp_listener = TokioTcpListener::from_std(std_tcp_listener)?;
Ok(Self {
maybe_reserved_port: Some(reserved_port),
socket_addr,
tcp_listener: tokio_tcp_listener,
})
}
}
#[cfg(test)]
mod test_new {
use super::*;
use regex::Regex;
use std::net::Ipv4Addr;
#[tokio::test]
async fn it_should_create_default_ip_with_random_port_when_none() {
let ip = None;
let port = None;
let setup = StartingTcpSetup::new(ip, port).unwrap();
let addr = format!("{}", setup.socket_addr);
let regex = Regex::new("^127\\.0\\.0\\.1:[0-9]+$").unwrap();
let is_match = regex.is_match(&addr);
assert!(is_match);
}
#[tokio::test]
async fn it_should_create_ip_with_random_port_when_ip_given() {
let ip = Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
let port = None;
let setup = StartingTcpSetup::new(ip, port).unwrap();
let addr = format!("{}", setup.socket_addr);
let regex = Regex::new("^127\\.0\\.0\\.1:[0-9]+$").unwrap();
let is_match = regex.is_match(&addr);
assert!(is_match);
}
#[tokio::test]
async fn it_should_create_default_ip_with_port_when_port_given() {
let ip = None;
let port = Some(8123);
let setup = StartingTcpSetup::new(ip, port).unwrap();
let addr = format!("{}", setup.socket_addr);
assert_eq!(addr, "127.0.0.1:8123");
}
#[tokio::test]
async fn it_should_create_ip_port_given_when_both_given() {
let ip = Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
let port = Some(8124);
let setup = StartingTcpSetup::new(ip, port).unwrap();
let addr = format!("{}", setup.socket_addr);
assert_eq!(addr, "127.0.0.1:8124");
}
}
@@ -0,0 +1,37 @@
use http::StatusCode;
use std::fmt;
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct StatusCodeFormatter(pub StatusCode);
impl fmt::Display for StatusCodeFormatter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let code = self.0.as_u16();
let reason = self.0.canonical_reason().unwrap_or("unknown status code");
write!(f, "{code} ({reason})")
}
}
#[cfg(test)]
mod test_fmt {
use super::*;
#[test]
fn it_should_format_with_reason_where_available() {
let status_code = StatusCode::UNAUTHORIZED;
let debug = StatusCodeFormatter(status_code);
let output = format!("{}", debug);
assert_eq!(output, "401 (Unauthorized)");
}
#[test]
fn it_should_provide_only_number_where_reason_is_unavailable() {
let status_code = StatusCode::from_u16(218).unwrap(); // Unofficial Apache status code.
let debug = StatusCodeFormatter(status_code);
let output = format!("{}", debug);
assert_eq!(output, "218 (unknown status code)");
}
}
@@ -0,0 +1,68 @@
use anyhow::Result;
use axum::body::Body;
use http::Request;
use http::Response;
use hyper_util::client::legacy::Client;
use reserve_port::ReservedPort;
use std::future::Future;
use std::pin::Pin;
use url::Url;
use crate::transport_layer::TransportLayer;
use crate::transport_layer::TransportLayerType;
use crate::util::ServeHandle;
#[derive(Debug)]
pub struct HttpTransportLayer {
#[allow(dead_code)]
serve_handle: ServeHandle,
#[allow(dead_code)]
maybe_reserved_port: Option<ReservedPort>,
url: Url,
}
impl HttpTransportLayer {
pub(crate) fn new(
serve_handle: ServeHandle,
maybe_reserved_port: Option<ReservedPort>,
url: Url,
) -> Self {
Self {
serve_handle,
maybe_reserved_port,
url,
}
}
}
impl TransportLayer for HttpTransportLayer {
fn send<'a>(
&'a self,
request: Request<Body>,
) -> Pin<Box<dyn 'a + Future<Output = Result<Response<Body>>>>> {
Box::pin(async {
let client = Client::builder(hyper_util::rt::TokioExecutor::new()).build_http();
let hyper_response = client.request(request).await?;
let (parts, response_body) = hyper_response.into_parts();
let returned_response: Response<Body> =
Response::from_parts(parts, Body::new(response_body));
Ok(returned_response)
})
}
fn url(&self) -> Option<&Url> {
Some(&self.url)
}
fn transport_layer_type(&self) -> TransportLayerType {
TransportLayerType::Http
}
fn is_running(&self) -> bool {
!self.serve_handle.is_finished()
}
}
@@ -0,0 +1,74 @@
use anyhow::Error as AnyhowError;
use anyhow::Result;
use axum::body::Body;
use axum::response::Response as AxumResponse;
use bytes::Bytes;
use http::Request;
use http::Response;
use std::fmt::Debug;
use std::future::Future;
use std::pin::Pin;
use tower::util::ServiceExt;
use tower::Service;
use crate::transport_layer::TransportLayer;
use crate::transport_layer::TransportLayerType;
pub struct MockTransportLayer<S> {
service: S,
}
impl<S, RouterService> MockTransportLayer<S>
where
S: Service<Request<Body>, Response = RouterService> + Clone + Send + Sync,
AnyhowError: From<S::Error>,
S::Future: Send,
RouterService: Service<Request<Body>, Response = AxumResponse>,
{
pub(crate) fn new(service: S) -> Self {
Self { service }
}
}
impl<S, RouterService> TransportLayer for MockTransportLayer<S>
where
S: Service<Request<Body>, Response = RouterService> + Clone + Send + Sync + 'static,
AnyhowError: From<S::Error>,
S::Future: Send + Sync,
RouterService: Service<Request<Body>, Response = AxumResponse>,
AnyhowError: From<RouterService::Error>,
{
fn send<'a>(
&'a self,
request: Request<Body>,
) -> Pin<Box<dyn 'a + Future<Output = Result<Response<Body>>>>> {
Box::pin(async {
let body: Body = Bytes::new().into();
let empty_request = Request::builder()
.body(body)
.expect("should build empty request");
let service = self.service.clone();
let router = service.oneshot(empty_request).await?;
let response = router.oneshot(request).await?;
Ok(response)
})
}
fn transport_layer_type(&self) -> TransportLayerType {
TransportLayerType::Mock
}
/// This will always return true.
#[inline(always)]
fn is_running(&self) -> bool {
true
}
}
impl<S> Debug for MockTransportLayer<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "MockTransportLayer {{ service: {{unknown}} }}")
}
}
@@ -0,0 +1,5 @@
mod http_transport_layer;
pub use self::http_transport_layer::*;
mod mock_transport_layer;
pub use self::mock_transport_layer::*;
@@ -0,0 +1,91 @@
use std::convert::Infallible;
use std::fmt::Debug;
use std::ops::Range;
use std::ops::RangeBounds;
use std::ops::RangeFrom;
use std::ops::RangeFull;
use std::ops::RangeInclusive;
use std::ops::RangeTo;
use std::ops::RangeToInclusive;
pub trait TryIntoRangeBounds<B> {
type TargetRange: RangeBounds<B>;
type Error: Debug;
fn try_into_range_bounds(self) -> Result<Self::TargetRange, Self::Error>;
}
impl<A, B> TryIntoRangeBounds<B> for Range<A>
where
A: TryInto<B>,
A::Error: Debug,
{
type TargetRange = Range<B>;
type Error = <A as TryInto<B>>::Error;
fn try_into_range_bounds(self) -> Result<Self::TargetRange, Self::Error> {
Ok(self.start.try_into()?..self.end.try_into()?)
}
}
impl<A, B> TryIntoRangeBounds<B> for RangeFrom<A>
where
A: TryInto<B>,
A::Error: Debug,
{
type TargetRange = RangeFrom<B>;
type Error = <A as TryInto<B>>::Error;
fn try_into_range_bounds(self) -> Result<Self::TargetRange, Self::Error> {
Ok(self.start.try_into()?..)
}
}
impl<A, B> TryIntoRangeBounds<B> for RangeTo<A>
where
A: TryInto<B>,
A::Error: Debug,
{
type TargetRange = RangeTo<B>;
type Error = <A as TryInto<B>>::Error;
fn try_into_range_bounds(self) -> Result<Self::TargetRange, Self::Error> {
Ok(..self.end.try_into()?)
}
}
impl<A, B> TryIntoRangeBounds<B> for RangeInclusive<A>
where
A: TryInto<B>,
A::Error: Debug,
{
type TargetRange = RangeInclusive<B>;
type Error = <A as TryInto<B>>::Error;
fn try_into_range_bounds(self) -> Result<Self::TargetRange, Self::Error> {
let (start, end) = self.into_inner();
Ok(start.try_into()?..=end.try_into()?)
}
}
impl<A, B> TryIntoRangeBounds<B> for RangeToInclusive<A>
where
A: TryInto<B>,
A::Error: Debug,
{
type TargetRange = RangeToInclusive<B>;
type Error = <A as TryInto<B>>::Error;
fn try_into_range_bounds(self) -> Result<Self::TargetRange, Self::Error> {
Ok(..=self.end.try_into()?)
}
}
impl<B> TryIntoRangeBounds<B> for RangeFull {
type TargetRange = RangeFull;
type Error = Infallible;
fn try_into_range_bounds(self) -> Result<Self::TargetRange, Self::Error> {
Ok(self)
}
}
@@ -0,0 +1,5 @@
mod test_response_websocket;
pub use self::test_response_websocket::*;
mod ws_key_generator;
pub use self::ws_key_generator::*;
@@ -0,0 +1,9 @@
use hyper::upgrade::OnUpgrade;
use crate::transport_layer::TransportLayerType;
#[derive(Debug, Clone)]
pub struct TestResponseWebSocket {
pub maybe_on_upgrade: Option<OnUpgrade>,
pub transport_type: TransportLayerType,
}
@@ -0,0 +1,8 @@
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use uuid::Uuid;
/// Generates a random key for use, that is base 64 encoded for use over HTTP.
pub fn generate_ws_key() -> String {
STANDARD.encode(Uuid::new_v4().as_bytes())
}
@@ -0,0 +1,21 @@
use anyhow::anyhow;
use anyhow::Result;
use std::sync::Arc;
use std::sync::Mutex;
pub fn with_this_mut<T, F, R>(this: &Arc<Mutex<T>>, name: &str, some_action: F) -> Result<R>
where
F: FnOnce(&mut T) -> R,
{
let mut this_locked = this.lock().map_err(|err| {
anyhow!(
"Failed to lock InternalTestServer for `{}`, {:?}",
name,
err,
)
})?;
let result = some_action(&mut this_locked);
Ok(result)
}
+440
View File
@@ -0,0 +1,440 @@
//!
//! Axum Test is a library for writing tests for web servers written using Axum:
//!
//! * You create a [`TestServer`] within a test,
//! * use that to build [`TestRequest`] against your application,
//! * receive back a [`TestResponse`],
//! * then assert the response is how you expect.
//!
//! It includes built in support for serializing and deserializing request and response bodies using Serde,
//! support for cookies and headers, and other common bits you would expect.
//!
//! `TestServer` will pass http requests directly to the handler,
//! or can be run on a random IP / Port address.
//!
//! ## Getting Started
//!
//! Create a [`TestServer`] running your Axum [`Router`](::axum::Router):
//!
//! ```rust
//! # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
//! #
//! use axum::Router;
//! use axum::extract::Json;
//! use axum::routing::put;
//! use axum_test::TestServer;
//! use serde_json::json;
//! use serde_json::Value;
//!
//! async fn route_put_user(Json(user): Json<Value>) -> () {
//! // todo
//! }
//!
//! let my_app = Router::new()
//! .route("/users", put(route_put_user));
//!
//! let server = TestServer::new(my_app)?;
//! #
//! # Ok(())
//! # }
//! ```
//!
//! Then make requests against it:
//!
//! ```rust
//! # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
//! #
//! # use axum::Router;
//! # use axum::extract::Json;
//! # use axum::routing::put;
//! # use axum_test::TestServer;
//! # use serde_json::json;
//! # use serde_json::Value;
//! #
//! # async fn put_user(Json(user): Json<Value>) -> () {}
//! #
//! # let my_app = Router::new()
//! # .route("/users", put(put_user));
//! #
//! # let server = TestServer::new(my_app)?;
//! #
//! let response = server.put("/users")
//! .json(&json!({
//! "username": "Terrance Pencilworth",
//! }))
//! .await;
//! #
//! # Ok(())
//! # }
//! ```
//!
#![allow(clippy::module_inception)]
#![allow(clippy::derivable_impls)]
#![allow(clippy::manual_range_contains)]
#![forbid(unsafe_code)]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
pub(crate) mod internals;
pub mod multipart;
pub mod transport_layer;
pub mod util;
mod test_request;
pub use self::test_request::*;
mod test_response;
pub use self::test_response::*;
mod test_server_builder;
pub use self::test_server_builder::*;
mod test_server_config;
pub use self::test_server_config::*;
mod test_server;
pub use self::test_server::*;
#[cfg(feature = "ws")]
mod test_web_socket;
#[cfg(feature = "ws")]
pub use self::test_web_socket::*;
#[cfg(feature = "ws")]
pub use tokio_tungstenite::tungstenite::Message as WsMessage;
mod transport;
pub use self::transport::*;
pub use http;
#[cfg(test)]
mod integrated_test_cookie_saving {
use super::*;
use axum::extract::Request;
use axum::routing::get;
use axum::routing::post;
use axum::routing::put;
use axum::Router;
use axum_extra::extract::cookie::Cookie as AxumCookie;
use axum_extra::extract::cookie::CookieJar;
use cookie::time::OffsetDateTime;
use cookie::Cookie;
use http_body_util::BodyExt;
use std::time::Duration;
const TEST_COOKIE_NAME: &'static str = &"test-cookie";
async fn get_cookie(cookies: CookieJar) -> (CookieJar, String) {
let cookie = cookies.get(&TEST_COOKIE_NAME);
let cookie_value = cookie
.map(|c| c.value().to_string())
.unwrap_or_else(|| "cookie-not-found".to_string());
(cookies, cookie_value)
}
async fn put_cookie(mut cookies: CookieJar, request: Request) -> (CookieJar, &'static str) {
let body_bytes = request
.into_body()
.collect()
.await
.expect("Should extract the body")
.to_bytes();
let body_text: String = String::from_utf8_lossy(&body_bytes).to_string();
let cookie = AxumCookie::new(TEST_COOKIE_NAME, body_text);
cookies = cookies.add(cookie);
(cookies, &"done")
}
async fn post_expire_cookie(mut cookies: CookieJar) -> (CookieJar, &'static str) {
let mut cookie = AxumCookie::new(TEST_COOKIE_NAME, "expired".to_string());
let expired_time = OffsetDateTime::now_utc() - Duration::from_secs(1);
cookie.set_expires(expired_time);
cookies = cookies.add(cookie);
(cookies, &"done")
}
fn new_test_router() -> Router {
Router::new()
.route("/cookie", put(put_cookie))
.route("/cookie", get(get_cookie))
.route("/expire", post(post_expire_cookie))
}
#[tokio::test]
async fn it_should_not_pass_cookies_created_back_up_to_server_by_default() {
// Run the server.
let server = TestServer::new(new_test_router()).expect("Should create test server");
// Create a cookie.
server.put(&"/cookie").text(&"new-cookie").await;
// Check it comes back.
let response_text = server.get(&"/cookie").await.text();
assert_eq!(response_text, "cookie-not-found");
}
#[tokio::test]
async fn it_should_not_pass_cookies_created_back_up_to_server_when_turned_off() {
// Run the server.
let server = TestServer::builder()
.do_not_save_cookies()
.build(new_test_router())
.expect("Should create test server");
// Create a cookie.
server.put(&"/cookie").text(&"new-cookie").await;
// Check it comes back.
let response_text = server.get(&"/cookie").await.text();
assert_eq!(response_text, "cookie-not-found");
}
#[tokio::test]
async fn it_should_pass_cookies_created_back_up_to_server_automatically() {
// Run the server.
let server = TestServer::builder()
.save_cookies()
.build(new_test_router())
.expect("Should create test server");
// Create a cookie.
server.put(&"/cookie").text(&"cookie-found!").await;
// Check it comes back.
let response_text = server.get(&"/cookie").await.text();
assert_eq!(response_text, "cookie-found!");
}
#[tokio::test]
async fn it_should_pass_cookies_created_back_up_to_server_when_turned_on_for_request() {
// Run the server.
let server = TestServer::builder()
.do_not_save_cookies() // it's off by default!
.build(new_test_router())
.expect("Should create test server");
// Create a cookie.
server
.put(&"/cookie")
.text(&"cookie-found!")
.save_cookies()
.await;
// Check it comes back.
let response_text = server.get(&"/cookie").await.text();
assert_eq!(response_text, "cookie-found!");
}
#[tokio::test]
async fn it_should_wipe_cookies_cleared_by_request() {
// Run the server.
let server = TestServer::builder()
.do_not_save_cookies() // it's off by default!
.build(new_test_router())
.expect("Should create test server");
// Create a cookie.
server
.put(&"/cookie")
.text(&"cookie-found!")
.save_cookies()
.await;
// Check it comes back.
let response_text = server.get(&"/cookie").clear_cookies().await.text();
assert_eq!(response_text, "cookie-not-found");
}
#[tokio::test]
async fn it_should_wipe_cookies_cleared_by_test_server() {
// Run the server.
let mut server = TestServer::builder()
.do_not_save_cookies() // it's off by default!
.build(new_test_router())
.expect("Should create test server");
// Create a cookie.
server
.put(&"/cookie")
.text(&"cookie-found!")
.save_cookies()
.await;
server.clear_cookies();
// Check it comes back.
let response_text = server.get(&"/cookie").await.text();
assert_eq!(response_text, "cookie-not-found");
}
#[tokio::test]
async fn it_should_send_cookies_added_to_request() {
// Run the server.
let server = TestServer::builder()
.do_not_save_cookies() // it's off by default!
.build(new_test_router())
.expect("Should create test server");
// Check it comes back.
let cookie = Cookie::new(TEST_COOKIE_NAME, "my-custom-cookie");
let response_text = server.get(&"/cookie").add_cookie(cookie).await.text();
assert_eq!(response_text, "my-custom-cookie");
}
#[tokio::test]
async fn it_should_send_cookies_added_to_test_server() {
// Run the server.
let mut server = TestServer::builder()
.do_not_save_cookies() // it's off by default!
.build(new_test_router())
.expect("Should create test server");
// Check it comes back.
let cookie = Cookie::new(TEST_COOKIE_NAME, "my-custom-cookie");
server.add_cookie(cookie);
let response_text = server.get(&"/cookie").await.text();
assert_eq!(response_text, "my-custom-cookie");
}
#[tokio::test]
async fn it_should_remove_expired_cookies_from_later_requests() {
// Run the server.
let mut server = TestServer::new(new_test_router()).expect("Should create test server");
server.save_cookies();
// Create a cookie.
server.put(&"/cookie").text(&"cookie-found!").await;
// Check it comes back.
let response_text = server.get(&"/cookie").await.text();
assert_eq!(response_text, "cookie-found!");
server.post(&"/expire").await;
// Then expire the cookie.
let found_cookie = server.post(&"/expire").await.maybe_cookie(TEST_COOKIE_NAME);
assert!(found_cookie.is_some());
// It's no longer found
let response_text = server.get(&"/cookie").await.text();
assert_eq!(response_text, "cookie-not-found");
}
}
#[cfg(feature = "typed-routing")]
#[cfg(test)]
mod integrated_test_typed_routing_and_query {
use super::*;
use axum::extract::Query;
use axum::Router;
use axum_extra::routing::RouterExt;
use axum_extra::routing::TypedPath;
use serde::Deserialize;
use serde::Serialize;
#[derive(TypedPath, Deserialize)]
#[typed_path("/path-query/{id}")]
struct TestingPathQuery {
id: u32,
}
#[derive(Serialize, Deserialize)]
struct QueryParams {
param: String,
other: Option<String>,
}
async fn route_get_with_param(
TestingPathQuery { id }: TestingPathQuery,
Query(params): Query<QueryParams>,
) -> String {
let query = params.param;
if let Some(other) = params.other {
format!("get {id}, {query}&{other}")
} else {
format!("get {id}, {query}")
}
}
fn new_app() -> Router {
Router::new().typed_get(route_get_with_param)
}
#[tokio::test]
async fn it_should_send_typed_get_with_query_params() {
let server = TestServer::new(new_app()).unwrap();
let path = TestingPathQuery { id: 123 }.with_query_params(QueryParams {
param: "with-typed-query".to_string(),
other: None,
});
server
.typed_get(&path)
.expect_success()
.await
.assert_text("get 123, with-typed-query");
}
#[tokio::test]
async fn it_should_send_typed_get_with_added_query_param() {
let server = TestServer::new(new_app()).unwrap();
let path = TestingPathQuery { id: 123 };
server
.typed_get(&path)
.add_query_param("param", "with-added-query")
.expect_success()
.await
.assert_text("get 123, with-added-query");
}
#[tokio::test]
async fn it_should_send_both_typed_and_added_query() {
let server = TestServer::new(new_app()).unwrap();
let path = TestingPathQuery { id: 123 }.with_query_params(QueryParams {
param: "with-typed-query".to_string(),
other: None,
});
server
.typed_get(&path)
.add_query_param("other", "with-added-query")
.expect_success()
.await
.assert_text("get 123, with-typed-query&with-added-query");
}
#[tokio::test]
async fn it_should_send_replaced_query_when_cleared() {
let server = TestServer::new(new_app()).unwrap();
let path = TestingPathQuery { id: 123 }.with_query_params(QueryParams {
param: "with-typed-query".to_string(),
other: Some("with-typed-other".to_string()),
});
server
.typed_get(&path)
.clear_query_params()
.add_query_param("param", "with-added-query")
.expect_success()
.await
.assert_text("get 123, with-added-query");
}
}
+63
View File
@@ -0,0 +1,63 @@
//!
//! This supplies the building blocks for sending multipart forms using
//! [`TestRequest::multipart()`](crate::TestRequest::multipart()).
//!
//! The request body can be built using [`MultipartForm`] and [`Part`].
//!
//! # Simple example
//!
//! ```rust
//! # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
//! #
//! use axum::Router;
//! use axum_test::TestServer;
//! use axum_test::multipart::MultipartForm;
//!
//! let app = Router::new();
//! let server = TestServer::new(app)?;
//!
//! let multipart_form = MultipartForm::new()
//! .add_text("name", "Joe")
//! .add_text("animals", "foxes");
//!
//! let response = server.post(&"/my-form")
//! .multipart(multipart_form)
//! .await;
//! #
//! # Ok(()) }
//! ```
//!
//! # Sending byte parts
//!
//! ```rust
//! # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
//! #
//! use axum::Router;
//! use axum_test::TestServer;
//! use axum_test::multipart::MultipartForm;
//! use axum_test::multipart::Part;
//!
//! let app = Router::new();
//! let server = TestServer::new(app)?;
//!
//! let image_bytes = include_bytes!("../../README.md");
//! let image_part = Part::bytes(image_bytes.as_slice())
//! .file_name(&"README.md")
//! .mime_type(&"text/markdown");
//!
//! let multipart_form = MultipartForm::new()
//! .add_part("file", image_part);
//!
//! let response = server.post(&"/my-form")
//! .multipart(multipart_form)
//! .await;
//! #
//! # Ok(()) }
//! ```
//!
mod multipart_form;
pub use self::multipart_form::*;
mod part;
pub use self::part::*;
@@ -0,0 +1,66 @@
use crate::multipart::Part;
use axum::body::Body as AxumBody;
use rust_multipart_rfc7578_2::client::multipart::Body as CommonMultipartBody;
use rust_multipart_rfc7578_2::client::multipart::Form;
use std::fmt::Display;
use std::io::Cursor;
#[derive(Debug)]
pub struct MultipartForm {
inner: Form<'static>,
}
impl MultipartForm {
pub fn new() -> Self {
Default::default()
}
/// Creates a text part, and adds it to be sent.
pub fn add_text<N, T>(mut self, name: N, text: T) -> Self
where
N: Display,
T: ToString,
{
self.inner.add_text(name, text.to_string());
self
}
/// Adds a new section to this multipart form to be sent.
///
/// See [`Part`](crate::multipart::Part).
pub fn add_part<N>(mut self, name: N, part: Part) -> Self
where
N: Display,
{
let reader = Cursor::new(part.bytes);
self.inner.add_reader_2(
name,
reader,
part.file_name,
Some(part.mime_type),
part.headers,
);
self
}
/// Returns the content type this form will use when it is sent.
pub fn content_type(&self) -> String {
self.inner.content_type()
}
}
impl Default for MultipartForm {
fn default() -> Self {
Self {
inner: Default::default(),
}
}
}
impl From<MultipartForm> for AxumBody {
fn from(multipart: MultipartForm) -> Self {
let inner_body: CommonMultipartBody = multipart.inner.into();
AxumBody::from_stream(inner_body)
}
}
+210
View File
@@ -0,0 +1,210 @@
use anyhow::Context;
use bytes::Bytes;
use http::HeaderName;
use http::HeaderValue;
use mime::Mime;
use std::fmt::Debug;
use std::fmt::Display;
///
/// For creating a section of a MultipartForm.
///
/// Use [`Part::text()`](crate::multipart::Part::text()) and [`Part::bytes()`](crate::multipart::Part::bytes()) for creating new instances.
/// Then attach them to a `MultipartForm` using [`MultipartForm::add_part()`](crate::multipart::MultipartForm::add_part()).
///
#[derive(Debug, Clone)]
pub struct Part {
pub(crate) bytes: Bytes,
pub(crate) file_name: Option<String>,
pub(crate) mime_type: Mime,
pub(crate) headers: Vec<(HeaderName, HeaderValue)>,
}
impl Part {
/// Creates a new part of a multipart form, that will send text.
///
/// The default mime type for this part will be `text/plain`,
pub fn text<T>(text: T) -> Self
where
T: Display,
{
let bytes = text.to_string().into_bytes().into();
Self::new(bytes, mime::TEXT_PLAIN)
}
/// Creates a new part of a multipart form, that will upload bytes.
///
/// The default mime type for this part will be `application/octet-stream`,
pub fn bytes<B>(bytes: B) -> Self
where
B: Into<Bytes>,
{
Self::new(bytes.into(), mime::APPLICATION_OCTET_STREAM)
}
fn new(bytes: Bytes, mime_type: Mime) -> Self {
Self {
bytes,
file_name: None,
mime_type,
headers: Default::default(),
}
}
/// Sets the file name for this part of a multipart form.
///
/// By default there is no filename. This will set one.
pub fn file_name<T>(mut self, file_name: T) -> Self
where
T: Display,
{
self.file_name = Some(file_name.to_string());
self
}
/// Sets the mime type for this part of a multipart form.
///
/// The default mime type is `text/plain` or `application/octet-stream`,
/// depending on how this instance was created.
/// This function will replace that.
pub fn mime_type<M>(mut self, mime_type: M) -> Self
where
M: AsRef<str>,
{
let raw_mime_type = mime_type.as_ref();
let parsed_mime_type = raw_mime_type
.parse()
.with_context(|| format!("Failed to parse '{raw_mime_type}' as a Mime type"))
.unwrap();
self.mime_type = parsed_mime_type;
self
}
/// Adds a header to be sent with the Part of this Multiform.
///
/// ```rust
/// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
/// #
/// use axum::Router;
/// use axum_test::TestServer;
/// use axum_test::multipart::MultipartForm;
/// use axum_test::multipart::Part;
///
/// let app = Router::new();
/// let server = TestServer::new(app)?;
///
/// let readme_bytes = include_bytes!("../../README.md");
/// let readme_part = Part::bytes(readme_bytes.as_slice())
/// .file_name(&"README.md")
/// // Add a header to the Part
/// .add_header("x-text-category", "readme");
///
/// let multipart_form = MultipartForm::new()
/// .add_part("file", readme_part);
///
/// let response = server.post(&"/my-form")
/// .multipart(multipart_form)
/// .await;
/// #
/// # Ok(()) }
/// ```
///
pub fn add_header<N, V>(mut self, name: N, value: V) -> Self
where
N: TryInto<HeaderName>,
N::Error: Debug,
V: TryInto<HeaderValue>,
V::Error: Debug,
{
let header_name: HeaderName = name
.try_into()
.expect("Failed to convert header name to HeaderName");
let header_value: HeaderValue = value
.try_into()
.expect("Failed to convert header vlue to HeaderValue");
self.headers.push((header_name, header_value));
self
}
}
#[cfg(test)]
mod test_text {
use super::*;
#[test]
fn it_should_contain_text_given() {
let part = Part::text("some_text");
let output = String::from_utf8_lossy(&part.bytes);
assert_eq!(output, "some_text");
}
#[test]
fn it_should_use_mime_type_text() {
let part = Part::text("some_text");
assert_eq!(part.mime_type, mime::TEXT_PLAIN);
}
}
#[cfg(test)]
mod test_byes {
use super::*;
#[test]
fn it_should_contain_bytes_given() {
let bytes = "some_text".as_bytes();
let part = Part::bytes(bytes);
let output = String::from_utf8_lossy(&part.bytes);
assert_eq!(output, "some_text");
}
#[test]
fn it_should_use_mime_type_octet_stream() {
let bytes = "some_text".as_bytes();
let part = Part::bytes(bytes);
assert_eq!(part.mime_type, mime::APPLICATION_OCTET_STREAM);
}
}
#[cfg(test)]
mod test_file_name {
use super::*;
#[test]
fn it_should_use_file_name_given() {
let mut part = Part::text("some_text");
assert_eq!(part.file_name, None);
part = part.file_name("my-text.txt");
assert_eq!(part.file_name, Some("my-text.txt".to_string()));
}
}
#[cfg(test)]
mod test_mime_type {
use super::*;
#[test]
fn it_should_use_mime_type_set() {
let mut part = Part::text("some_text");
assert_eq!(part.mime_type, mime::TEXT_PLAIN);
part = part.mime_type("application/json");
assert_eq!(part.mime_type, mime::APPLICATION_JSON);
}
#[test]
#[should_panic]
fn it_should_error_if_invalid_mime_type() {
let part = Part::text("some_text");
part.mime_type("🦊");
assert!(false);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
use cookie::CookieJar;
use http::HeaderName;
use http::HeaderValue;
use http::Method;
use url::Url;
use crate::internals::ExpectedState;
use crate::internals::QueryParamsStore;
#[derive(Debug, Clone)]
pub struct TestRequestConfig {
pub is_saving_cookies: bool,
pub expected_state: ExpectedState,
pub content_type: Option<String>,
pub full_request_url: Url,
pub method: Method,
pub cookies: CookieJar,
pub query_params: QueryParamsStore,
pub headers: Vec<(HeaderName, HeaderValue)>,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
use anyhow::Context;
use anyhow::Result;
use cookie::Cookie;
use cookie::CookieJar;
use http::HeaderName;
use http::HeaderValue;
use serde::Serialize;
use std::sync::Arc;
use std::sync::Mutex;
use crate::internals::with_this_mut;
use crate::internals::QueryParamsStore;
#[derive(Debug)]
pub(crate) struct ServerSharedState {
scheme: Option<String>,
cookies: CookieJar,
query_params: QueryParamsStore,
headers: Vec<(HeaderName, HeaderValue)>,
}
impl ServerSharedState {
pub(crate) fn new() -> Self {
Self {
scheme: None,
cookies: CookieJar::new(),
query_params: QueryParamsStore::new(),
headers: Vec::new(),
}
}
pub(crate) fn scheme(&self) -> Option<&str> {
self.scheme.as_deref()
}
pub(crate) fn cookies(&self) -> &CookieJar {
&self.cookies
}
pub(crate) fn query_params(&self) -> &QueryParamsStore {
&self.query_params
}
pub(crate) fn headers(&self) -> &Vec<(HeaderName, HeaderValue)> {
&self.headers
}
/// Adds the given cookies.
///
/// They will be stored over the top of the existing cookies.
pub(crate) fn add_cookies_by_header<'a, I>(
this: &Arc<Mutex<Self>>,
cookie_headers: I,
) -> Result<()>
where
I: Iterator<Item = &'a HeaderValue>,
{
with_this_mut(this, "add_cookies_by_header", |this| {
for cookie_header in cookie_headers {
let cookie_header_str = cookie_header
.to_str()
.context("Reading cookie header for storing in the `TestServer`")
.unwrap();
let cookie: Cookie<'static> = Cookie::parse(cookie_header_str)?.into_owned();
this.cookies.add(cookie);
}
Ok(()) as Result<()>
})?
}
/// Adds the given cookies.
///
/// They will be stored over the top of the existing cookies.
pub(crate) fn clear_cookies(this: &Arc<Mutex<Self>>) -> Result<()> {
with_this_mut(this, "clear_cookies", |this| {
this.cookies = CookieJar::new();
})
}
/// Adds the given cookies.
///
/// They will be stored over the top of the existing cookies.
pub(crate) fn add_cookies(this: &Arc<Mutex<Self>>, cookies: CookieJar) -> Result<()> {
with_this_mut(this, "add_cookies", |this| {
for cookie in cookies.iter() {
this.cookies.add(cookie.to_owned());
}
})
}
pub(crate) fn add_cookie(this: &Arc<Mutex<Self>>, cookie: Cookie) -> Result<()> {
with_this_mut(this, "add_cookie", |this| {
this.cookies.add(cookie.into_owned());
})
}
pub(crate) fn add_query_params<V>(this: &Arc<Mutex<Self>>, query_params: V) -> Result<()>
where
V: Serialize,
{
with_this_mut(this, "add_query_params", |this| {
this.query_params.add(query_params)
})?
}
pub(crate) fn add_query_param<V>(this: &Arc<Mutex<Self>>, key: &str, value: V) -> Result<()>
where
V: Serialize,
{
with_this_mut(this, "add_query_param", |this| {
this.query_params.add(&[(key, value)])
})?
}
pub(crate) fn add_raw_query_param(this: &Arc<Mutex<Self>>, raw_value: &str) -> Result<()> {
with_this_mut(this, "add_raw_query_param", |this| {
this.query_params.add_raw(raw_value.to_string())
})
}
pub(crate) fn clear_query_params(this: &Arc<Mutex<Self>>) -> Result<()> {
with_this_mut(this, "clear_query_params", |this| this.query_params.clear())
}
pub(crate) fn clear_headers(this: &Arc<Mutex<Self>>) -> Result<()> {
with_this_mut(this, "clear_headers", |this| this.headers.clear())
}
pub(crate) fn add_header(
this: &Arc<Mutex<Self>>,
name: HeaderName,
value: HeaderValue,
) -> Result<()> {
with_this_mut(this, "add_header", |this| this.headers.push((name, value)))
}
pub(crate) fn set_scheme(this: &Arc<Mutex<Self>>, scheme: String) -> Result<()> {
with_this_mut(this, "set_scheme", |this| this.scheme = Some(scheme))
}
pub(crate) fn set_scheme_unlocked(&mut self, scheme: String) {
self.scheme = Some(scheme);
}
}
@@ -0,0 +1,270 @@
use anyhow::Result;
use std::net::IpAddr;
use crate::transport_layer::IntoTransportLayer;
use crate::TestServer;
use crate::TestServerConfig;
use crate::Transport;
/// A builder for [`crate::TestServer`]. Inside is a [`crate::TestServerConfig`],
/// configured by each method, and then turn into a server by [`crate::TestServerBuilder::build`].
///
/// The recommended way to make instances is to call [`crate::TestServer::builder`].
///
/// # Creating a [`crate::TestServer`]
///
/// ```rust
/// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
/// #
/// use axum::Router;
/// use axum_test::TestServerBuilder;
///
/// let my_app = Router::new();
/// let server = TestServerBuilder::new()
/// .save_cookies()
/// .default_content_type(&"application/json")
/// .build(my_app)?;
/// #
/// # Ok(())
/// # }
/// ```
///
/// # Creating a [`crate::TestServerConfig`]
///
/// ```rust
/// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
/// #
/// use axum::Router;
/// use axum_test::TestServer;
/// use axum_test::TestServerBuilder;
///
/// let my_app = Router::new();
/// let config = TestServerBuilder::new()
/// .save_cookies()
/// .default_content_type(&"application/json")
/// .into_config();
///
/// // Build the Test Server
/// let server = TestServer::new_with_config(my_app, config)?;
/// #
/// # Ok(())
/// # }
/// ```
///
/// These can be passed to [`crate::TestServer::new_with_config`].
///
#[derive(Debug, Clone)]
pub struct TestServerBuilder {
config: TestServerConfig,
}
impl TestServerBuilder {
/// Creates a default `TestServerBuilder`.
pub fn new() -> Self {
Default::default()
}
pub fn from_config(config: TestServerConfig) -> Self {
Self { config }
}
pub fn http_transport(self) -> Self {
self.transport(Transport::HttpRandomPort)
}
pub fn http_transport_with_ip_port(self, ip: Option<IpAddr>, port: Option<u16>) -> Self {
self.transport(Transport::HttpIpPort { ip, port })
}
pub fn mock_transport(self) -> Self {
self.transport(Transport::MockHttp)
}
pub fn transport(mut self, transport: Transport) -> Self {
self.config.transport = Some(transport);
self
}
pub fn save_cookies(mut self) -> Self {
self.config.save_cookies = true;
self
}
pub fn do_not_save_cookies(mut self) -> Self {
self.config.save_cookies = false;
self
}
pub fn default_content_type(mut self, content_type: &str) -> Self {
self.config.default_content_type = Some(content_type.to_string());
self
}
pub fn default_scheme(mut self, scheme: &str) -> Self {
self.config.default_scheme = Some(scheme.to_string());
self
}
pub fn expect_success_by_default(mut self) -> Self {
self.config.expect_success_by_default = true;
self
}
pub fn restrict_requests_with_http_schema(mut self) -> Self {
self.config.restrict_requests_with_http_schema = true;
self
}
/// For turning this into a [`crate::TestServerConfig`] object,
/// with can be passed to [`crate::TestServer::new_with_config`].
///
/// ```rust
/// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
/// #
/// use axum::Router;
/// use axum_test::TestServer;
///
/// let my_app = Router::new();
/// let config = TestServer::builder()
/// .save_cookies()
/// .default_content_type(&"application/json")
/// .into_config();
///
/// // Build the Test Server
/// let server = TestServer::new_with_config(my_app, config)?;
/// #
/// # Ok(())
/// # }
/// ```
pub fn into_config(self) -> TestServerConfig {
self.config
}
/// Creates a new [`crate::TestServer`], running the application given,
/// and with all settings from this `TestServerBuilder` applied.
///
/// ```rust
/// use axum::Router;
/// use axum_test::TestServer;
///
/// let app = Router::new();
/// let server = TestServer::builder()
/// .save_cookies()
/// .default_content_type(&"application/json")
/// .build(app);
/// ```
///
/// This is the equivalent to building [`crate::TestServerConfig`] yourself,
/// and calling [`crate::TestServer::new_with_config`].
pub fn build<A>(self, app: A) -> Result<TestServer>
where
A: IntoTransportLayer,
{
self.into_config().build(app)
}
}
impl Default for TestServerBuilder {
fn default() -> Self {
Self {
config: TestServerConfig::default(),
}
}
}
impl From<TestServerConfig> for TestServerBuilder {
fn from(config: TestServerConfig) -> Self {
TestServerBuilder::from_config(config)
}
}
#[cfg(test)]
mod test_build {
use super::*;
use std::net::Ipv4Addr;
#[test]
fn it_should_build_default_config_by_default() {
let config = TestServer::builder().into_config();
let expected = TestServerConfig::default();
assert_eq!(config, expected);
}
#[test]
fn it_should_save_cookies_when_set() {
let config = TestServer::builder().save_cookies().into_config();
assert_eq!(config.save_cookies, true);
}
#[test]
fn it_should_not_save_cookies_when_set() {
let config = TestServer::builder().do_not_save_cookies().into_config();
assert_eq!(config.save_cookies, false);
}
#[test]
fn it_should_mock_transport_when_set() {
let config = TestServer::builder().mock_transport().into_config();
assert_eq!(config.transport, Some(Transport::MockHttp));
}
#[test]
fn it_should_use_random_http_transport_when_set() {
let config = TestServer::builder().http_transport().into_config();
assert_eq!(config.transport, Some(Transport::HttpRandomPort));
}
#[test]
fn it_should_use_http_transport_with_ip_port_when_set() {
let config = TestServer::builder()
.http_transport_with_ip_port(Some(IpAddr::V4(Ipv4Addr::new(123, 4, 5, 6))), Some(987))
.into_config();
assert_eq!(
config.transport,
Some(Transport::HttpIpPort {
ip: Some(IpAddr::V4(Ipv4Addr::new(123, 4, 5, 6))),
port: Some(987),
})
);
}
#[test]
fn it_should_set_default_content_type_when_set() {
let config = TestServer::builder()
.default_content_type("text/csv")
.into_config();
assert_eq!(config.default_content_type, Some("text/csv".to_string()));
}
#[test]
fn it_should_set_default_scheme_when_set() {
let config = TestServer::builder().default_scheme("ftps").into_config();
assert_eq!(config.default_scheme, Some("ftps".to_string()));
}
#[test]
fn it_should_set_expect_success_by_default_when_set() {
let config = TestServer::builder()
.expect_success_by_default()
.into_config();
assert_eq!(config.expect_success_by_default, true);
}
#[test]
fn it_should_set_restrict_requests_with_http_schema_when_set() {
let config = TestServer::builder()
.restrict_requests_with_http_schema()
.into_config();
assert_eq!(config.restrict_requests_with_http_schema, true);
}
}
+181
View File
@@ -0,0 +1,181 @@
use anyhow::Result;
use crate::transport_layer::IntoTransportLayer;
use crate::TestServer;
use crate::TestServerBuilder;
use crate::Transport;
/// This is for customising the [`TestServer`](crate::TestServer) on construction.
/// It implements [`Default`] to ease building.
///
/// ```rust
/// use axum_test::TestServerConfig;
///
/// let config = TestServerConfig {
/// save_cookies: true,
/// ..TestServerConfig::default()
/// };
/// ```
///
/// These can be passed to `TestServer::new_with_config`:
///
/// ```rust
/// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
/// #
/// use axum::Router;
/// use axum_test::TestServer;
/// use axum_test::TestServerConfig;
///
/// let my_app = Router::new();
///
/// let config = TestServerConfig {
/// save_cookies: true,
/// ..TestServerConfig::default()
/// };
///
/// // Build the Test Server
/// let server = TestServer::new_with_config(my_app, config)?;
/// #
/// # Ok(())
/// # }
/// ```
///
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct TestServerConfig {
/// Which transport mode to use to process requests.
/// For setting if the server should use mocked http (which uses [`tower::util::Oneshot`](tower::util::Oneshot)),
/// or if it should run on a named or random IP address.
///
/// The default is to use mocking, apart from services built using [`axum::extract::connect_info::IntoMakeServiceWithConnectInfo`](axum::extract::connect_info::IntoMakeServiceWithConnectInfo)
/// (this is because it needs a real TCP stream).
pub transport: Option<Transport>,
/// Set for the server to save cookies that are returned,
/// for use in future requests.
///
/// This is useful for automatically saving session cookies (and similar)
/// like a browser would do.
///
/// **Defaults** to false (being turned off).
pub save_cookies: bool,
/// Asserts that requests made to the test server,
/// will by default,
/// return a status code in the 2xx range.
///
/// This can be overridden on a per request basis using
/// [`TestRequest::expect_failure()`](crate::TestRequest::expect_failure()).
///
/// This is useful when making multiple requests at a start of test
/// which you presume should always work.
///
/// **Defaults** to false (being turned off).
pub expect_success_by_default: bool,
/// If you make a request with a 'http://' schema,
/// then it will ignore the Test Server's address.
///
/// For example if the test server is running at `http://localhost:1234`,
/// and you make a request to `http://google.com`.
/// Then the request will go to `http://google.com`.
/// Ignoring the `localhost:1234` part.
///
/// Turning this setting on will change this behaviour.
///
/// After turning this on, the same request will go to
/// `http://localhost:1234/http://google.com`.
///
/// **Defaults** to false (being turned off).
pub restrict_requests_with_http_schema: bool,
/// Set the default content type for all requests created by the `TestServer`.
///
/// This overrides the default 'best efforts' approach of requests.
pub default_content_type: Option<String>,
/// Set the default scheme to use for all requests created by the `TestServer`.
///
/// This overrides the default 'http'.
pub default_scheme: Option<String>,
}
impl TestServerConfig {
/// Creates a default `TestServerConfig`.
pub fn new() -> Self {
Default::default()
}
/// This is shorthand for calling [`crate::TestServer::new_with_config`],
/// and passing this config.
///
/// ```rust
/// # async fn test() -> Result<(), Box<dyn ::std::error::Error>> {
/// #
/// use axum::Router;
/// use axum_test::TestServer;
/// use axum_test::TestServerConfig;
///
/// let app = Router::new();
/// let config = TestServerConfig {
/// save_cookies: true,
/// default_content_type: Some("application/json".to_string()),
/// ..Default::default()
/// };
/// let server = TestServer::new_with_config(app, config)?;
/// #
/// # Ok(())
/// # }
/// ```
pub fn build<A>(self, app: A) -> Result<TestServer>
where
A: IntoTransportLayer,
{
TestServer::new_with_config(app, self)
}
}
impl Default for TestServerConfig {
fn default() -> Self {
Self {
transport: None,
save_cookies: false,
expect_success_by_default: false,
restrict_requests_with_http_schema: false,
default_content_type: None,
default_scheme: None,
}
}
}
impl From<TestServerBuilder> for TestServerConfig {
fn from(builder: TestServerBuilder) -> Self {
builder.into_config()
}
}
#[cfg(test)]
mod test_scheme {
use axum::extract::Request;
use axum::routing::get;
use axum::Router;
use crate::TestServer;
use crate::TestServerConfig;
async fn route_get_scheme(request: Request) -> String {
request.uri().scheme_str().unwrap().to_string()
}
#[tokio::test]
async fn it_should_set_scheme_when_present_in_config() {
let router = Router::new().route("/scheme", get(route_get_scheme));
let config = TestServerConfig {
default_scheme: Some("https".to_string()),
..Default::default()
};
let server = TestServer::new_with_config(router, config).unwrap();
server.get("/scheme").await.assert_text("https");
}
}
+664
View File
@@ -0,0 +1,664 @@
use crate::WsMessage;
use anyhow::anyhow;
use anyhow::Context;
use anyhow::Result;
use bytes::Bytes;
use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use hyper::upgrade::Upgraded;
use hyper_util::rt::TokioIo;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::fmt::Debug;
use std::fmt::Display;
use tokio_tungstenite::tungstenite::protocol::Role;
use tokio_tungstenite::WebSocketStream;
#[cfg(feature = "pretty-assertions")]
use pretty_assertions::assert_eq;
#[derive(Debug)]
pub struct TestWebSocket {
stream: WebSocketStream<TokioIo<Upgraded>>,
}
impl TestWebSocket {
pub(crate) async fn new(upgraded: Upgraded) -> Self {
let upgraded_io = TokioIo::new(upgraded);
let stream = WebSocketStream::from_raw_socket(upgraded_io, Role::Client, None).await;
Self { stream }
}
pub async fn close(mut self) {
self.stream
.close(None)
.await
.expect("Failed to close WebSocket stream");
}
pub async fn send_text<T>(&mut self, raw_text: T)
where
T: Display,
{
let text = format!("{}", raw_text);
self.send_message(WsMessage::Text(text.into())).await;
}
pub async fn send_json<J>(&mut self, body: &J)
where
J: ?Sized + Serialize,
{
let raw_json =
::serde_json::to_string(body).expect("It should serialize the content into Json");
self.send_message(WsMessage::Text(raw_json.into())).await;
}
#[cfg(feature = "yaml")]
pub async fn send_yaml<Y>(&mut self, body: &Y)
where
Y: ?Sized + Serialize,
{
let raw_yaml =
::serde_yaml::to_string(body).expect("It should serialize the content into Yaml");
self.send_message(WsMessage::Text(raw_yaml.into())).await;
}
#[cfg(feature = "msgpack")]
pub async fn send_msgpack<M>(&mut self, body: &M)
where
M: ?Sized + Serialize,
{
let body_bytes =
::rmp_serde::to_vec(body).expect("It should serialize the content into MsgPack");
self.send_message(WsMessage::Binary(body_bytes.into()))
.await;
}
pub async fn send_message(&mut self, message: WsMessage) {
self.stream.send(message).await.unwrap();
}
#[must_use]
pub async fn receive_text(&mut self) -> String {
let message = self.receive_message().await;
message_to_text(message)
.context("Failed to read message as a String")
.unwrap()
}
#[must_use]
pub async fn receive_json<T>(&mut self) -> T
where
T: DeserializeOwned,
{
let bytes = self.receive_bytes().await;
serde_json::from_slice::<T>(&bytes)
.context("Failed to deserialize message as Json")
.unwrap()
}
#[cfg(feature = "yaml")]
#[must_use]
pub async fn receive_yaml<T>(&mut self) -> T
where
T: DeserializeOwned,
{
let bytes = self.receive_bytes().await;
serde_yaml::from_slice::<T>(&bytes)
.context("Failed to deserialize message as Yaml")
.unwrap()
}
#[cfg(feature = "msgpack")]
#[must_use]
pub async fn receive_msgpack<T>(&mut self) -> T
where
T: DeserializeOwned,
{
let received_bytes = self.receive_bytes().await;
rmp_serde::from_slice::<T>(&received_bytes)
.context("Failed to deserializing message as MsgPack")
.unwrap()
}
#[must_use]
pub async fn receive_bytes(&mut self) -> Bytes {
let message = self.receive_message().await;
message_to_bytes(message)
.context("Failed to read message as a Bytes")
.unwrap()
}
#[must_use]
pub async fn receive_message(&mut self) -> WsMessage {
self.maybe_receive_message()
.await
.expect("No message found on WebSocket stream")
}
pub async fn assert_receive_json<T>(&mut self, expected: &T)
where
T: DeserializeOwned + PartialEq<T> + Debug,
{
assert_eq!(*expected, self.receive_json::<T>().await);
}
pub async fn assert_receive_text<C>(&mut self, expected: C)
where
C: AsRef<str>,
{
let expected_contents = expected.as_ref();
assert_eq!(expected_contents, &self.receive_text().await);
}
pub async fn assert_receive_text_contains<C>(&mut self, expected: C)
where
C: AsRef<str>,
{
let expected_contents = expected.as_ref();
let received = self.receive_text().await;
let is_contained = received.contains(expected_contents);
assert!(
is_contained,
"Failed to find '{expected_contents}', received '{received}'"
);
}
#[cfg(feature = "yaml")]
pub async fn assert_receive_yaml<T>(&mut self, expected: &T)
where
T: DeserializeOwned + PartialEq<T> + Debug,
{
assert_eq!(*expected, self.receive_yaml::<T>().await);
}
#[cfg(feature = "msgpack")]
pub async fn assert_receive_msgpack<T>(&mut self, expected: &T)
where
T: DeserializeOwned + PartialEq<T> + Debug,
{
assert_eq!(*expected, self.receive_msgpack::<T>().await);
}
#[must_use]
async fn maybe_receive_message(&mut self) -> Option<WsMessage> {
let maybe_message = self.stream.next().await;
match maybe_message {
None => None,
Some(message_result) => {
let message =
message_result.expect("Failed to receive message from WebSocket stream");
Some(message)
}
}
}
}
fn message_to_text(message: WsMessage) -> Result<String> {
let text = match message {
WsMessage::Text(text) => text.to_string(),
WsMessage::Binary(data) => {
String::from_utf8(data.to_vec()).map_err(|err| err.utf8_error())?
}
WsMessage::Ping(data) => {
String::from_utf8(data.to_vec()).map_err(|err| err.utf8_error())?
}
WsMessage::Pong(data) => {
String::from_utf8(data.to_vec()).map_err(|err| err.utf8_error())?
}
WsMessage::Close(None) => String::new(),
WsMessage::Close(Some(frame)) => frame.reason.to_string(),
WsMessage::Frame(_) => {
return Err(anyhow!(
"Unexpected Frame, did not expect Frame message whilst reading"
))
}
};
Ok(text)
}
fn message_to_bytes(message: WsMessage) -> Result<Bytes> {
let bytes = match message {
WsMessage::Text(string) => string.into(),
WsMessage::Binary(data) => data,
WsMessage::Ping(data) => data,
WsMessage::Pong(data) => data,
WsMessage::Close(None) => Bytes::new(),
WsMessage::Close(Some(frame)) => frame.reason.into(),
WsMessage::Frame(_) => {
return Err(anyhow!(
"Unexpected Frame, did not expect Frame message whilst reading"
))
}
};
Ok(bytes)
}
#[cfg(test)]
mod test_assert_receive_text {
use crate::TestServer;
use axum::extract::ws::Message;
use axum::extract::ws::WebSocket;
use axum::extract::WebSocketUpgrade;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
fn new_test_app() -> TestServer {
pub async fn route_get_websocket_ping_pong(ws: WebSocketUpgrade) -> Response {
async fn handle_ping_pong(mut socket: WebSocket) {
while let Some(maybe_message) = socket.recv().await {
let message_text = maybe_message.unwrap().into_text().unwrap();
let encoded_text = format!("Text: {message_text}").try_into().unwrap();
let encoded_data = format!("Binary: {message_text}").into_bytes().into();
socket.send(Message::Text(encoded_text)).await.unwrap();
socket.send(Message::Binary(encoded_data)).await.unwrap();
}
}
ws.on_upgrade(move |socket| handle_ping_pong(socket))
}
let app = Router::new().route(&"/ws-ping-pong", get(route_get_websocket_ping_pong));
TestServer::builder().http_transport().build(app).unwrap()
}
#[tokio::test]
async fn it_should_ping_pong_text_in_text_and_binary() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket.send_text("Hello World!").await;
websocket.assert_receive_text("Text: Hello World!").await;
websocket.assert_receive_text("Binary: Hello World!").await;
}
#[tokio::test]
async fn it_should_ping_pong_large_text_blobs() {
const LARGE_BLOB_SIZE: usize = 16777200; // Max websocket size (16mb) - 16 bytes for the 'Text: ' in the reply.
let large_blob = (0..LARGE_BLOB_SIZE).map(|_| "X").collect::<String>();
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket.send_text(&large_blob).await;
websocket
.assert_receive_text(format!("Text: {large_blob}"))
.await;
websocket
.assert_receive_text(format!("Binary: {large_blob}"))
.await;
}
#[tokio::test]
#[should_panic]
async fn it_should_not_match_partial_text_match() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket.send_text("Hello World!").await;
websocket.assert_receive_text("Hello World!").await;
}
#[tokio::test]
#[should_panic]
async fn it_should_not_match_different_text() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket.send_text("Hello World!").await;
websocket.assert_receive_text("🦊").await;
}
}
#[cfg(test)]
mod test_assert_receive_text_contains {
use crate::TestServer;
use axum::extract::ws::Message;
use axum::extract::ws::WebSocket;
use axum::extract::WebSocketUpgrade;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
fn new_test_app() -> TestServer {
pub async fn route_get_websocket_ping_pong(ws: WebSocketUpgrade) -> Response {
async fn handle_ping_pong(mut socket: WebSocket) {
while let Some(maybe_message) = socket.recv().await {
let message_text = maybe_message.unwrap().into_text().unwrap();
let encoded_text = format!("Text: {message_text}").try_into().unwrap();
socket.send(Message::Text(encoded_text)).await.unwrap();
}
}
ws.on_upgrade(move |socket| handle_ping_pong(socket))
}
let app = Router::new().route(&"/ws-ping-pong", get(route_get_websocket_ping_pong));
TestServer::builder().http_transport().build(app).unwrap()
}
#[tokio::test]
async fn it_should_assert_whole_text_match() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket.send_text("Hello World!").await;
websocket
.assert_receive_text_contains("Text: Hello World!")
.await;
}
#[tokio::test]
async fn it_should_assert_partial_text_match() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket.send_text("Hello World!").await;
websocket.assert_receive_text_contains("Hello World!").await;
}
#[tokio::test]
#[should_panic]
async fn it_should_not_match_different_text() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket.send_text("Hello World!").await;
websocket.assert_receive_text_contains("🦊").await;
}
}
#[cfg(test)]
mod test_assert_receive_json {
use crate::TestServer;
use axum::extract::ws::Message;
use axum::extract::ws::WebSocket;
use axum::extract::WebSocketUpgrade;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use serde_json::Value;
fn new_test_app() -> TestServer {
pub async fn route_get_websocket_ping_pong(ws: WebSocketUpgrade) -> Response {
async fn handle_ping_pong(mut socket: WebSocket) {
while let Some(maybe_message) = socket.recv().await {
let message_text = maybe_message.unwrap().into_text().unwrap();
let decoded = serde_json::from_str::<Value>(&message_text).unwrap();
let encoded_text = serde_json::to_string(&json!({
"format": "text",
"message": decoded
}))
.unwrap()
.try_into()
.unwrap();
let encoded_data = serde_json::to_vec(&json!({
"format": "binary",
"message": decoded
}))
.unwrap()
.into();
socket.send(Message::Text(encoded_text)).await.unwrap();
socket.send(Message::Binary(encoded_data)).await.unwrap();
}
}
ws.on_upgrade(move |socket| handle_ping_pong(socket))
}
let app = Router::new().route(&"/ws-ping-pong", get(route_get_websocket_ping_pong));
TestServer::builder().http_transport().build(app).unwrap()
}
#[tokio::test]
async fn it_should_ping_pong_json_in_text_and_binary() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket
.send_json(&json!({
"hello": "world",
"numbers": [1, 2, 3],
}))
.await;
// Once for text
websocket
.assert_receive_json(&json!({
"format": "text",
"message": {
"hello": "world",
"numbers": [1, 2, 3],
},
}))
.await;
// Again for binary
websocket
.assert_receive_json(&json!({
"format": "binary",
"message": {
"hello": "world",
"numbers": [1, 2, 3],
},
}))
.await;
}
}
#[cfg(feature = "yaml")]
#[cfg(test)]
mod test_assert_receive_yaml {
use crate::TestServer;
use axum::extract::ws::Message;
use axum::extract::ws::WebSocket;
use axum::extract::WebSocketUpgrade;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use serde_json::Value;
fn new_test_app() -> TestServer {
pub async fn route_get_websocket_ping_pong(ws: WebSocketUpgrade) -> Response {
async fn handle_ping_pong(mut socket: WebSocket) {
while let Some(maybe_message) = socket.recv().await {
let message_text = maybe_message.unwrap().into_text().unwrap();
let decoded = serde_yaml::from_str::<Value>(&message_text).unwrap();
let encoded_text = serde_yaml::to_string(&json!({
"format": "text",
"message": decoded
}))
.unwrap()
.try_into()
.unwrap();
let encoded_data = serde_yaml::to_string(&json!({
"format": "binary",
"message": decoded
}))
.unwrap()
.into();
socket.send(Message::Text(encoded_text)).await.unwrap();
socket.send(Message::Binary(encoded_data)).await.unwrap();
}
}
ws.on_upgrade(move |socket| handle_ping_pong(socket))
}
let app = Router::new().route(&"/ws-ping-pong", get(route_get_websocket_ping_pong));
TestServer::builder().http_transport().build(app).unwrap()
}
#[tokio::test]
async fn it_should_ping_pong_yaml_in_text_and_binary() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket
.send_json(&json!({
"hello": "world",
"numbers": [1, 2, 3],
}))
.await;
// Once for text
websocket
.assert_receive_yaml(&json!({
"format": "text",
"message": {
"hello": "world",
"numbers": [1, 2, 3],
},
}))
.await;
// Again for binary
websocket
.assert_receive_yaml(&json!({
"format": "binary",
"message": {
"hello": "world",
"numbers": [1, 2, 3],
},
}))
.await;
}
}
#[cfg(feature = "msgpack")]
#[cfg(test)]
mod test_assert_receive_msgpack {
use crate::TestServer;
use axum::extract::ws::Message;
use axum::extract::ws::WebSocket;
use axum::extract::WebSocketUpgrade;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use serde_json::Value;
fn new_test_app() -> TestServer {
pub async fn route_get_websocket_ping_pong(ws: WebSocketUpgrade) -> Response {
async fn handle_ping_pong(mut socket: WebSocket) {
while let Some(maybe_message) = socket.recv().await {
let message_data = maybe_message.unwrap().into_data();
let decoded = rmp_serde::from_slice::<Value>(&message_data).unwrap();
let encoded_data = ::rmp_serde::to_vec(&json!({
"format": "binary",
"message": decoded
}))
.unwrap()
.into();
socket.send(Message::Binary(encoded_data)).await.unwrap();
}
}
ws.on_upgrade(move |socket| handle_ping_pong(socket))
}
let app = Router::new().route(&"/ws-ping-pong", get(route_get_websocket_ping_pong));
TestServer::builder().http_transport().build(app).unwrap()
}
#[tokio::test]
async fn it_should_ping_pong_msgpack_in_binary() {
let server = new_test_app();
let mut websocket = server
.get_websocket(&"/ws-ping-pong")
.await
.into_websocket()
.await;
websocket
.send_msgpack(&json!({
"hello": "world",
"numbers": [1, 2, 3],
}))
.await;
websocket
.assert_receive_msgpack(&json!({
"format": "binary",
"message": {
"hello": "world",
"numbers": [1, 2, 3],
},
}))
.await;
}
}
+40
View File
@@ -0,0 +1,40 @@
use std::net::IpAddr;
/// Transport is for setting which transport mode for the `TestServer`
/// to use when making requests.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum Transport {
/// With this transport mode, `TestRequest` will use a mock HTTP
/// transport.
///
/// This is the Default Transport type.
MockHttp,
/// With this transport mode, a real web server will be spun up
/// running on a random port. Requests made using the `TestRequest`
/// will be made over the network stack.
HttpRandomPort,
/// With this transport mode, a real web server will be spun up.
/// Where you can pick which IP and Port to use for this to bind to.
///
/// Setting both `ip` and `port` to `None`, is the equivalent of
/// using `Transport::HttpRandomPort`.
HttpIpPort {
/// Set the IP to use for the server.
///
/// **Defaults** to `127.0.0.1`.
ip: Option<IpAddr>,
/// Set the port number to use for the server.
///
/// **Defaults** to a _random_ port.
port: Option<u16>,
},
}
impl Default for Transport {
fn default() -> Self {
Self::MockHttp
}
}
@@ -0,0 +1,43 @@
use anyhow::Result;
use crate::transport_layer::TransportLayer;
use crate::transport_layer::TransportLayerBuilder;
// mod into_make_service_tower;
mod into_make_service;
mod into_make_service_with_connect_info;
mod router;
mod serve;
mod with_graceful_shutdown;
#[cfg(feature = "shuttle")]
mod axum_service;
#[cfg(feature = "shuttle")]
mod shuttle_axum;
///
/// This exists to unify how to send mock or real messages to different services.
/// This includes differences between [`Router`](::axum::Router),
/// [`IntoMakeService`](::axum::routing::IntoMakeService),
/// and [`IntoMakeServiceWithConnectInfo`](::axum::extract::connect_info::IntoMakeServiceWithConnectInfo).
///
/// Implementing this will allow you to use the `TestServer` against other types.
///
/// **Warning**, this trait may change in a future release.
///
pub trait IntoTransportLayer: Sized {
fn into_http_transport_layer(
self,
builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>>;
fn into_mock_transport_layer(self) -> Result<Box<dyn TransportLayer>>;
fn into_default_transport(
self,
_builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
self.into_mock_transport_layer()
}
}
@@ -0,0 +1,86 @@
use anyhow::Result;
use axum::Router;
use shuttle_axum::AxumService;
use crate::transport_layer::IntoTransportLayer;
use crate::transport_layer::TransportLayer;
use crate::transport_layer::TransportLayerBuilder;
impl IntoTransportLayer for AxumService {
fn into_http_transport_layer(
self,
builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
Router::into_http_transport_layer(self.0, builder)
}
fn into_mock_transport_layer(self) -> Result<Box<dyn TransportLayer>> {
Router::into_mock_transport_layer(self.0)
}
}
#[cfg(test)]
mod test_into_http_transport_layer_for_axum_service {
use super::*;
use axum::extract::State;
use axum::routing::get;
use axum::Router;
use crate::TestServer;
async fn get_state(State(count): State<u32>) -> String {
format!("count is {}", count)
}
#[tokio::test]
async fn it_should_run() {
// Build an application with a route.
let app: AxumService = Router::new()
.route("/count", get(get_state))
.with_state(123)
.into();
// Run the server.
let server = TestServer::builder()
.http_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/count").await.assert_text(&"count is 123");
}
}
#[cfg(test)]
mod test_into_mock_transport_layer_for_axum_service {
use super::*;
use axum::extract::State;
use axum::routing::get;
use axum::Router;
use crate::TestServer;
async fn get_state(State(count): State<u32>) -> String {
format!("count is {}", count)
}
#[tokio::test]
async fn it_should_run() {
// Build an application with a route.
let app: AxumService = Router::new()
.route("/count", get(get_state))
.with_state(123)
.into();
// Run the server.
let server = TestServer::builder()
.mock_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/count").await.assert_text(&"count is 123");
}
}
@@ -0,0 +1,195 @@
use anyhow::Result;
use axum::extract::Request as AxumRequest;
use axum::response::Response as AxumResponse;
use axum::routing::IntoMakeService;
use std::convert::Infallible;
use tower::Service;
use url::Url;
use crate::internals::HttpTransportLayer;
use crate::internals::MockTransportLayer;
use crate::transport_layer::IntoTransportLayer;
use crate::transport_layer::TransportLayer;
use crate::transport_layer::TransportLayerBuilder;
use crate::util::spawn_serve;
impl<S> IntoTransportLayer for IntoMakeService<S>
where
S: Service<AxumRequest, Response = AxumResponse, Error = Infallible>
+ Clone
+ Send
+ Sync
+ 'static,
S::Future: Send,
{
fn into_http_transport_layer(
self,
builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
let (socket_addr, tcp_listener, maybe_reserved_port) =
builder.tcp_listener_with_reserved_port()?;
let serve_handle = spawn_serve(tcp_listener, self);
let server_address = format!("http://{socket_addr}");
let server_url: Url = server_address.parse()?;
Ok(Box::new(HttpTransportLayer::new(
serve_handle,
maybe_reserved_port,
server_url,
)))
}
fn into_mock_transport_layer(self) -> Result<Box<dyn TransportLayer>> {
let transport_layer = MockTransportLayer::new(self);
Ok(Box::new(transport_layer))
}
}
#[cfg(test)]
mod test_into_http_transport_layer_for_into_make_service {
use crate::TestServer;
use axum::extract::Request;
use axum::extract::State;
use axum::routing::get;
use axum::Router;
use axum::ServiceExt;
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;
async fn get_ping() -> &'static str {
"pong!"
}
async fn get_state(State(count): State<u32>) -> String {
format!("count is {}", count)
}
#[tokio::test]
async fn it_should_create_and_test_with_make_into_service() {
// Build an application with a route.
let app = Router::new()
.route("/ping", get(get_ping))
.into_make_service();
// Run the server.
let server = TestServer::builder()
.http_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/ping").await.assert_text(&"pong!");
}
#[tokio::test]
async fn it_should_create_and_test_with_make_into_service_with_state() {
// Build an application with a route.
let app = Router::new()
.route("/count", get(get_state))
.with_state(123)
.into_make_service();
// Run the server.
let server = TestServer::builder()
.http_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/count").await.assert_text(&"count is 123");
}
#[tokio::test]
async fn it_should_create_and_run_with_router_wrapped_service() {
// Build an application with a route.
let router = Router::new()
.route("/count", get(get_state))
.with_state(123);
let normalized_router = NormalizePathLayer::trim_trailing_slash().layer(router);
let app = ServiceExt::<Request>::into_make_service(normalized_router);
// Run the server.
let server = TestServer::builder()
.http_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/count").await.assert_text(&"count is 123");
}
}
#[cfg(test)]
mod test_into_mock_transport_layer_for_into_make_service {
use crate::TestServer;
use axum::extract::Request;
use axum::extract::State;
use axum::routing::get;
use axum::Router;
use axum::ServiceExt;
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;
async fn get_ping() -> &'static str {
"pong!"
}
async fn get_state(State(count): State<u32>) -> String {
format!("count is {}", count)
}
#[tokio::test]
async fn it_should_create_and_test_with_make_into_service() {
// Build an application with a route.
let app = Router::new()
.route("/ping", get(get_ping))
.into_make_service();
// Run the server.
let server = TestServer::builder()
.mock_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/ping").await.assert_text(&"pong!");
}
#[tokio::test]
async fn it_should_create_and_test_with_make_into_service_with_state() {
// Build an application with a route.
let app = Router::new()
.route("/count", get(get_state))
.with_state(123)
.into_make_service();
// Run the server.
let server = TestServer::builder()
.mock_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/count").await.assert_text(&"count is 123");
}
#[tokio::test]
async fn it_should_create_and_run_with_router_wrapped_service() {
// Build an application with a route.
let router = Router::new()
.route("/count", get(get_state))
.with_state(123);
let normalized_router = NormalizePathLayer::trim_trailing_slash().layer(router);
let app = ServiceExt::<Request>::into_make_service(normalized_router);
// Run the server.
let server = TestServer::builder()
.mock_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/count").await.assert_text(&"count is 123");
}
}
@@ -0,0 +1,130 @@
use crate::internals::HttpTransportLayer;
use crate::transport_layer::IntoTransportLayer;
use crate::transport_layer::TransportLayer;
use crate::transport_layer::TransportLayerBuilder;
use crate::util::spawn_serve;
use anyhow::anyhow;
use anyhow::Result;
use axum::extract::connect_info::IntoMakeServiceWithConnectInfo;
use axum::extract::Request as AxumRequest;
use axum::response::Response as AxumResponse;
use axum::serve::IncomingStream;
use std::convert::Infallible;
use tokio::net::TcpListener;
use tower::Service;
use url::Url;
impl<S, C> IntoTransportLayer for IntoMakeServiceWithConnectInfo<S, C>
where
for<'a> C: axum::extract::connect_info::Connected<IncomingStream<'a, TcpListener>>,
S: Service<AxumRequest, Response = AxumResponse, Error = Infallible> + Clone + Send + 'static,
S::Future: Send,
{
fn into_http_transport_layer(
self,
builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
let (socket_addr, tcp_listener, maybe_reserved_port) =
builder.tcp_listener_with_reserved_port()?;
let serve_handle = spawn_serve(tcp_listener, self);
let server_address = format!("http://{socket_addr}");
let server_url: Url = server_address.parse()?;
Ok(Box::new(HttpTransportLayer::new(
serve_handle,
maybe_reserved_port,
server_url,
)))
}
fn into_mock_transport_layer(self) -> Result<Box<dyn TransportLayer>> {
Err(anyhow!("`IntoMakeServiceWithConnectInfo` cannot be mocked, as it's underlying implementation requires a real connection. Set the `TestServerConfig` to run with a transport of `HttpRandomPort`, or a `HttpIpPort`."))
}
fn into_default_transport(
self,
builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
self.into_http_transport_layer(builder)
}
}
#[cfg(test)]
mod test_into_http_transport_layer_for_into_make_service_with_connect_info {
use crate::TestServer;
use axum::extract::Request;
use axum::routing::get;
use axum::Router;
use axum::ServiceExt;
use std::net::SocketAddr;
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;
async fn get_ping() -> &'static str {
"pong!"
}
#[tokio::test]
async fn it_should_create_and_test_with_make_into_service_with_connect_info() {
// Build an application with a route.
let app = Router::new()
.route("/ping", get(get_ping))
.into_make_service_with_connect_info::<SocketAddr>();
// Run the server.
let server = TestServer::builder()
.http_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/ping").await.assert_text(&"pong!");
}
#[tokio::test]
async fn it_should_create_and_run_with_router_wrapped_service() {
// Build an application with a route.
let router = Router::new().route("/ping", get(get_ping));
let normalized_router = NormalizePathLayer::trim_trailing_slash().layer(router);
let app = ServiceExt::<Request>::into_make_service_with_connect_info::<SocketAddr>(
normalized_router,
);
// Run the server.
let server = TestServer::builder()
.http_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/ping").await.assert_text(&"pong!");
}
}
#[cfg(test)]
mod test_into_mock_transport_layer_for_into_make_service_with_connect_info {
use crate::TestServer;
use axum::routing::get;
use axum::Router;
use std::net::SocketAddr;
async fn get_ping() -> &'static str {
"pong!"
}
#[tokio::test]
async fn it_should_panic_when_creating_test_using_mock() {
// Build an application with a route.
let app = Router::new()
.route("/ping", get(get_ping))
.into_make_service_with_connect_info::<SocketAddr>();
// Build the server.
let result = TestServer::builder().mock_transport().build(app);
let err = result.unwrap_err();
let err_msg = format!("{}", err);
assert_eq!(err_msg, "`IntoMakeServiceWithConnectInfo` cannot be mocked, as it's underlying implementation requires a real connection. Set the `TestServerConfig` to run with a transport of `HttpRandomPort`, or a `HttpIpPort`.");
}
}
@@ -0,0 +1,117 @@
use anyhow::Result;
use axum::Router;
use crate::transport_layer::IntoTransportLayer;
use crate::transport_layer::TransportLayer;
use crate::transport_layer::TransportLayerBuilder;
impl IntoTransportLayer for Router<()> {
fn into_http_transport_layer(
self,
builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
self.into_make_service().into_http_transport_layer(builder)
}
fn into_mock_transport_layer(self) -> Result<Box<dyn TransportLayer>> {
self.into_make_service().into_mock_transport_layer()
}
}
#[cfg(test)]
mod test_into_http_transport_layer {
use axum::extract::State;
use axum::routing::get;
use axum::Router;
use crate::TestServer;
async fn get_ping() -> &'static str {
"pong!"
}
async fn get_state(State(count): State<u32>) -> String {
format!("count is {}", count)
}
#[tokio::test]
async fn it_should_create_and_test_with_make_into_service() {
// Build an application with a route.
let app: Router = Router::new().route("/ping", get(get_ping));
// Run the server.
let server = TestServer::builder()
.http_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/ping").await.assert_text(&"pong!");
}
#[tokio::test]
async fn it_should_create_and_test_with_make_into_service_with_state() {
// Build an application with a route.
let app: Router = Router::new()
.route("/count", get(get_state))
.with_state(123);
// Run the server.
let server = TestServer::builder()
.http_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/count").await.assert_text(&"count is 123");
}
}
#[cfg(test)]
mod test_into_mock_transport_layer_for_router {
use axum::extract::State;
use axum::routing::get;
use axum::Router;
use crate::TestServer;
async fn get_ping() -> &'static str {
"pong!"
}
async fn get_state(State(count): State<u32>) -> String {
format!("count is {}", count)
}
#[tokio::test]
async fn it_should_create_and_test_with_make_into_service() {
// Build an application with a route.
let app: Router = Router::new().route("/ping", get(get_ping));
// Run the server.
let server = TestServer::builder()
.mock_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/ping").await.assert_text(&"pong!");
}
#[tokio::test]
async fn it_should_create_and_test_with_make_into_service_with_state() {
// Build an application with a route.
let app: Router = Router::new()
.route("/count", get(get_state))
.with_state(123);
// Run the server.
let server = TestServer::builder()
.mock_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/count").await.assert_text(&"count is 123");
}
}
@@ -0,0 +1,154 @@
use crate::internals::HttpTransportLayer;
use crate::transport_layer::IntoTransportLayer;
use crate::transport_layer::TransportLayer;
use crate::transport_layer::TransportLayerBuilder;
use crate::util::ServeHandle;
use anyhow::anyhow;
use anyhow::Context;
use anyhow::Result;
use axum::extract::Request;
use axum::response::Response;
use axum::serve::IncomingStream;
use axum::serve::Serve;
use std::convert::Infallible;
use tokio::net::TcpListener;
use tokio::spawn;
use tower::Service;
use url::Url;
impl<M, S> IntoTransportLayer for Serve<TcpListener, M, S>
where
M: for<'a> Service<IncomingStream<'a, TcpListener>, Error = Infallible, Response = S>
+ Send
+ 'static,
for<'a> <M as Service<IncomingStream<'a, TcpListener>>>::Future: Send,
S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + 'static,
S::Future: Send,
{
fn into_http_transport_layer(
self,
_builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
Err(anyhow!("`Serve` must be started with http or mock transport. Do not set any transport on `TestServerConfig`."))
}
fn into_mock_transport_layer(self) -> Result<Box<dyn TransportLayer>> {
Err(anyhow!("`Serve` cannot be mocked, as it's underlying implementation requires a real connection. Do not set any transport on `TestServerConfig`."))
}
fn into_default_transport(
self,
_builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
let socket_addr = self.local_addr()?;
let join_handle = spawn(async move {
self.await
.context("Failed to create ::axum::Server for TestServer")
.expect("Expect server to start serving");
});
let server_address = format!("http://{socket_addr}");
let server_url: Url = server_address.parse()?;
Ok(Box::new(HttpTransportLayer::new(
ServeHandle::new(join_handle),
None,
server_url,
)))
}
}
#[cfg(test)]
mod test_into_http_transport_layer {
use crate::util::new_random_tokio_tcp_listener;
use crate::TestServer;
use axum::routing::get;
use axum::routing::IntoMakeService;
use axum::serve;
use axum::Router;
async fn get_ping() -> &'static str {
"pong!"
}
#[tokio::test]
#[should_panic]
async fn it_should_panic_when_run_with_http() {
// Build an application with a route.
let app: IntoMakeService<Router> = Router::new()
.route("/ping", get(get_ping))
.into_make_service();
let port = new_random_tokio_tcp_listener().unwrap();
let application = serve(port, app);
// Run the server.
TestServer::builder()
.http_transport()
.build(application)
.expect("Should create test server");
}
}
#[cfg(test)]
mod test_into_mock_transport_layer {
use crate::util::new_random_tokio_tcp_listener;
use crate::TestServer;
use axum::routing::get;
use axum::routing::IntoMakeService;
use axum::serve;
use axum::Router;
async fn get_ping() -> &'static str {
"pong!"
}
#[tokio::test]
#[should_panic]
async fn it_should_panic_when_run_with_mock_http() {
// Build an application with a route.
let app: IntoMakeService<Router> = Router::new()
.route("/ping", get(get_ping))
.into_make_service();
let port = new_random_tokio_tcp_listener().unwrap();
let application = serve(port, app);
// Run the server.
TestServer::builder()
.mock_transport()
.build(application)
.expect("Should create test server");
}
}
#[cfg(test)]
mod test_into_default_transport {
use crate::util::new_random_tokio_tcp_listener;
use crate::TestServer;
use axum::routing::get;
use axum::routing::IntoMakeService;
use axum::serve;
use axum::Router;
async fn get_ping() -> &'static str {
"pong!"
}
#[tokio::test]
async fn it_should_run_service() {
// Build an application with a route.
let app: IntoMakeService<Router> = Router::new()
.route("/ping", get(get_ping))
.into_make_service();
let port = new_random_tokio_tcp_listener().unwrap();
let application = serve(port, app);
// Run the server.
let server = TestServer::builder()
.build(application)
.expect("Should create test server");
// Get the request.
server.get(&"/ping").await.assert_text(&"pong!");
}
}
@@ -0,0 +1,89 @@
use anyhow::Result;
use shuttle_axum::ShuttleAxum;
use crate::transport_layer::IntoTransportLayer;
use crate::transport_layer::TransportLayer;
use crate::transport_layer::TransportLayerBuilder;
impl IntoTransportLayer for ShuttleAxum {
fn into_http_transport_layer(
self,
builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
self.map_err(Into::into)
.and_then(|axum_service| axum_service.into_http_transport_layer(builder))
}
fn into_mock_transport_layer(self) -> Result<Box<dyn TransportLayer>> {
self.map_err(Into::into)
.and_then(|axum_service| axum_service.into_mock_transport_layer())
}
}
#[cfg(test)]
mod test_into_http_transport_layer_for_shuttle_axum {
use super::*;
use axum::extract::State;
use axum::routing::get;
use axum::Router;
use shuttle_axum::AxumService;
use crate::TestServer;
async fn get_state(State(count): State<u32>) -> String {
format!("count is {}", count)
}
#[tokio::test]
async fn it_should_run() {
// Build an application with a route.
let router = Router::new()
.route("/count", get(get_state))
.with_state(123);
let app: ShuttleAxum = Ok(AxumService::from(router));
// Run the server.
let server = TestServer::builder()
.http_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/count").await.assert_text(&"count is 123");
}
}
#[cfg(test)]
mod test_into_mock_transport_layer_for_shuttle_axum {
use super::*;
use axum::extract::State;
use axum::routing::get;
use axum::Router;
use shuttle_axum::AxumService;
use crate::TestServer;
async fn get_state(State(count): State<u32>) -> String {
format!("count is {}", count)
}
#[tokio::test]
async fn it_should_run() {
// Build an application with a route.
let router = Router::new()
.route("/count", get(get_state))
.with_state(123);
let app: ShuttleAxum = Ok(AxumService::from(router));
// Run the server.
let server = TestServer::builder()
.mock_transport()
.build(app)
.expect("Should create test server");
// Get the request.
server.get(&"/count").await.assert_text(&"count is 123");
}
}
@@ -0,0 +1,159 @@
use crate::internals::HttpTransportLayer;
use crate::transport_layer::IntoTransportLayer;
use crate::transport_layer::TransportLayer;
use crate::transport_layer::TransportLayerBuilder;
use crate::util::ServeHandle;
use anyhow::anyhow;
use anyhow::Context;
use anyhow::Result;
use axum::extract::Request;
use axum::response::Response;
use axum::serve::IncomingStream;
use axum::serve::WithGracefulShutdown;
use std::convert::Infallible;
use std::future::Future;
use tokio::net::TcpListener;
use tokio::spawn;
use tower::Service;
use url::Url;
impl<M, S, F> IntoTransportLayer for WithGracefulShutdown<TcpListener, M, S, F>
where
M: for<'a> Service<IncomingStream<'a, TcpListener>, Error = Infallible, Response = S>
+ Send
+ 'static,
for<'a> <M as Service<IncomingStream<'a, TcpListener>>>::Future: Send,
S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + 'static,
S::Future: Send,
F: Future<Output = ()> + Send + 'static,
{
fn into_http_transport_layer(
self,
_builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
Err(anyhow!("`WithGracefulShutdown` must be started with http or mock transport. Do not set any transport on `TestServerConfig`."))
}
fn into_mock_transport_layer(self) -> Result<Box<dyn TransportLayer>> {
Err(anyhow!("`WithGracefulShutdown` cannot be mocked, as it's underlying implementation requires a real connection. Do not set any transport on `TestServerConfig`."))
}
fn into_default_transport(
self,
_builder: TransportLayerBuilder,
) -> Result<Box<dyn TransportLayer>> {
let socket_addr = self.local_addr()?;
let join_handle = spawn(async move {
self.await
.context("Failed to create ::axum::Server for TestServer")
.expect("Expect server to start serving");
});
let server_address = format!("http://{socket_addr}");
let server_url: Url = server_address.parse()?;
Ok(Box::new(HttpTransportLayer::new(
ServeHandle::new(join_handle),
None,
server_url,
)))
}
}
#[cfg(test)]
mod test_into_http_transport_layer {
use crate::util::new_random_tokio_tcp_listener;
use crate::TestServer;
use axum::routing::get;
use axum::routing::IntoMakeService;
use axum::serve;
use axum::Router;
use std::future::pending;
async fn get_ping() -> &'static str {
"pong!"
}
#[tokio::test]
#[should_panic]
async fn it_should_panic_when_run_with_http() {
// Build an application with a route.
let app: IntoMakeService<Router> = Router::new()
.route("/ping", get(get_ping))
.into_make_service();
let port = new_random_tokio_tcp_listener().unwrap();
let application = serve(port, app).with_graceful_shutdown(pending());
// Run the server.
TestServer::builder()
.http_transport()
.build(application)
.expect("Should create test server");
}
}
#[cfg(test)]
mod test_into_mock_transport_layer {
use crate::util::new_random_tokio_tcp_listener;
use crate::TestServer;
use axum::routing::get;
use axum::routing::IntoMakeService;
use axum::serve;
use axum::Router;
use std::future::pending;
async fn get_ping() -> &'static str {
"pong!"
}
#[tokio::test]
#[should_panic]
async fn it_should_panic_when_run_with_mock_http() {
// Build an application with a route.
let app: IntoMakeService<Router> = Router::new()
.route("/ping", get(get_ping))
.into_make_service();
let port = new_random_tokio_tcp_listener().unwrap();
let application = serve(port, app).with_graceful_shutdown(pending());
// Run the server.
TestServer::builder()
.mock_transport()
.build(application)
.expect("Should create test server");
}
}
#[cfg(test)]
mod test_into_default_transport {
use crate::util::new_random_tokio_tcp_listener;
use crate::TestServer;
use axum::routing::get;
use axum::routing::IntoMakeService;
use axum::serve;
use axum::Router;
use std::future::pending;
async fn get_ping() -> &'static str {
"pong!"
}
#[tokio::test]
async fn it_should_run_service() {
// Build an application with a route.
let app: IntoMakeService<Router> = Router::new()
.route("/ping", get(get_ping))
.into_make_service();
let port = new_random_tokio_tcp_listener().unwrap();
let application = serve(port, app).with_graceful_shutdown(pending());
// Run the server.
let server = TestServer::builder()
.build(application)
.expect("Should create test server");
// Get the request.
server.get(&"/ping").await.assert_text(&"pong!");
}
}
@@ -0,0 +1,11 @@
mod into_transport_layer;
pub use self::into_transport_layer::*;
mod transport_layer_builder;
pub use self::transport_layer_builder::*;
mod transport_layer_type;
pub use self::transport_layer_type::*;
mod transport_layer;
pub use self::transport_layer::*;
@@ -0,0 +1,42 @@
use anyhow::Result;
use axum::body::Body;
use http::Request;
use http::Response;
use std::fmt::Debug;
use std::future::Future;
use std::pin::Pin;
use url::Url;
use crate::transport_layer::TransportLayerType;
pub trait TransportLayer: Debug + Send + Sync + 'static {
fn send<'a>(
&'a self,
request: Request<Body>,
) -> Pin<Box<dyn 'a + Future<Output = Result<Response<Body>>>>>;
fn url(&self) -> Option<&Url> {
None
}
fn transport_layer_type(&self) -> TransportLayerType;
fn is_running(&self) -> bool;
}
#[cfg(test)]
mod test_sync {
use super::*;
use tokio::sync::OnceCell;
#[test]
fn it_should_compile_with_tokyo_once_cell() {
// if it compiles, it works!
fn _take_tokio_once_cell<T>(layer: T) -> OnceCell<Box<dyn TransportLayer>>
where
T: TransportLayer,
{
OnceCell::new_with(Some(Box::new(layer)))
}
}
}
@@ -0,0 +1,38 @@
use anyhow::Context;
use anyhow::Result;
use reserve_port::ReservedPort;
use std::net::IpAddr;
use std::net::SocketAddr;
use tokio::net::TcpListener;
use crate::internals::StartingTcpSetup;
#[derive(Debug, Clone)]
pub struct TransportLayerBuilder {
ip: Option<IpAddr>,
port: Option<u16>,
}
impl TransportLayerBuilder {
pub(crate) fn new(ip: Option<IpAddr>, port: Option<u16>) -> Self {
Self { ip, port }
}
pub(crate) fn tcp_listener_with_reserved_port(
self,
) -> Result<(SocketAddr, TcpListener, Option<ReservedPort>)> {
let setup = StartingTcpSetup::new(self.ip, self.port)
.context("Cannot create socket address for use")?;
let socket_addr = setup.socket_addr;
let tcp_listener = setup.tcp_listener;
let maybe_reserved_port = setup.maybe_reserved_port;
Ok((socket_addr, tcp_listener, maybe_reserved_port))
}
pub fn tcp_listener(self) -> Result<TcpListener> {
let (_, tcp_listener, _) = self.tcp_listener_with_reserved_port()?;
Ok(tcp_listener)
}
}
@@ -0,0 +1,5 @@
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TransportLayerType {
Http,
Mock,
}
+17
View File
@@ -0,0 +1,17 @@
mod new_random_port;
pub use self::new_random_port::*;
mod new_random_socket_addr;
pub use self::new_random_socket_addr::*;
mod new_random_tcp_listener;
pub use self::new_random_tcp_listener::*;
mod new_random_tokio_tcp_listener;
pub use self::new_random_tokio_tcp_listener::*;
mod spawn_serve;
pub use self::spawn_serve::*;
mod serve_handle;
pub use self::serve_handle::*;
@@ -0,0 +1,8 @@
use anyhow::anyhow;
use anyhow::Result;
use reserve_port::ReservedPort;
/// Returns a randomly selected port that is not in use.
pub fn new_random_port() -> Result<u16> {
ReservedPort::random_permanently_reserved().map_err(|_| anyhow!("No free port was found"))
}
@@ -0,0 +1,17 @@
use anyhow::Result;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use crate::util::new_random_port;
pub(crate) const DEFAULT_IP_ADDRESS: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
/// Generates a `SocketAddr` on the IP 127.0.0.1, using a random port.
pub fn new_random_socket_addr() -> Result<SocketAddr> {
let ip_address = DEFAULT_IP_ADDRESS;
let port = new_random_port()?;
let addr = SocketAddr::new(ip_address, port);
Ok(addr)
}
@@ -0,0 +1,24 @@
use anyhow::Result;
use reserve_port::ReservedPort;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::net::TcpListener;
pub(crate) const DEFAULT_IP_ADDRESS: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
/// Binds a [`std::net::TcpListener`] on the IP 127.0.0.1, using a random port.
///
/// This is the best way to pick a local port.
pub fn new_random_tcp_listener() -> Result<TcpListener> {
let (tcp_listener, _) = ReservedPort::random_permanently_reserved_tcp(DEFAULT_IP_ADDRESS)?;
Ok(tcp_listener)
}
/// Binds a [`std::net::TcpListener`] on the IP 127.0.0.1, using a random port.
///
/// It is returned with the [`std::net::SocketAddr`] available.
pub fn new_random_tcp_listener_with_socket_addr() -> Result<(TcpListener, SocketAddr)> {
let result = ReservedPort::random_permanently_reserved_tcp(DEFAULT_IP_ADDRESS)?;
Ok(result)
}
@@ -0,0 +1,29 @@
use anyhow::Result;
use reserve_port::ReservedPort;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use tokio::net::TcpListener as TokioTcpListener;
pub(crate) const DEFAULT_IP_ADDRESS: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
/// Binds a [`tokio::net::TcpListener`] on the IP 127.0.0.1, using a random port.
///
/// This is the best way to pick a local port.
pub fn new_random_tokio_tcp_listener() -> Result<TokioTcpListener> {
new_random_tokio_tcp_listener_with_socket_addr()
.map(|(tokio_tcp_listener, _)| tokio_tcp_listener)
}
/// Binds a [`tokio::net::TcpListener`] on the IP 127.0.0.1, using a random port.
///
/// It is returned with the [`std::net::SocketAddr`] available.
pub fn new_random_tokio_tcp_listener_with_socket_addr() -> Result<(TokioTcpListener, SocketAddr)> {
let (tcp_listener, random_socket) =
ReservedPort::random_permanently_reserved_tcp(DEFAULT_IP_ADDRESS)?;
tcp_listener.set_nonblocking(true)?;
let tokio_tcp_listener = TokioTcpListener::from_std(tcp_listener)?;
Ok((tokio_tcp_listener, random_socket))
}
+25
View File
@@ -0,0 +1,25 @@
use tokio::task::JoinHandle;
/// A handle to a running Axum service.
///
/// When the handle is dropped, it will attempt to terminate the service.
#[derive(Debug)]
pub struct ServeHandle {
server_handle: JoinHandle<()>,
}
impl ServeHandle {
pub(crate) fn new(server_handle: JoinHandle<()>) -> Self {
Self { server_handle }
}
pub fn is_finished(&self) -> bool {
self.server_handle.is_finished()
}
}
impl Drop for ServeHandle {
fn drop(&mut self) {
self.server_handle.abort()
}
}
+33
View File
@@ -0,0 +1,33 @@
use crate::util::ServeHandle;
use axum::extract::Request;
use axum::response::Response;
use axum::serve;
use axum::serve::IncomingStream;
use axum::serve::Listener;
use core::fmt::Debug;
use std::convert::Infallible;
use tokio::spawn;
use tower::Service;
/// A wrapper around [`axum::serve()`] for tests,
/// which spawns the service in a new thread.
///
/// The [`crate::util::ServeHandle`] returned will automatically attempt
/// to terminate the service when dropped.
pub fn spawn_serve<L, M, S>(tcp_listener: L, make_service: M) -> ServeHandle
where
L: Listener,
L::Addr: Debug,
M: for<'a> Service<IncomingStream<'a, L>, Error = Infallible, Response = S> + Send + 'static,
for<'a> <M as Service<IncomingStream<'a, L>>>::Future: Send,
S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + 'static,
S::Future: Send,
{
let server_handle = spawn(async move {
serve(tcp_listener, make_service)
.await
.expect("Expect server to start serving");
});
ServeHandle::new(server_handle)
}
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -e
cargo +stable check
cargo +stable test --example=example-shuttle --features shuttle
cargo +stable test --example=example-todo
cargo +stable test --example=example-websocket-ping-pong --features ws
cargo +stable test --example=example-websocket-chat --features ws
cargo +stable test --features all "$@"
cargo +stable test "$@"
# Check minimum version works, excluding shuttle
cargo +1.83 check --features "pretty-assertions,yaml,msgpack,reqwest,typed-routing,ws"
# Check nightly also works, see https://github.com/JosephLenton/axum-test/issues/133
cargo +nightly check --features all "$@"
# Check the various build variations work
cargo +stable check --no-default-features
cargo +stable check --features all
cargo +stable check --features pretty-assertions
cargo +stable check --features yaml
cargo +stable check --features msgpack
cargo +stable check --features reqwest
# cargo +stable check --features shuttle
cargo +stable check --features typed-routing
cargo +stable check --features ws
cargo +stable check --features reqwest
cargo +stable clippy --features all