mini-serve 0.12.3

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

#[derive(Deserialize)]
struct ItemParams {
	id: u64,
}

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

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

	let resp = reqwest::get(&format!("http://127.0.0.1:{}/items/42", 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("id").and_then(|v| v.as_u64()), Some(42));
}

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

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

	let resp = reqwest::get(&format!("http://127.0.0.1:{}/items/not-a-number", port))
		.await
		.expect("request failed");

	assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
	let body: serde_json::Value = resp.json().await.expect("failed to parse json");
	assert_eq!(
		body.get("message").and_then(|v| v.as_str()),
		Some("invalid path parameters")
	);
}

/// `PathParams` and `QueryParams` are no longer inserted into request extensions when
/// they would be empty — each insert boxes a value and hashes a `TypeId`, and a route
/// like `/health` was paying for both on every request to carry nothing.
///
/// The saving is only honest if no consumer can tell. This pins the reader half of that
/// bargain: a param-less route asking for its params gets the same `400` it would get for
/// a param it could not parse, not a `500`. It used to be a `500`, which said "the server
/// is broken" about a handler asking a question its own route cannot answer.
#[tokio::test]
async fn a_param_less_route_asking_for_params_is_a_400_not_a_500() {
	let app = RouteBuilder::stateless()
		.get("/health", handler(|req, _state| async move {
			let params: ItemParams = path_params(&req)?;
			mini_serve::json(StatusCode::OK, &serde_json::json!({"id": params.id}))
		}))
		.seal();

	let port = app.bind_ephemeral().await.expect("failed to bind");

	let resp = reqwest::get(&format!("http://127.0.0.1:{port}/health"))
		.await
		.expect("request failed");

	assert_eq!(
		resp.status(),
		StatusCode::BAD_REQUEST,
		"a route that captures nothing cannot answer for its params; that is the \
		 caller's error, not a server fault"
	);
}

/// The other half: `query_params` must read a missing extension as an empty map, so a
/// request with no query string is indistinguishable from one that carried `?` alone.
/// Without this, skipping the insert silently turns `Some(empty)` into `None` at every
/// call site.
#[tokio::test]
async fn query_params_reads_absence_as_emptiness() {
	let app = RouteBuilder::stateless()
		.get("/search", handler(|req, _state| async move {
			let q = query_params(&req);
			mini_serve::json(StatusCode::OK, &serde_json::json!({
				"count": q.0.len(),
				"term":  q.0.get("term").cloned(),
			}))
		}))
		.seal();

	let port = app.bind_ephemeral().await.expect("failed to bind");

	for (url, expected_count, expected_term) in [
		("/search",             0, None),
		("/search?",            0, None),
		("/search?term=rust",   1, Some("rust")),
	] {
		let resp = reqwest::get(&format!("http://127.0.0.1:{port}{url}"))
			.await
			.expect("request failed");
		assert_eq!(resp.status(), StatusCode::OK, "{url}");

		let body: serde_json::Value = resp.json().await.expect("failed to parse json");
		assert_eq!(
			body.get("count").and_then(|v| v.as_u64()),
			Some(expected_count),
			"{url} must present a map whether or not one was inserted"
		);
		assert_eq!(
			body.get("term").and_then(|v| v.as_str()),
			expected_term,
			"{url}"
		);
	}
}

/// `parse_query` used to run on every request, building a map to hold nothing for the
/// ones with no query string. It is now guarded — on *emptiness*, not absence, because
/// `?` alone parses as `Some("")` and a guard written as `is_none()` would miss exactly
/// the case it exists to catch.
///
/// This pins the parsing behaviour the guard must not change: a repeated key resolving
/// to its last value, an empty value, a valueless key, and `+`/percent decoding.
#[tokio::test]
async fn a_guarded_query_parse_still_parses_the_same_way() {
	let app = RouteBuilder::stateless()
		.get("/q", handler(|req, _state| async move {
			let q = query_params(&req);
			let mut pairs: Vec<String> =
				q.0.iter().map(|(k, v)| format!("{k}={v}")).collect();
			pairs.sort();
			mini_serve::json(StatusCode::OK, &serde_json::json!({"pairs": pairs}))
		}))
		.seal();

	let port = app.bind_ephemeral().await.expect("failed to bind");

	for (query, expected) in [
		("?a=1&a=2",          vec!["a=2"]),
		("?a=",               vec!["a="]),
		("?flag",             vec!["flag="]),
		("?name=a+b",         vec!["name=a b"]),
		("?name=%69tem",      vec!["name=item"]),
		("?a=1&b=2",          vec!["a=1", "b=2"]),
	] {
		let body: serde_json::Value =
			reqwest::get(&format!("http://127.0.0.1:{port}/q{query}"))
				.await
				.expect("request failed")
				.json()
				.await
				.expect("failed to parse json");

		let pairs: Vec<&str> = body["pairs"]
			.as_array()
			.expect("pairs")
			.iter()
			.map(|v| v.as_str().unwrap())
			.collect();
		assert_eq!(pairs, expected, "query {query}");
	}
}