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};

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

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

	let client = reqwest::Client::new();
	let resp = client
		.post(format!("http://127.0.0.1:{}/api/items", port))
		.send()
		.await
		.expect("request failed");

	assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);

	let allow_header = resp
		.headers()
		.get("allow")
		.and_then(|h| h.to_str().ok())
		.expect("missing Allow header");

	assert!(allow_header.contains("GET"), "Allow header should contain GET: {}", allow_header);
	assert!(allow_header.contains("HEAD"), "Allow header should contain HEAD: {}", allow_header);
}

#[tokio::test]
async fn multiple_methods_on_same_path() {
	let app = RouteBuilder::stateless()
		.get("/resource", handler(|_req, _state| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({"method": "GET"}))
		}))
		.post("/resource", handler(|_req, _state| async {
			mini_serve::json(StatusCode::CREATED, &serde_json::json!({"method": "POST"}))
		}))
		.put("/resource", handler(|_req, _state| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({"method": "PUT"}))
		}))
		.seal();

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

	let client = reqwest::Client::new();

	let get_resp = client
		.get(format!("http://127.0.0.1:{}/resource", port))
		.send()
		.await
		.expect("GET failed");
	assert_eq!(get_resp.status(), StatusCode::OK);

	let post_resp = client
		.post(format!("http://127.0.0.1:{}/resource", port))
		.send()
		.await
		.expect("POST failed");
	assert_eq!(post_resp.status(), StatusCode::CREATED);

	let put_resp = client
		.put(format!("http://127.0.0.1:{}/resource", port))
		.send()
		.await
		.expect("PUT failed");
	assert_eq!(put_resp.status(), StatusCode::OK);

	let delete_resp = client
		.delete(format!("http://127.0.0.1:{}/resource", port))
		.send()
		.await
		.expect("DELETE failed");
	assert_eq!(delete_resp.status(), StatusCode::METHOD_NOT_ALLOWED);

	let allow_header = delete_resp
		.headers()
		.get("allow")
		.and_then(|h| h.to_str().ok())
		.expect("missing Allow header");

	let methods: Vec<&str> = allow_header.split(", ").collect();
	assert!(methods.contains(&"GET"), "Allow should contain GET");
	assert!(methods.contains(&"HEAD"), "Allow should contain HEAD");
	assert!(methods.contains(&"POST"), "Allow should contain POST");
	assert!(methods.contains(&"PUT"), "Allow should contain PUT");
	assert!(!methods.contains(&"DELETE"), "Allow should not contain DELETE");
}

#[tokio::test]
async fn unregistered_path_returns_404_without_allow() {
	let app = RouteBuilder::stateless()
		.get("/exists", handler(|_req, _state| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({}))
		}))
		.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:{}/does-not-exist", port))
		.await
		.expect("request failed");

	assert_eq!(resp.status(), StatusCode::NOT_FOUND);
	assert!(resp.headers().get("allow").is_none(), "404 should not have Allow header");
}

/// `PATCH` was the one common REST verb without a builder. Dispatch and the `Allow`
/// header are generic over `Method`, so this asserts the generic path rather than a
/// special case — if anything on the way had a fixed method list, the 405 below would
/// omit `PATCH`.
#[tokio::test]
async fn patch_routes_are_served() {
	let app = RouteBuilder::stateless()
		.patch(
			"/resource",
			handler(|_req, _state| async {
				mini_serve::json(StatusCode::OK, &serde_json::json!({"patched": true}))
			}),
		)
		.seal();

	let port = app.bind_ephemeral().await.unwrap();
	let response = reqwest::Client::new()
		.patch(format!("http://127.0.0.1:{port}/resource"))
		.send()
		.await
		.unwrap();

	assert_eq!(response.status(), StatusCode::OK);
	let body: serde_json::Value = response.json().await.unwrap();
	assert_eq!(body["patched"], true);
}

/// The `Allow` header is built from the router's own method map, so a `PATCH` route must
/// appear in it without anything else being taught about `PATCH`.
#[tokio::test]
async fn a_wrong_method_on_a_patch_route_reports_patch_in_allow() {
	let app = RouteBuilder::stateless()
		.patch(
			"/resource",
			handler(|_req, _state| async {
				mini_serve::json(StatusCode::OK, &serde_json::json!({"patched": true}))
			}),
		)
		.seal();

	let port = app.bind_ephemeral().await.unwrap();
	let response = reqwest::get(format!("http://127.0.0.1:{port}/resource")).await.unwrap();

	assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
	let allow = response
		.headers()
		.get("allow")
		.expect("a 405 must carry Allow")
		.to_str()
		.unwrap()
		.to_string();
	assert!(allow.contains("PATCH"), "Allow omitted PATCH: {allow}");
}

/// The route table stopped being a `HashMap<Method, _>` and became a `Vec` scanned
/// linearly, because SipHash defends against attacker-chosen keys being *inserted* and
/// this map's keys are the handful of methods the router registered itself.
///
/// The existing coverage asserts `Allow` *contains* GET and HEAD, which cannot see a
/// method that went missing or one that appeared twice. This pins the whole header,
/// byte for byte, across every method the builder can register — so a change to how the
/// table is stored, ordered, or deduplicated has to come past this test.
#[tokio::test]
async fn the_allow_header_lists_exactly_the_registered_methods() {
	let app = RouteBuilder::stateless()
		.get("/r", handler(|_, _| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({}))
		}))
		.post("/r", handler(|_, _| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({}))
		}))
		.put("/r", handler(|_, _| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({}))
		}))
		.patch("/r", handler(|_, _| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({}))
		}))
		.seal();

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

	let resp = reqwest::Client::new()
		.request(reqwest::Method::DELETE, format!("http://127.0.0.1:{port}/r"))
		.send()
		.await
		.expect("request failed");

	assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
	assert_eq!(
		resp.headers().get("allow").and_then(|h| h.to_str().ok()),
		Some("GET, HEAD, PATCH, POST, PUT"),
		"Allow must list every registered method exactly once, sorted"
	);
}

/// Registering the same method twice on one path must leave one handler. Under a
/// `HashMap` that was `insert`'s replace semantics for free; under a `Vec` it is a
/// choice, and pushing instead of replacing would leave the first handler shadowing an
/// unreachable second — or worse, list the method twice in `Allow`.
#[tokio::test]
async fn re_registering_a_method_replaces_rather_than_shadows() {
	let app = RouteBuilder::stateless()
		.get("/r", handler(|_, _| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({"which": "first"}))
		}))
		.get("/r", handler(|_, _| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({"which": "second"}))
		}))
		.post("/r", handler(|_, _| async {
			mini_serve::json(StatusCode::OK, &serde_json::json!({}))
		}))
		.seal();

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

	let body: serde_json::Value = reqwest::get(&format!("http://127.0.0.1:{port}/r"))
		.await
		.expect("request failed")
		.json()
		.await
		.expect("failed to parse json");
	assert_eq!(
		body.get("which").and_then(|v| v.as_str()),
		Some("second"),
		"the later registration must win, as HashMap::insert did"
	);

	let resp = reqwest::Client::new()
		.request(reqwest::Method::DELETE, format!("http://127.0.0.1:{port}/r"))
		.send()
		.await
		.expect("request failed");
	assert_eq!(
		resp.headers().get("allow").and_then(|h| h.to_str().ok()),
		Some("GET, HEAD, POST"),
		"a re-registered method must not appear twice in Allow"
	);
}