use axum::{
body::Bytes,
extract::{FromRequest, FromRequestParts, Request},
http::{header, request::Parts},
};
use serde::de::DeserializeOwned;
use serde_json::json;
use crate::error::{ApiError, ValidationDetails};
pub struct LoginForm<T>(pub T);
impl<T, S> FromRequest<S> for LoginForm<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
match axum::extract::Form::<T>::from_request(req, state).await {
Ok(axum::extract::Form(value)) => Ok(LoginForm(value)),
Err(_) => Err(ApiError::LoginBadCredentials),
}
}
}
pub struct Json<T>(pub T);
impl<T, S> FromRequest<S> for Json<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let content_type = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_lowercase();
if !content_type.contains("application/json") {
return Err(ApiError::Validation(ValidationDetails {
detail: json!([{
"loc": ["headers", "content-type"],
"msg": "content-type must be application/json",
"type": "value_error"
}]),
body: None,
}));
}
let bytes = Bytes::from_request(req, state).await.map_err(|e| {
ApiError::Validation(ValidationDetails {
detail: json!([{"loc": ["body"], "msg": e.to_string(), "type": "read_error"}]),
body: None,
})
})?;
match serde_json::from_slice::<T>(&bytes) {
Ok(value) => Ok(Json(value)),
Err(err) => {
let raw_body = serde_json::from_slice::<serde_json::Value>(&bytes).ok();
Err(ApiError::Validation(ValidationDetails {
detail: json!([{
"loc": ["body"],
"msg": err.to_string(),
"type": "value_error.json_parse"
}]),
body: raw_body,
}))
}
}
}
}
pub struct Query<T>(pub T);
impl<T, S> FromRequestParts<S> for Query<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let raw = parts.uri.query().unwrap_or("");
let de = serde_urlencoded::Deserializer::new(form_urlencoded::parse(raw.as_bytes()));
match serde_path_to_error::deserialize::<_, T>(de) {
Ok(value) => Ok(Query(value)),
Err(err) => {
let path = err.path().to_string();
let inner_msg = err.into_inner().to_string();
let loc = if path.is_empty() || path == "." {
json!(["query"])
} else {
let leaf = path.rsplit('.').next().unwrap_or(path.as_str());
json!(["query", leaf])
};
Err(ApiError::Validation(ValidationDetails {
detail: json!([{
"loc": loc,
"msg": inner_msg,
"type": "value_error"
}]),
body: None,
}))
}
}
}
}
pub use Query as ValidatedQuery;
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test code — panics are acceptable failures"
)]
mod tests {
use super::*;
use axum::{
Router,
body::{Body, to_bytes},
http::{Request, StatusCode},
routing::{get, post},
};
use serde::Deserialize;
use tower::ServiceExt;
#[derive(Deserialize)]
struct Payload {
name: String,
}
async fn handler(Json(p): Json<Payload>) -> String {
p.name
}
fn app() -> Router {
Router::new().route("/", post(handler))
}
#[tokio::test]
async fn test_missing_required_field_yields_validation_error() {
let req = Request::builder()
.method("POST")
.uri("/")
.header("content-type", "application/json")
.body(Body::from(r#"{"other": "value"}"#))
.expect("request");
let resp = app().oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("bytes");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert!(body["detail"].is_array(), "detail should be array: {body}");
}
#[tokio::test]
async fn test_wrong_content_type_yields_validation_error() {
let req = Request::builder()
.method("POST")
.uri("/")
.header("content-type", "text/plain")
.body(Body::from(r#"{"name": "test"}"#))
.expect("request");
let resp = app().oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_valid_json_succeeds() {
let req = Request::builder()
.method("POST")
.uri("/")
.header("content-type", "application/json")
.body(Body::from(r#"{"name": "alice"}"#))
.expect("request");
let resp = app().oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
}
#[derive(Deserialize, Default)]
enum TestOrderBy {
#[default]
#[serde(rename = "last_activity_at")]
LastActivityAt,
#[serde(rename = "started_at")]
StartedAt,
}
#[derive(Deserialize)]
struct TestQuery {
#[serde(default)]
order_by: TestOrderBy,
#[serde(default = "default_limit")]
limit: u32,
}
fn default_limit() -> u32 {
50
}
async fn query_handler(Query(q): Query<TestQuery>) -> String {
format!(
"limit={} ord={}",
q.limit,
matches!(q.order_by, TestOrderBy::LastActivityAt)
)
}
fn query_app() -> Router {
Router::new().route("/", get(query_handler))
}
#[tokio::test]
async fn valid_query_succeeds() {
let req = Request::builder()
.method("GET")
.uri("/?order_by=started_at&limit=42")
.body(Body::empty())
.expect("request");
let resp = query_app().oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn unknown_order_by_returns_400_with_python_envelope() {
let req = Request::builder()
.method("GET")
.uri("/?order_by=banana")
.body(Body::empty())
.expect("request");
let resp = query_app().oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("bytes");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert!(body["detail"].is_array(), "detail should be array: {body}");
let entry = &body["detail"][0];
let loc = entry["loc"].as_array().expect("loc array");
assert_eq!(loc[0], "query");
assert_eq!(loc[1], "order_by", "loc should target order_by: {body}");
let ty = entry["type"].as_str().expect("type str");
assert!(
ty.ends_with("value_error"),
"type should be value_error: {ty}"
);
}
#[tokio::test]
async fn out_of_range_limit_returns_400_with_python_envelope() {
let req = Request::builder()
.method("GET")
.uri("/?limit=-1")
.body(Body::empty())
.expect("request");
let resp = query_app().oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("bytes");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert!(body["detail"].is_array());
let entry = &body["detail"][0];
let loc = entry["loc"].as_array().expect("loc array");
assert_eq!(loc[0], "query");
assert_eq!(loc[1], "limit", "loc should target limit: {body}");
let ty = entry["type"].as_str().expect("type str");
assert!(ty.ends_with("value_error"));
}
}