use axum::{
extract::{FromRequest, FromRequestParts},
response::{IntoResponse, Response},
};
use serde::Serialize;
use crate::Error;
#[derive(Debug, Clone, Copy, Default, FromRequest)]
#[from_request(via(axum::Json), rejection(Error))]
pub struct Json<T>(pub T);
impl<T: Serialize> IntoResponse for Json<T> {
fn into_response(self) -> Response {
axum::Json(self.0).into_response()
}
}
#[derive(Debug, Clone, Copy, Default, FromRequestParts)]
#[from_request(via(axum::extract::Path), rejection(Error))]
pub struct Path<T>(pub T);
#[derive(Debug, Clone, Copy, Default, FromRequestParts)]
#[from_request(via(axum::extract::Query), rejection(Error))]
pub struct Query<T>(pub T);
#[cfg(test)]
mod tests {
use axum::{
Router,
body::{Body, to_bytes},
http::{Request, StatusCode, header::CONTENT_TYPE},
routing::{get, post},
};
use serde::Deserialize;
use tower::ServiceExt;
use super::*;
#[derive(Deserialize, Serialize)]
struct Folder {
name: String,
}
#[derive(Deserialize)]
struct Page {
size: u8,
}
fn app() -> Router {
Router::new()
.route(
"/folders",
post(|Json(folder): Json<Folder>| async move { Json(folder) }),
)
.route(
"/folders/{id}",
get(|Path(id): Path<u32>| async move { id.to_string() }),
)
.route(
"/list",
get(|Query(page): Query<Page>| async move { page.size.to_string() }),
)
}
async fn call(request: Request<Body>) -> (StatusCode, String, String) {
let response = app().oneshot(request).await.unwrap();
let status = response.status();
let kind = response.headers()[CONTENT_TYPE]
.to_str()
.unwrap()
.to_string();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, kind, String::from_utf8(bytes.to_vec()).unwrap())
}
fn post_json(body: &str) -> Request<Body> {
Request::post("/folders")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
fn get_uri(uri: &str) -> Request<Body> {
Request::get(uri).body(Body::empty()).unwrap()
}
#[tokio::test]
async fn a_good_request_passes_through() {
let (status, _, body) = call(post_json(r#"{"name":"a"}"#)).await;
assert_eq!((status, body.as_str()), (StatusCode::OK, r#"{"name":"a"}"#));
assert_eq!(call(get_uri("/folders/7")).await.2, "7");
assert_eq!(call(get_uri("/list?size=3")).await.2, "3");
}
#[tokio::test]
async fn a_path_that_does_not_fit_its_route_is_a_server_fault() {
let app = Router::new().route("/", get(|Path(_): Path<u32>| async {}));
let request = Request::get("/").body(Body::empty()).unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(body.contains("internal_server_error"), "{body}");
assert!(!body.contains("bad_request"), "{body}");
}
#[tokio::test]
async fn every_rejection_is_a_problem_and_quotes_nothing() {
let cases = [
(post_json("{secret"), StatusCode::BAD_REQUEST),
(
post_json(r#"{"name":["secret"]}"#),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
Request::post("/folders")
.body(Body::from("secret"))
.unwrap(),
StatusCode::UNSUPPORTED_MEDIA_TYPE,
),
(get_uri("/folders/secret"), StatusCode::BAD_REQUEST),
(get_uri("/list?size=secret"), StatusCode::BAD_REQUEST),
];
for (request, expected) in cases {
let (status, kind, body) = call(request).await;
assert_eq!(status, expected, "{body}");
assert_eq!(kind, crate::problem::PROBLEM_JSON, "{body}");
assert!(body.contains("detail"), "{body}");
assert!(!body.contains("secret"), "{body}");
let code = expected.canonical_reason().unwrap();
let code = code.to_ascii_lowercase().replace(' ', "_");
assert!(body.contains(&format!("\"code\":\"{code}\"")), "{body}");
}
}
}