#![deny(missing_docs)]
use async_trait::async_trait;
use bytes::Bytes;
use churust_core::{
AppBuilder, Call, Error, FromCall, IntoResponse, Middleware, Next, Phase, Plugin, Response,
Result,
};
use http::header::{CONTENT_LENGTH, CONTENT_TYPE};
use http::{HeaderValue, Method};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct Json<T>(
) = json_wrapper;`.
pub T,
);
#[async_trait]
impl<T> FromCall for Json<T>
where
T: DeserializeOwned + Send,
{
async fn from_call(mut call: Call) -> Result<Self> {
require_json_content_type(&call)?;
let bytes = call.try_receive_bytes().await?;
churust_core::check_body_limit(&call, bytes.len())?;
let value = serde_json::from_slice::<T>(&bytes)
.map_err(|e| Error::bad_request(format!("invalid JSON body: {e}")))?;
Ok(Json(value))
}
}
fn require_json_content_type(call: &Call) -> Result<()> {
let raw = call
.header(http::header::CONTENT_TYPE.as_str())
.unwrap_or("");
let media = raw.split(';').next().unwrap_or("").trim();
let ok = media.eq_ignore_ascii_case("application/json")
|| media
.rsplit_once('+')
.is_some_and(|(_, suffix)| suffix.eq_ignore_ascii_case("json"));
if ok {
return Ok(());
}
Err(Error::new(
http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
"expected a JSON content type",
))
}
impl<T> IntoResponse for Json<T>
where
T: Serialize,
{
fn into_response(self) -> Response {
match serde_json::to_vec(&self.0) {
Ok(bytes) => Response::bytes("application/json", Bytes::from(bytes)),
Err(e) => Error::internal(format!("JSON serialization failed: {e}")).into_response(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ContentNegotiation {
pretty: bool,
}
impl ContentNegotiation {
pub fn new() -> Self {
Self::default()
}
pub fn pretty(mut self, pretty: bool) -> Self {
self.pretty = pretty;
self
}
}
impl Plugin for ContentNegotiation {
fn install(self: Box<Self>, app: &mut AppBuilder) {
app.add_middleware_in(
Phase::Plugins,
Arc::new(JsonErrors {
pretty: self.pretty,
}),
);
}
}
struct JsonErrors {
pretty: bool,
}
#[async_trait]
impl Middleware for JsonErrors {
async fn handle(&self, call: Call, next: Next) -> Response {
let is_head = call.method() == Method::HEAD;
let mut res = next.run(call).await;
let is_error = res.status.is_client_error() || res.status.is_server_error();
let is_text = res
.headers
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.starts_with("text/plain"))
.unwrap_or(false);
if is_error && is_text {
match res.body.as_bytes() {
Some(buffered) if !buffered.is_empty() => {
let msg = String::from_utf8_lossy(buffered).into_owned();
let body = serde_json::json!({ "error": msg, "status": res.status.as_u16() });
let bytes = if self.pretty {
serde_json::to_vec_pretty(&body)
} else {
serde_json::to_vec(&body)
}
.unwrap_or_default();
res.headers.remove(CONTENT_LENGTH);
res.body = churust_core::Body::from(bytes);
res.headers
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
}
Some(_) if is_head => {
res.headers.remove(CONTENT_LENGTH);
res.headers
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
}
_ => {}
}
}
res
}
}
#[async_trait::async_trait]
pub trait CallJson {
async fn receive_json<T: serde::de::DeserializeOwned>(&mut self) -> churust_core::Result<T>;
fn respond_json<T: serde::Serialize + Sync>(&self, value: &T) -> churust_core::Response;
}
#[async_trait::async_trait]
impl CallJson for churust_core::Call {
async fn receive_json<T: serde::de::DeserializeOwned>(&mut self) -> churust_core::Result<T> {
require_json_content_type(self)?;
let bytes = self.try_receive_bytes().await?;
churust_core::check_body_limit(self, bytes.len())?;
serde_json::from_slice::<T>(&bytes)
.map_err(|e| churust_core::Error::bad_request(format!("invalid JSON body: {e}")))
}
fn respond_json<T: serde::Serialize + Sync>(&self, value: &T) -> churust_core::Response {
match serde_json::to_vec(value) {
Ok(body) => churust_core::Response::bytes("application/json", body),
Err(_) => churust_core::Error::internal("failed to serialize response").into_response(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use churust_core::{App, Churust, TestClient};
use http::header::CONTENT_LENGTH;
use http::{Method, StatusCode};
use serde::Deserialize;
#[derive(Serialize, Deserialize)]
struct Echo {
msg: String,
}
fn app() -> App {
Churust::server()
.install(ContentNegotiation::new())
.routing(|r| {
r.post("/echo", |Json(body): Json<Echo>| async move { Json(body) });
r.get("/boom", |_c: Call| async {
Err::<&str, _>(Error::bad_request("nope"))
});
})
.build()
}
#[tokio::test]
async fn json_round_trips_through_handler() {
let client = TestClient::new(app());
let res = client
.post("/echo")
.header("content-type", "application/json")
.body(r#"{"msg":"hi"}"#)
.send()
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.header("content-type"), Some("application/json"));
let parsed: Echo = serde_json::from_slice(res.body_bytes()).unwrap();
assert_eq!(parsed.msg, "hi");
}
#[tokio::test]
async fn invalid_json_is_400() {
let client = TestClient::new(app());
let res = client
.post("/echo")
.header("content-type", "application/json")
.body("not json")
.send()
.await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn errors_render_as_json() {
let client = TestClient::new(app());
let res = client.get("/boom").send().await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
assert_eq!(res.header("content-type"), Some("application/json"));
let v: serde_json::Value = serde_json::from_slice(res.body_bytes()).unwrap();
assert_eq!(v["error"], "nope");
assert_eq!(v["status"], 400);
}
#[tokio::test]
async fn a_head_error_reply_agrees_with_the_get_it_stands_in_for() {
let client = TestClient::new(app());
let get = client.get("/boom").send().await;
let head = client.request(Method::HEAD, "/boom").send().await;
assert_eq!(head.status(), get.status());
assert_eq!(
head.text(),
"",
"a HEAD response must not carry a body at all"
);
assert_eq!(
head.header("content-type"),
get.header("content-type"),
"HEAD advertised a different media type than the GET it stands in for"
);
if let Some(len) = head.header(CONTENT_LENGTH.as_str()) {
assert_eq!(
len.parse::<usize>().unwrap(),
get.body_bytes().len(),
"HEAD promised a size the GET does not deliver"
);
}
}
}