mini-serve 0.13.12

An HTTP server: trie router, middleware, CORS, optional TLS. Built on hyper + tokio.
Documentation
use hyper::StatusCode;
use mini_serve::{RouteBuilder, handler, path_params};
use serde::Deserialize;

#[derive(Deserialize)]
struct NameParam {
	name: String,
}

#[tokio::test]
async fn percent_encoded_path_segment_matches_route() {
	let app = RouteBuilder::stateless()
		.get("/api/items", handler(|_req, _state| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({"result": "success"}))
		}))
		.seal();

	let port = app.bind_ephemeral().await.expect("failed to bind");
	tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;

	// /api/%69tems should decode to /api/items and match
	let resp = reqwest::get(&format!("http://127.0.0.1:{}/api/%69tems", port))
		.await
		.expect("request failed");

	assert_eq!(resp.status(), StatusCode::OK);
	let body: serde_json::Value = resp.json().await.expect("failed to parse json");
	assert_eq!(body.get("result").and_then(|v| v.as_str()), Some("success"));
}

#[tokio::test]
async fn percent_encoded_path_param_extracted_correctly() {
	let app = RouteBuilder::stateless()
		.get("/users/:name", handler(|req, _state| async move {
			let params: NameParam = path_params(&req)?;
			let name = params.name;
			mini_serve::json(StatusCode::OK, &serde_json::json!({"name": name}))
		}))
		.seal();

	let port = app.bind_ephemeral().await.expect("failed to bind");
	tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;

	// %40 is @, so alice%40example.com should decode to alice@example.com
	let resp = reqwest::get(&format!("http://127.0.0.1:{}/users/alice%40example.com", port))
		.await
		.expect("request failed");

	assert_eq!(resp.status(), StatusCode::OK);
	let body: serde_json::Value = resp.json().await.expect("failed to parse json");
	assert_eq!(
		body.get("name").and_then(|v| v.as_str()),
		Some("alice@example.com")
	);
}

#[tokio::test]
async fn plus_decodes_as_space_in_query_string() {
	let app = RouteBuilder::stateless()
		.get("/search", handler(|req, _state| async move {
			let query_str = req
				.uri()
				.query()
				.unwrap_or("");

			// Manually parse to test
			let mut map = std::collections::HashMap::new();
			for pair in query_str.split('&').filter(|s| !s.is_empty()) {
				if let Some((key, value)) = pair.split_once('=') {
					let key = key.replace('+', " ");
					let key = percent_encoding::percent_decode_str(&key)
						.decode_utf8_lossy()
						.into_owned();
					let value = value.replace('+', " ");
					let value = percent_encoding::percent_decode_str(&value)
						.decode_utf8_lossy()
						.into_owned();
					map.insert(key, value);
				}
			}

			mini_serve::json(StatusCode::OK, &serde_json::json!({"query": map}))
		}))
		.seal();

	let port = app.bind_ephemeral().await.expect("failed to bind");
	tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;

	// hello+world should decode to "hello world"
	let resp = reqwest::get(&format!("http://127.0.0.1:{}/search?q=hello+world", port))
		.await
		.expect("request failed");

	assert_eq!(resp.status(), StatusCode::OK);
	let body: serde_json::Value = resp.json().await.expect("failed to parse json");
	let query = body.get("query").and_then(|v| v.as_object()).expect("missing query");
	assert_eq!(query.get("q").and_then(|v| v.as_str()), Some("hello world"));
}

#[tokio::test]
async fn percent_encoded_query_param_decoded() {
	let app = RouteBuilder::stateless()
		.get("/search", handler(|req, _state| async move {
			let query_str = req.uri().query().unwrap_or("");
			let mut map = std::collections::HashMap::new();
			for pair in query_str.split('&').filter(|s| !s.is_empty()) {
				if let Some((key, value)) = pair.split_once('=') {
					let key = key.replace('+', " ");
					let key = percent_encoding::percent_decode_str(&key)
						.decode_utf8_lossy()
						.into_owned();
					let value = value.replace('+', " ");
					let value = percent_encoding::percent_decode_str(&value)
						.decode_utf8_lossy()
						.into_owned();
					map.insert(key, value);
				}
			}

			mini_serve::json(StatusCode::OK, &serde_json::json!({"params": map}))
		}))
		.seal();

	let port = app.bind_ephemeral().await.expect("failed to bind");
	tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;

	// %3D is =, so key%3Dvalue should decode to "key=value"
	let resp = reqwest::get(&format!("http://127.0.0.1:{}/search?data=key%3Dvalue", port))
		.await
		.expect("request failed");

	assert_eq!(resp.status(), StatusCode::OK);
	let body: serde_json::Value = resp.json().await.expect("failed to parse json");
	let params = body.get("params").and_then(|v| v.as_object()).expect("missing params");
	assert_eq!(params.get("data").and_then(|v| v.as_str()), Some("key=value"));
}

#[tokio::test]
async fn combined_percent_encoding_and_plus_in_query() {
	let app = RouteBuilder::stateless()
		.get("/api/search", handler(|req, _state| async move {
			let query_str = req.uri().query().unwrap_or("");
			let mut map = std::collections::HashMap::new();
			for pair in query_str.split('&').filter(|s| !s.is_empty()) {
				if let Some((key, value)) = pair.split_once('=') {
					let key = key.replace('+', " ");
					let key = percent_encoding::percent_decode_str(&key)
						.decode_utf8_lossy()
						.into_owned();
					let value = value.replace('+', " ");
					let value = percent_encoding::percent_decode_str(&value)
						.decode_utf8_lossy()
						.into_owned();
					map.insert(key, value);
				}
			}

			mini_serve::json(StatusCode::OK, &serde_json::json!({"result": map}))
		}))
		.seal();

	let port = app.bind_ephemeral().await.expect("failed to bind");
	tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;

	// "hello world & goodbye" with %26 for & and + for spaces
	let resp = reqwest::get(&format!(
		"http://127.0.0.1:{}/api/search?msg=hello+world+%26+goodbye",
		port
	))
	.await
	.expect("request failed");

	assert_eq!(resp.status(), StatusCode::OK);
	let body: serde_json::Value = resp.json().await.expect("failed to parse json");
	let result = body
		.get("result")
		.and_then(|v| v.as_object())
		.expect("missing result");
	assert_eq!(
		result.get("msg").and_then(|v| v.as_str()),
		Some("hello world & goodbye")
	);
}