use std::sync::Arc;
use axum::body::Body;
use axum::http::Request;
use axum::response::Response;
use tower::ServiceExt;
use crate::Ctx;
use crate::errors;
use crate::httpapi;
use crate::registry::Registry;
use crate::spec::command::{Command, HTTPHints};
use xyz_rust::XyzArgs;
#[derive(XyzArgs)]
struct AddHTTPArgs {
#[xyz(desc = "用户名", required, validate = "min=2", http = "path")]
name: String,
#[xyz(desc = "年龄", default = "18", http = "query")]
age: i32,
#[xyz(desc = "令牌", skip, http = "header", http_name = "X-Token")]
token: String,
}
fn add_user(_: &Ctx, in_: &AddHTTPArgs) -> errors::Result<String> {
Ok(format!("{}:{}:{}", in_.name, in_.age, in_.token))
}
async fn resp_body(resp: Response) -> (u16, String) {
let status = resp.status().as_u16();
let body = resp.into_body();
let bytes = axum::body::to_bytes(body, 1 << 20).await.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
async fn call(router: axum::routing::Router, req: Request<Body>) -> (u16, String) {
let resp = router.oneshot(req).await.unwrap();
resp_body(resp).await
}
fn add_reg() -> Registry {
let reg = Registry::new();
Command::new("user.add", add_user)
.summary("加用户")
.http(HTTPHints {
method: "POST".into(),
path: "/users/{name}".into(),
..Default::default()
})
.register(®)
.unwrap();
reg
}
#[tokio::test(flavor = "current_thread")]
async fn binding_merges_body_path_query_header() {
let reg = add_reg();
let router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
let body = serde_json::json!({"age": 9}).to_string();
let req = Request::builder()
.method("POST")
.uri("/users/alice?age=7")
.header("X-Token", "tk")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let (status, out) = call(router, req).await;
assert_eq!(status, 200);
assert_eq!(out.trim(), "\"alice:7:tk\"");
}
#[tokio::test(flavor = "current_thread")]
async fn invalid_declared_json_body_is_400() {
let reg = add_reg();
let router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
let req = Request::builder()
.method("POST")
.uri("/users/alice")
.header("content-type", "application/json")
.body(Body::from("{nope".to_string()))
.unwrap();
let (status, out) = call(router, req).await;
assert_eq!(status, 400);
assert!(out.contains("invalid JSON body"), "{out}");
}
#[tokio::test(flavor = "current_thread")]
async fn undeclared_body_is_not_rejected_as_json() {
let reg = Registry::new();
#[derive(XyzArgs)]
struct F {
#[xyz(desc = "n", http = "form")]
note: String,
}
fn fh(_: &Ctx, in_: &F) -> errors::Result<String> {
Ok(format!("note={}", in_.note))
}
Command::new("f.submit", fh)
.http(HTTPHints {
method: "POST".into(),
path: "/f".into(),
..Default::default()
})
.register(®)
.unwrap();
let router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
let req = Request::builder()
.method("POST")
.uri("/f")
.body(Body::from("note=hello%20form".to_string()))
.unwrap();
let (status, out) = call(router.clone(), req).await;
assert_eq!(status, 200);
assert_eq!(out.trim(), "\"note=hello form\"");
let req = Request::builder()
.method("POST")
.uri("/f?note=fromquery")
.body(Body::from("note=frombody".to_string()))
.unwrap();
let (_, out) = call(router, req).await;
assert_eq!(out.trim(), "\"note=frombody\"");
}
#[tokio::test(flavor = "current_thread")]
async fn validation_error_maps_to_400() {
let reg = add_reg();
let router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
let req = Request::builder()
.method("POST")
.uri("/users/a") .body(Body::empty())
.unwrap();
let (status, out) = call(router, req).await;
assert_eq!(status, 400);
assert!(
out.contains("invalid value for field \\\"name\\\": min"),
"{out}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn business_error_maps_to_404_with_error_body() {
let reg = Registry::new();
#[derive(XyzArgs)]
struct N {
#[xyz(desc = "n")]
n: String,
}
fn nh(_: &Ctx, _: &N) -> errors::Result<String> {
Err(errors::new(errors::Kind::NotFound, "no such user"))
}
Command::new("n.hit", nh)
.http(HTTPHints {
method: "GET".into(),
path: "/n".into(),
..Default::default()
})
.register(®)
.unwrap();
let router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
let req = Request::builder()
.method("GET")
.uri("/n")
.body(Body::empty())
.unwrap();
let (status, out) = call(router, req).await;
assert_eq!(status, 404);
assert_eq!(
out.trim(),
"{\"error\":\"no such user\",\"kind\":\"not_found\"}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn rich_error_body_and_status_override() {
use crate::errors;
let reg = Registry::new();
#[derive(xyz_rust::XyzArgs)]
struct RichArgs {
#[xyz(desc = "n")]
n: String,
}
fn rich(_: &Ctx, _: &RichArgs) -> errors::Result<String> {
Err(errors::new(errors::Kind::Conflict, "dup")
.with_code("DUPLICATE")
.with_detail("name", serde_json::json!("alice"))
.with_status(410))
}
Command::new("n.rich", rich)
.http(HTTPHints {
method: "GET".into(),
path: "/n/rich".into(),
..Default::default()
})
.register(®)
.unwrap();
let router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
let req = Request::builder()
.method("GET")
.uri("/n/rich")
.body(Body::empty())
.unwrap();
let (status, out) = call(router, req).await;
assert_eq!(status, 410);
let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(v["error"], "dup");
assert_eq!(v["kind"], "conflict");
assert_eq!(v["code"], "DUPLICATE");
assert_eq!(v["detail"]["name"], "alice");
}
#[tokio::test(flavor = "current_thread")]
async fn healthz_and_openapi() {
let reg = add_reg();
let router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
let req = Request::builder()
.method("GET")
.uri("/healthz")
.body(Body::empty())
.unwrap();
let (status, out) = call(router.clone(), req).await;
assert_eq!(status, 200);
assert_eq!(out, "{\"status\":\"ok\"}\n");
let req = Request::builder()
.method("GET")
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (status, out) = call(router, req).await;
assert_eq!(status, 200);
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["openapi"], "3.0.3");
let path = v["paths"]["/users/{name}"]["post"].clone();
assert_eq!(path["summary"], "加用户");
let params = path["parameters"].as_array().unwrap();
assert!(
params
.iter()
.any(|p| p["name"] == "name" && p["in"] == "path"),
"{params:?}"
);
assert!(path["requestBody"]["content"]["application/json"]["schema"]["type"] == "object");
}
#[test]
fn conflicting_routes_rejected() {
let reg = Registry::new();
#[derive(XyzArgs)]
struct X {}
fn xh(_: &Ctx, _: &X) -> errors::Result<String> {
Ok(String::new())
}
Command::new("x.one", xh)
.http(HTTPHints {
method: "GET".into(),
path: "/dup".into(),
..Default::default()
})
.register(®)
.unwrap();
Command::new("x.two", xh)
.http(HTTPHints {
method: "GET".into(),
path: "/dup".into(),
..Default::default()
})
.register(®)
.unwrap();
let err = httpapi::router(®, Arc::new(Ctx::new())).unwrap_err();
assert!(err.to_string().contains("conflicts"), "{err}");
let reg2 = Registry::new();
Command::new("x.one", xh)
.http(HTTPHints {
method: "GET".into(),
path: "/d".into(),
..Default::default()
})
.register(®2)
.unwrap();
Command::new("x.two", xh)
.http(HTTPHints {
method: "POST".into(),
path: "/d".into(),
..Default::default()
})
.register(®2)
.unwrap();
assert!(httpapi::router(®2, Arc::new(Ctx::new())).is_ok());
}
#[tokio::test(flavor = "current_thread")]
async fn handler_for_binds_one_entry() {
let reg = add_reg();
let entry = reg.get("user.add").unwrap();
let h = httpapi::handler_for(entry);
let router: axum::routing::Router =
axum::routing::Router::new().route("/users/{name}", axum::routing::post(h));
let req = Request::builder()
.method("POST")
.uri("/users/some?age=3")
.header("X-Token", "z")
.body(Body::empty())
.unwrap();
let (status, out) = call(router, req).await;
assert_eq!(status, 200);
assert_eq!(out.trim(), "\"some:3:z\""); }
#[tokio::test(flavor = "current_thread")]
async fn bearer_and_cors_middleware() {
let reg = add_reg();
let mut router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
router = crate::httpapi::middleware::apply(
router,
vec!["s3cret".into()],
vec!["http://app.local".into()],
std::time::Duration::ZERO,
);
let req = Request::builder()
.method("GET")
.uri("/nope")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status().as_u16(), 401);
assert_eq!(resp.headers().get("www-authenticate").unwrap(), "Bearer");
let req = Request::builder()
.method("GET")
.uri("/nope")
.header("Authorization", "Bearer s3cret")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status().as_u16(), 404);
let req = Request::builder()
.method("OPTIONS")
.uri("/nope")
.header("Origin", "http://app.local")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status().as_u16(), 204);
}
#[tokio::test(flavor = "current_thread")]
async fn gzip_compresses_when_asked() {
let reg = add_reg();
let mut router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
router = crate::httpapi::middleware::apply(router, vec![], vec![], std::time::Duration::ZERO);
let req = Request::builder()
.method("GET")
.uri("/healthz")
.header("Accept-Encoding", "gzip")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.headers().get("content-encoding").unwrap(), "gzip");
}
#[test]
fn normalize_addr_port_shorthand() {
use crate::httpapi::normalize_addr;
assert_eq!(normalize_addr(":8080"), "0.0.0.0:8080");
assert_eq!(normalize_addr("127.0.0.1:8080"), "127.0.0.1:8080");
assert_eq!(normalize_addr("0.0.0.0:9090"), "0.0.0.0:9090");
assert_eq!(normalize_addr("::"), "::");
assert_eq!(normalize_addr("[::1]:8080"), "[::1]:8080");
}
#[tokio::test(flavor = "current_thread")]
async fn server_context_headers_on_all_responses() {
use axum::middleware::from_fn;
use tower::ServiceExt;
let router = httpapi::router(&add_reg(), Arc::new(Ctx::new())).unwrap();
let with_ctx = router.layer(from_fn(crate::httpapi::middleware::server_context_mw(
"appt".to_string(),
"9.9".to_string(),
vec![("X-Custom".to_string(), "c".to_string())],
true,
)));
let req = Request::builder()
.method("GET")
.uri("/nowhere")
.body(Body::empty())
.unwrap();
let resp = with_ctx.oneshot(req).await.unwrap();
let h = resp.headers();
assert_eq!(h.get("x-app-name").unwrap(), "appt");
assert_eq!(h.get("x-app-version").unwrap(), "9.9");
assert_eq!(h.get("x-xyz-version").unwrap(), crate::version::SDK_VERSION);
assert_eq!(h.get("x-custom").unwrap(), "c");
let router = httpapi::router(&add_reg(), Arc::new(Ctx::new())).unwrap();
let no_auto = router
.clone()
.layer(from_fn(crate::httpapi::middleware::server_context_mw(
"appt".to_string(),
"9.9".to_string(),
vec![("X-Custom".to_string(), "c".to_string())],
false,
)));
let req = Request::builder()
.method("GET")
.uri("/nowhere")
.body(Body::empty())
.unwrap();
let resp = no_auto.oneshot(req).await.unwrap();
let h = resp.headers();
assert!(h.get("x-app-name").is_none());
assert_eq!(h.get("x-custom").unwrap(), "c");
let req = Request::builder()
.method("GET")
.uri("/users/alice?age=3")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert!(resp.headers().get("x-xyz-command").is_none()); }
#[tokio::test(flavor = "current_thread")]
async fn accept_language_localizes_and_reaches_handler() {
use crate::lang::{XyzLang, parse_accept_language};
assert_eq!(
parse_accept_language("zh-CN,zh;q=0.9,en;q=0.8"),
Some(XyzLang::ZhCn)
);
assert_eq!(parse_accept_language("en-US,en;q=0.9"), Some(XyzLang::En));
assert_eq!(parse_accept_language("fr,de;q=0.9"), None);
assert_eq!(
parse_accept_language("en;q=0.3,zh;q=0.9"),
Some(XyzLang::ZhCn)
);
let reg = Registry::new();
#[derive(xyz_rust::XyzArgs)]
struct MArgs {
#[xyz(desc = "n")]
n: String,
}
fn who(_: &Ctx, _: &MArgs) -> errors::Result<String> {
Ok(String::new())
}
struct CtxLang;
fn lang_of(ctx: &Ctx, _: &MArgs) -> errors::Result<String> {
let _ = CtxLang;
Ok(xyz_rust::language_from_ctx(ctx))
}
Command::new("m.lang", lang_of)
.http(HTTPHints {
method: "GET".into(),
path: "/m/lang".into(),
..Default::default()
})
.register(®)
.unwrap();
let _ = who;
let router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
let req = Request::builder()
.method("GET")
.uri("/m/lang")
.header("Accept-Language", "zh-CN,en;q=0.5")
.body(Body::empty())
.unwrap();
let (status, out) = call(router.clone(), req).await;
assert_eq!(status, 200);
assert_eq!(out.trim(), "\"zh-CN\"");
let req = Request::builder()
.method("GET")
.uri("/m/lang")
.body(Body::empty())
.unwrap();
let (_, out) = call(router, req).await;
assert_eq!(
out.trim(),
format!("\"{}\"", crate::lang::current().as_str())
);
Command::new("m.post", lang_of)
.http(HTTPHints {
method: "POST".into(),
path: "/m/post".into(),
..Default::default()
})
.register(®)
.unwrap();
let req = Request::builder()
.method("POST")
.uri("/m/post")
.header("Content-Type", "application/json")
.header("Accept-Language", "zh-CN")
.body(Body::from("{not json"))
.unwrap();
let (status, out) = call(httpapi::router(®, Arc::new(Ctx::new())).unwrap(), req).await;
assert_eq!(status, 400);
assert!(out.contains("无效的 JSON 请求体"), "{out}");
}
#[tokio::test(flavor = "current_thread")]
async fn missing_route_is_localized_json_404() {
let router = httpapi::router(&add_reg(), Arc::new(Ctx::new())).unwrap();
let req = Request::builder()
.method("GET")
.uri("/no/such/route")
.body(Body::empty())
.unwrap();
let (status, out) = call(router.clone(), req).await;
assert_eq!(status, 404);
let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
assert!(v["error"].as_str().is_some_and(|s| !s.is_empty()));
let req = Request::builder()
.method("GET")
.uri("/no/such/route")
.header("Accept-Language", "zh-CN")
.body(Body::empty())
.unwrap();
let (status, out) = call(router, req).await;
assert_eq!(status, 404);
let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(v["error"], "未找到");
}
#[tokio::test(flavor = "current_thread")]
async fn default_get_post_and_multi_method() {
use crate::registry::Registry;
use crate::spec::command::Command;
#[derive(xyz_rust::XyzArgs)]
struct DArgs {
#[xyz(desc = "值")]
v: i64,
}
fn echo(_: &Ctx, a: &DArgs) -> errors::Result<i64> {
Ok(a.v)
}
let reg = Registry::new();
Command::new("d.echo", echo)
.http(HTTPHints {
method: String::new(),
path: "/d/echo".into(),
..Default::default()
})
.register(®)
.unwrap();
Command::new("d.multi", echo)
.http(HTTPHints {
method: "GET,POST,PUT".into(),
path: "/d/multi".into(),
..Default::default()
})
.register(®)
.unwrap();
let router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
let req = Request::builder()
.method("GET")
.uri("/d/echo?v=7")
.body(Body::empty())
.unwrap();
let (status, out) = call(router.clone(), req).await;
assert_eq!(status, 200);
assert_eq!(out.trim(), "7");
let req = Request::builder()
.method("POST")
.uri("/d/echo")
.header("Content-Type", "application/json")
.body(Body::from("{\"v\":9}"))
.unwrap();
let (status, out) = call(router.clone(), req).await;
assert_eq!(status, 200);
assert_eq!(out.trim(), "9");
for m in ["GET", "POST", "PUT"] {
let req = Request::builder()
.method(m)
.uri("/d/multi?v=1")
.body(Body::empty())
.unwrap();
let (status, _) = call(router.clone(), req).await;
assert_eq!(status, 200, "method {m}");
}
}
#[tokio::test(flavor = "current_thread")]
async fn openapi_is_rich() {
use crate::registry::Registry;
use crate::spec::command::Command;
#[derive(xyz_rust::XyzArgs)]
struct RArgs {
#[xyz(desc = "目标名", required, http = "path")]
name: String,
#[xyz(desc = "模式", enum = "fast,slow", default = "fast", http = "query")]
mode: String,
#[xyz(desc = "令牌", http = "header", http_name = "X-Token")]
token: String,
#[xyz(desc = "数量上限", default = "10")]
limit: i32,
}
fn get(_: &Ctx, a: &RArgs) -> errors::Result<String> {
Ok(a.name.clone())
}
let reg = Registry::new();
Command::new("r.get", get)
.summary("读取资源")
.description("更长的说明")
.http(HTTPHints {
method: String::new(),
path: "/r/{name}".into(),
..Default::default()
})
.register(®)
.unwrap();
let doc_handler = crate::httpapi::openapi::openapi_handler(®, "appt", "9.9");
let router =
axum::routing::Router::new().route("/openapi.json", axum::routing::get(doc_handler));
let req = Request::builder()
.method("GET")
.uri("/openapi.json")
.body(Body::empty())
.unwrap();
let (status, out) = call(router, req).await;
assert_eq!(status, 200);
let doc: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(doc["info"]["title"], "appt");
assert_eq!(doc["info"]["version"], "9.9");
let ops = &doc["paths"]["/r/{name}"];
assert!(ops["get"].is_object(), "{ops}");
assert!(ops["post"].is_object(), "{ops}");
assert!(ops["get"].get("requestBody").is_none());
assert!(ops["post"]["requestBody"]["content"]["application/json"]["schema"].is_object());
assert_eq!(ops["get"]["summary"], "读取资源");
assert_eq!(ops["get"]["description"], "更长的说明");
let params = ops["get"]["parameters"].as_array().unwrap();
let by = |name: &str| {
params
.iter()
.find(|p| p["name"] == name)
.unwrap_or_else(|| panic!("param {name} missing in {params:?}"))
};
let path_p = by("name");
assert_eq!(path_p["in"], "path");
assert_eq!(path_p["required"], true);
assert_eq!(path_p["description"], "目标名");
let mode_p = by("mode");
assert_eq!(mode_p["in"], "query");
assert_eq!(mode_p["schema"]["type"], "string");
assert_eq!(
mode_p["schema"]["enum"],
serde_json::json!(["fast", "slow"])
);
assert_eq!(mode_p["schema"]["default"], "fast");
let limit_p = by("limit");
assert_eq!(limit_p["in"], "query");
assert_eq!(limit_p["schema"]["type"], "integer");
assert_eq!(limit_p["schema"]["default"], 10);
let token_p = by("X-Token");
assert_eq!(token_p["in"], "header");
assert_eq!(token_p["description"], "令牌");
}
#[tokio::test(flavor = "current_thread")]
async fn lowercase_methods_and_route_conflict_becomes_error() {
use crate::registry::Registry;
use crate::spec::command::Command;
#[derive(xyz_rust::XyzArgs)]
struct LArgs {
#[xyz(desc = "值")]
v: i64,
}
fn echo(_: &Ctx, a: &LArgs) -> errors::Result<i64> {
Ok(a.v)
}
let reg = Registry::new();
Command::new("l.c", echo)
.http(HTTPHints {
method: "get".into(),
path: "/l/c".into(),
..Default::default()
})
.register(®)
.unwrap();
let router = httpapi::router(®, Arc::new(Ctx::new())).unwrap();
let req = Request::builder()
.method("GET")
.uri("/l/c?v=3")
.body(Body::empty())
.unwrap();
let (status, out) = call(router, req).await;
assert_eq!(status, 200);
assert_eq!(out.trim(), "3");
let reg2 = Registry::new();
Command::new("bad.hz", echo)
.http(HTTPHints {
method: "GET".into(),
path: "/healthz".into(),
..Default::default()
})
.register(®2)
.unwrap();
let err = httpapi::router(®2, Arc::new(Ctx::new())).unwrap_err();
assert!(
err.to_string().contains("conflict"),
"want conflict error, got: {err}"
);
}