use fandhe_backend_http::request::RequestHead;
use fandhe_backend_http::response::Response;
use fandhe_backend_routes::{PathParams, Router};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct EchoBody {
message: String,
}
#[derive(Debug, Serialize)]
struct UserResponse {
id: u64,
name: String,
}
#[derive(Debug, Serialize)]
struct SearchResponse {
query: String,
limit: u32,
results: Vec<String>,
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}
fn json_response(status: u16, payload: &impl Serialize) -> Response {
let body = serde_json::to_vec(payload).unwrap_or_else(|_| b"{}".to_vec());
Response::new(status, body).with_content_type("application/json")
}
fn health(_head: &RequestHead, _body: &[u8]) -> Response {
Response::new(200, b"OK".to_vec()).with_content_type("text/plain")
}
fn hello(_head: &RequestHead, params: &PathParams<'_>, _body: &[u8]) -> Response {
let name = params.get("name").unwrap_or("world");
Response::new(200, format!("Hello, {name}!").into_bytes()).with_content_type("text/plain")
}
fn users_by_id(id_str: &str) -> Response {
match id_str.parse::<u64>() {
Ok(id) => json_response(
200,
&UserResponse {
id,
name: format!("User {id}"),
},
),
Err(_) => json_response(
400,
&ErrorBody {
error: "invalid id".to_string(),
},
),
}
}
fn users(_head: &RequestHead, params: &PathParams<'_>, _body: &[u8]) -> Response {
users_by_id(params.get("id").unwrap_or(""))
}
fn echo_body(body: &[u8]) -> Response {
match serde_json::from_slice::<EchoBody>(body) {
Ok(payload) => json_response(200, &payload),
Err(_) => json_response(
400,
&ErrorBody {
error: "invalid json body".to_string(),
},
),
}
}
fn echo(_head: &RequestHead, body: &[u8]) -> Response {
echo_body(body)
}
fn query_param<'a>(query: &'a str, key: &str) -> Option<&'a str> {
fandhe_backend_http::query::parse_query(query)
.ok()?
.find_map(|(k, v)| if k == key { Some(v) } else { None })
}
fn search_query(query: Option<&str>) -> Response {
let query = query.unwrap_or("");
let Some(q) = query_param(query, "q").filter(|v| !v.is_empty()) else {
return json_response(
400,
&ErrorBody {
error: "missing required query parameter 'q'".to_string(),
},
);
};
let limit = match query_param(query, "limit") {
None => 10,
Some(raw) => match raw.parse::<u32>() {
Ok(limit) => limit,
Err(_) => {
return json_response(
400,
&ErrorBody {
error: "invalid 'limit' query parameter".to_string(),
},
);
}
},
};
json_response(
200,
&SearchResponse {
query: q.to_string(),
limit,
results: vec![format!("{q}-result-0")],
},
)
}
fn search(head: &RequestHead, _body: &[u8]) -> Response {
search_query(head.query())
}
fn build_router() -> Router {
Router::new()
.route("GET", "/health", health)
.route_param("GET", "/hello/{name}", hello)
.expect("static route_param pattern '/hello/{name}' must be valid")
.route_param("GET", "/users/{id}", users)
.expect("static route_param pattern '/users/{id}' must be valid")
.route("POST", "/echo", echo)
.route("GET", "/search", search)
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::io::Result<()> {
let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:3003".to_string());
let server = fandhe_backend_core::Server::new().handler(build_router());
#[cfg(feature = "openapi")]
let server = server.openapi();
let bound = server.bind(&addr).await?;
println!("openapi_endpoints listening on {}", bound.local_addr()?);
bound.run().await
}
#[cfg(test)]
mod tests {
use super::*;
use fandhe_backend_http::request::{ParseOutcome, parse_request_head};
fn head_of(raw: &str) -> RequestHead {
match parse_request_head(raw.as_bytes()).unwrap() {
ParseOutcome::Complete { head, .. } => head,
ParseOutcome::Incomplete => panic!("incomplete request head in test fixture"),
}
}
fn body_str(response: &Response) -> String {
String::from_utf8(response.body.clone()).unwrap()
}
fn body_json(response: &Response) -> serde_json::Value {
serde_json::from_slice(&response.body).unwrap()
}
#[tokio::test]
async fn health_returns_200_ok() {
let router = build_router();
let res = router
.dispatch(&head_of("GET /health HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 200);
assert_eq!(body_str(&res), "OK");
let text = String::from_utf8(res.serialize(true)).unwrap();
assert!(text.contains("Content-Type: text/plain\r\n"));
}
#[tokio::test]
async fn health_wrong_method_returns_405() {
let router = build_router();
let res = router
.dispatch(&head_of("POST /health HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 405);
}
#[tokio::test]
async fn hello_returns_greeting() {
let router = build_router();
let res = router
.dispatch(&head_of("GET /hello/alice HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 200);
assert_eq!(body_str(&res), "Hello, alice!");
let text = String::from_utf8(res.serialize(true)).unwrap();
assert!(text.contains("Content-Type: text/plain\r\n"));
}
#[tokio::test]
async fn users_valid_id_returns_200_json() {
let router = build_router();
let res = router
.dispatch(&head_of("GET /users/42 HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 200);
let parsed = body_json(&res);
assert_eq!(parsed["id"], 42);
assert_eq!(parsed["name"], "User 42");
}
#[tokio::test]
async fn users_invalid_id_returns_400_json() {
let router = build_router();
let res = router
.dispatch(&head_of("GET /users/abc HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 400);
let parsed = body_json(&res);
assert_eq!(parsed["error"], "invalid id");
}
#[tokio::test]
async fn users_by_id_directly_rejects_non_numeric() {
let res = users_by_id("-1");
assert_eq!(res.status, 400);
}
#[tokio::test]
async fn echo_valid_json_roundtrips() {
let router = build_router();
let res = router
.dispatch(
&head_of("POST /echo HTTP/1.1\r\n\r\n"),
br#"{"message":"hi"}"#,
)
.await;
assert_eq!(res.status, 200);
let parsed = body_json(&res);
assert_eq!(parsed["message"], "hi");
}
#[tokio::test]
async fn echo_invalid_json_returns_400() {
let router = build_router();
let res = router
.dispatch(&head_of("POST /echo HTTP/1.1\r\n\r\n"), b"not json")
.await;
assert_eq!(res.status, 400);
let parsed = body_json(&res);
assert_eq!(parsed["error"], "invalid json body");
}
#[tokio::test]
async fn echo_wrong_method_returns_405() {
let router = build_router();
let res = router
.dispatch(&head_of("GET /echo HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 405);
}
#[tokio::test]
async fn search_returns_200_with_query_and_limit() {
let router = build_router();
let res = router
.dispatch(&head_of("GET /search?q=rust&limit=5 HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 200);
let parsed = body_json(&res);
assert_eq!(parsed["query"], "rust");
assert_eq!(parsed["limit"], 5);
}
#[tokio::test]
async fn search_missing_q_returns_400() {
let router = build_router();
let res = router
.dispatch(&head_of("GET /search HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 400);
}
#[tokio::test]
async fn search_missing_q_with_limit_only_returns_400() {
let router = build_router();
let res = router
.dispatch(&head_of("GET /search?limit=5 HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 400);
}
#[tokio::test]
async fn search_invalid_limit_returns_400() {
let router = build_router();
let res = router
.dispatch(
&head_of("GET /search?q=rust&limit=abc HTTP/1.1\r\n\r\n"),
b"",
)
.await;
assert_eq!(res.status, 400);
}
#[tokio::test]
async fn search_default_limit_is_ten_when_omitted() {
let router = build_router();
let res = router
.dispatch(&head_of("GET /search?q=rust HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 200);
let parsed = body_json(&res);
assert_eq!(parsed["limit"], 10);
}
#[tokio::test]
async fn search_unknown_path_still_returns_404() {
let router = build_router();
let res = router
.dispatch(&head_of("GET /missing HTTP/1.1\r\n\r\n"), b"")
.await;
assert_eq!(res.status, 404);
}
}