From a29018f98c9175f03df522af5f4e803d5e445c43 Mon Sep 17 00:00:00 2001 From: future-highway <113635015+future-highway@users.noreply.github.com> Date: Sat, 16 Dec 2023 14:34:52 -0500 Subject: [PATCH 1/9] Add `JsonDeserializer` extractor --- axum/src/extract/mod.rs | 3 + axum/src/json.rs | 270 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 246 insertions(+), 27 deletions(-) diff --git a/axum/src/extract/mod.rs b/axum/src/extract/mod.rs index 719083d11f..53461409c8 100644 --- a/axum/src/extract/mod.rs +++ b/axum/src/extract/mod.rs @@ -42,6 +42,9 @@ pub use self::connect_info::ConnectInfo; #[cfg(feature = "json")] pub use crate::Json; +#[cfg(feature = "json")] +pub use crate::json::JsonDeserializer; + #[doc(no_inline)] pub use crate::Extension; diff --git a/axum/src/json.rs b/axum/src/json.rs index ebff242dd4..8107771615 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -7,17 +7,18 @@ use http::{ header::{self, HeaderMap, HeaderValue}, StatusCode, }; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::marker::PhantomData; /// JSON Extractor / Response. /// /// When used as an extractor, it can deserialize request bodies into some type that -/// implements [`serde::Deserialize`]. The request will be rejected (and a [`JsonRejection`] will +/// implements [`serde::de::DeserializeOwned`]. The request will be rejected (and a [`JsonRejection`] will /// be returned) if: /// /// - The request doesn't have a `Content-Type: application/json` (or similar) header. /// - The body doesn't contain syntactically valid JSON. -/// - The body contains syntactically valid JSON but it couldn't be deserialized into the target +/// - The body contains syntactically valid JSON, but it couldn't be deserialized into the target /// type. /// - Buffering the request body fails. /// @@ -135,6 +136,32 @@ fn json_content_type(headers: &HeaderMap) -> bool { is_json_content_type } +fn json_from_bytes<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Result { + let deserializer = &mut serde_json::Deserializer::from_slice(bytes); + + match serde_path_to_error::deserialize(deserializer) { + Ok(value) => Ok(value), + Err(err) => { + let rejection = match err.inner().classify() { + serde_json::error::Category::Data => JsonDataError::from_err(err).into(), + serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { + JsonSyntaxError::from_err(err).into() + } + serde_json::error::Category::Io => { + if cfg!(debug_assertions) { + // we don't use `serde_json::from_reader` and instead always buffer + // bodies first, so we shouldn't encounter any IO errors + unreachable!() + } else { + JsonSyntaxError::from_err(err).into() + } + } + }; + Err(rejection) + } + } +} + axum_core::__impl_deref!(Json); impl From for Json { @@ -151,30 +178,7 @@ where /// but special cases may require first extracting a `Request` into `Bytes` then optionally /// constructing a `Json`. pub fn from_bytes(bytes: &[u8]) -> Result { - let deserializer = &mut serde_json::Deserializer::from_slice(bytes); - - let value = match serde_path_to_error::deserialize(deserializer) { - Ok(value) => value, - Err(err) => { - let rejection = match err.inner().classify() { - serde_json::error::Category::Data => JsonDataError::from_err(err).into(), - serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { - JsonSyntaxError::from_err(err).into() - } - serde_json::error::Category::Io => { - if cfg!(debug_assertions) { - // we don't use `serde_json::from_reader` and instead always buffer - // bodies first, so we shouldn't encounter any IO errors - unreachable!() - } else { - JsonSyntaxError::from_err(err).into() - } - } - }; - return Err(rejection); - } - }; - + let value = json_from_bytes(bytes)?; Ok(Json(value)) } } @@ -209,12 +213,119 @@ where } } +/// JSON Extractor for zero-copy deserialization. +/// +/// Deserialize request bodies into some type that implements [`serde::Deserialize<'de>`]. +/// Parsing JSON is delayed until [`deserialize`](JsonDeserializer::deserialize) is called. +/// If the type implements [`serde::de::DeserializeOwned`], the [`Json`] extractor should +/// be preferred. +/// +/// The request will be rejected (and a [`JsonRejection`] will be returned) if: +/// +/// - The request doesn't have a `Content-Type: application/json` (or similar) header. +/// - Buffering the request body fails. +/// +/// Additionally, a `JsonRejection` error will be returned, when calling `deserialize` if: +/// +/// - The body doesn't contain syntactically valid JSON. +/// - The body contains syntactically valid JSON, but it couldn't be deserialized into the target +/// type. +/// - Attempting to deserialize escaped JSON into a type that must be borrowed (e.g. `&'a str`). +/// +/// ⚠️ `serde` will implicitly try to borrow for `&str` and `&[u8]` types, but will error if the +/// input contains escaped characters. Use `Cow<'a, str>` or `Cow<'a, [u8]>`, with the +/// `#[serde(borrow)]` attribute, to allow serde to fall back to an owned type when encountering +/// escaped characters. +/// +/// ⚠️ Since parsing JSON requires consuming the request body, the `Json` extractor must be +/// *last* if there are multiple extractors in a handler. +/// See ["the order of extractors"][order-of-extractors] +/// +/// # Example +/// +/// ```rust,no_run +/// use axum::{ +/// extract, +/// routing::post, +/// Router, +/// response::{IntoResponse, Response} +/// }; +/// use serde::Deserialize; +/// use std::borrow::Cow; +/// use http::StatusCode; +/// +/// #[derive(Deserialize)] +/// struct Data<'a> { +/// #[serde(borrow)] +/// borrow_text: Cow<'a, str>, +/// #[serde(borrow)] +/// borrow_bytes: Cow<'a, [u8]>, +/// borrow_dangerous: &'a str, +/// not_borrowed: String, +/// } +/// +/// async fn upload(deserializer: extract::JsonDeserializer>) -> Response { +/// let data = match deserializer.deserialize() { +/// Ok(data) => data, +/// Err(e) => return e.into_response(), +/// }; +/// +/// // payload is a `Data` with borrowed data from `deserializer`, +/// // which owns the request body (`Bytes`). +/// +/// StatusCode::OK.into_response() +/// } +/// +/// let app = Router::new().route("/upload", post(upload)); +/// # let _: Router = app; +/// ``` +#[derive(Debug, Clone, Default)] +#[cfg_attr(docsrs, doc(cfg(feature = "json")))] +pub struct JsonDeserializer { + bytes: Bytes, + _marker: PhantomData, +} + +#[async_trait] +impl FromRequest for JsonDeserializer +where + T: Deserialize<'static>, + S: Send + Sync, +{ + type Rejection = JsonRejection; + + async fn from_request(req: Request, state: &S) -> Result { + if json_content_type(req.headers()) { + let bytes = Bytes::from_request(req, state).await?; + Ok(Self { + bytes, + _marker: PhantomData, + }) + } else { + Err(MissingJsonContentType.into()) + } + } +} + +impl<'de, 'a: 'de, T> JsonDeserializer +where + T: Deserialize<'de>, +{ + /// Deserialize the request body into the target type. + /// See [`JsonDeserializer`] for more details. + pub fn deserialize(&'a self) -> Result { + let value = json_from_bytes(&self.bytes)?; + Ok(value) + } +} + #[cfg(test)] mod tests { use super::*; use crate::{routing::post, test_helpers::*, Router}; use serde::Deserialize; use serde_json::{json, Value}; + use std::borrow::Cow; #[crate::test] async fn deserialize_body() { @@ -232,6 +343,111 @@ mod tests { assert_eq!(body, "bar"); } + #[crate::test] + async fn deserializer_deserialize_body() { + #[derive(Debug, Deserialize)] + struct Input<'a> { + #[serde(borrow)] + foo: Cow<'a, str>, + } + + async fn handler(deserializer: JsonDeserializer>) -> Response { + match deserializer.deserialize() { + Ok(input) => { + assert!(matches!(input.foo, Cow::Borrowed(_))); + input.foo.into_owned().into_response() + } + Err(e) => e.into_response(), + } + } + + let app = Router::new().route("/", post(handler)); + + let client = TestClient::new(app); + let res = client.post("/").json(&json!({ "foo": "bar" })).send().await; + let body = res.text().await; + + assert_eq!(body, "bar"); + } + + #[crate::test] + async fn deserializer_deserialize_body_escaped_to_cow() { + #[derive(Debug, Deserialize)] + struct Input<'a> { + #[serde(borrow)] + foo: Cow<'a, str>, + } + + async fn handler(deserializer: JsonDeserializer>) -> Response { + match deserializer.deserialize() { + Ok(Input { foo }) => { + let Cow::Owned(foo) = foo else { + panic!("Deserializer is expected to fallback to Cow::Owned when encountering escaped characters") + }; + + foo.into_response() + } + Err(e) => e.into_response(), + } + } + + let app = Router::new().route("/", post(handler)); + + let client = TestClient::new(app); + + // The escaped characters prevent serde_json from borrowing. + let res = client + .post("/") + .json(&json!({ "foo": "\"bar\"" })) + .send() + .await; + + let body = res.text().await; + + assert_eq!(body, r#""bar""#); + } + + #[crate::test] + async fn deserializer_deserialize_body_escaped_to_str() { + #[derive(Debug, Deserialize)] + struct Input<'a> { + // Explicit `#[serde(borrow)]` attribute is not required for `&str` or &[u8]. + // See: https://serde.rs/lifetimes.html#borrowing-data-in-a-derived-impl + foo: &'a str, + } + + async fn route_fn(deserializer: JsonDeserializer>) -> Response { + match deserializer.deserialize() { + Ok(Input { foo }) => foo.to_owned().into_response(), + Err(e) => e.into_response(), + } + } + + let app = Router::new().route("/", post(route_fn)); + + let client = TestClient::new(app); + + let res = client + .post("/") + .json(&json!({ "foo": "good" })) + .send() + .await; + let body = res.text().await; + assert_eq!(body, "good"); + + let res = client + .post("/") + .json(&json!({ "foo": "\"bad\"" })) + .send() + .await; + assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body_text = res.text().await; + assert_eq!( + body_text, + "Failed to deserialize the JSON body into the target type: foo: invalid type: string \"\\\"bad\\\"\", expected a borrowed string at line 1 column 16" + ); + } + #[crate::test] async fn consume_body_to_json_requires_json_content_type() { #[derive(Debug, Deserialize)] From bbd9ce735c170e81a8dd17447a60ecd84128dfbf Mon Sep 17 00:00:00 2001 From: future-highway <113635015+future-highway@users.noreply.github.com> Date: Sun, 17 Dec 2023 09:59:29 -0500 Subject: [PATCH 2/9] Revert "Add `JsonDeserializer` extractor" This reverts commit a29018f98c9175f03df522af5f4e803d5e445c43. --- axum/src/extract/mod.rs | 3 - axum/src/json.rs | 270 ++++------------------------------------ 2 files changed, 27 insertions(+), 246 deletions(-) diff --git a/axum/src/extract/mod.rs b/axum/src/extract/mod.rs index 53461409c8..719083d11f 100644 --- a/axum/src/extract/mod.rs +++ b/axum/src/extract/mod.rs @@ -42,9 +42,6 @@ pub use self::connect_info::ConnectInfo; #[cfg(feature = "json")] pub use crate::Json; -#[cfg(feature = "json")] -pub use crate::json::JsonDeserializer; - #[doc(no_inline)] pub use crate::Extension; diff --git a/axum/src/json.rs b/axum/src/json.rs index 8107771615..ebff242dd4 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -7,18 +7,17 @@ use http::{ header::{self, HeaderMap, HeaderValue}, StatusCode, }; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use std::marker::PhantomData; +use serde::{de::DeserializeOwned, Serialize}; /// JSON Extractor / Response. /// /// When used as an extractor, it can deserialize request bodies into some type that -/// implements [`serde::de::DeserializeOwned`]. The request will be rejected (and a [`JsonRejection`] will +/// implements [`serde::Deserialize`]. The request will be rejected (and a [`JsonRejection`] will /// be returned) if: /// /// - The request doesn't have a `Content-Type: application/json` (or similar) header. /// - The body doesn't contain syntactically valid JSON. -/// - The body contains syntactically valid JSON, but it couldn't be deserialized into the target +/// - The body contains syntactically valid JSON but it couldn't be deserialized into the target /// type. /// - Buffering the request body fails. /// @@ -136,32 +135,6 @@ fn json_content_type(headers: &HeaderMap) -> bool { is_json_content_type } -fn json_from_bytes<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Result { - let deserializer = &mut serde_json::Deserializer::from_slice(bytes); - - match serde_path_to_error::deserialize(deserializer) { - Ok(value) => Ok(value), - Err(err) => { - let rejection = match err.inner().classify() { - serde_json::error::Category::Data => JsonDataError::from_err(err).into(), - serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { - JsonSyntaxError::from_err(err).into() - } - serde_json::error::Category::Io => { - if cfg!(debug_assertions) { - // we don't use `serde_json::from_reader` and instead always buffer - // bodies first, so we shouldn't encounter any IO errors - unreachable!() - } else { - JsonSyntaxError::from_err(err).into() - } - } - }; - Err(rejection) - } - } -} - axum_core::__impl_deref!(Json); impl From for Json { @@ -178,7 +151,30 @@ where /// but special cases may require first extracting a `Request` into `Bytes` then optionally /// constructing a `Json`. pub fn from_bytes(bytes: &[u8]) -> Result { - let value = json_from_bytes(bytes)?; + let deserializer = &mut serde_json::Deserializer::from_slice(bytes); + + let value = match serde_path_to_error::deserialize(deserializer) { + Ok(value) => value, + Err(err) => { + let rejection = match err.inner().classify() { + serde_json::error::Category::Data => JsonDataError::from_err(err).into(), + serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { + JsonSyntaxError::from_err(err).into() + } + serde_json::error::Category::Io => { + if cfg!(debug_assertions) { + // we don't use `serde_json::from_reader` and instead always buffer + // bodies first, so we shouldn't encounter any IO errors + unreachable!() + } else { + JsonSyntaxError::from_err(err).into() + } + } + }; + return Err(rejection); + } + }; + Ok(Json(value)) } } @@ -213,119 +209,12 @@ where } } -/// JSON Extractor for zero-copy deserialization. -/// -/// Deserialize request bodies into some type that implements [`serde::Deserialize<'de>`]. -/// Parsing JSON is delayed until [`deserialize`](JsonDeserializer::deserialize) is called. -/// If the type implements [`serde::de::DeserializeOwned`], the [`Json`] extractor should -/// be preferred. -/// -/// The request will be rejected (and a [`JsonRejection`] will be returned) if: -/// -/// - The request doesn't have a `Content-Type: application/json` (or similar) header. -/// - Buffering the request body fails. -/// -/// Additionally, a `JsonRejection` error will be returned, when calling `deserialize` if: -/// -/// - The body doesn't contain syntactically valid JSON. -/// - The body contains syntactically valid JSON, but it couldn't be deserialized into the target -/// type. -/// - Attempting to deserialize escaped JSON into a type that must be borrowed (e.g. `&'a str`). -/// -/// ⚠️ `serde` will implicitly try to borrow for `&str` and `&[u8]` types, but will error if the -/// input contains escaped characters. Use `Cow<'a, str>` or `Cow<'a, [u8]>`, with the -/// `#[serde(borrow)]` attribute, to allow serde to fall back to an owned type when encountering -/// escaped characters. -/// -/// ⚠️ Since parsing JSON requires consuming the request body, the `Json` extractor must be -/// *last* if there are multiple extractors in a handler. -/// See ["the order of extractors"][order-of-extractors] -/// -/// # Example -/// -/// ```rust,no_run -/// use axum::{ -/// extract, -/// routing::post, -/// Router, -/// response::{IntoResponse, Response} -/// }; -/// use serde::Deserialize; -/// use std::borrow::Cow; -/// use http::StatusCode; -/// -/// #[derive(Deserialize)] -/// struct Data<'a> { -/// #[serde(borrow)] -/// borrow_text: Cow<'a, str>, -/// #[serde(borrow)] -/// borrow_bytes: Cow<'a, [u8]>, -/// borrow_dangerous: &'a str, -/// not_borrowed: String, -/// } -/// -/// async fn upload(deserializer: extract::JsonDeserializer>) -> Response { -/// let data = match deserializer.deserialize() { -/// Ok(data) => data, -/// Err(e) => return e.into_response(), -/// }; -/// -/// // payload is a `Data` with borrowed data from `deserializer`, -/// // which owns the request body (`Bytes`). -/// -/// StatusCode::OK.into_response() -/// } -/// -/// let app = Router::new().route("/upload", post(upload)); -/// # let _: Router = app; -/// ``` -#[derive(Debug, Clone, Default)] -#[cfg_attr(docsrs, doc(cfg(feature = "json")))] -pub struct JsonDeserializer { - bytes: Bytes, - _marker: PhantomData, -} - -#[async_trait] -impl FromRequest for JsonDeserializer -where - T: Deserialize<'static>, - S: Send + Sync, -{ - type Rejection = JsonRejection; - - async fn from_request(req: Request, state: &S) -> Result { - if json_content_type(req.headers()) { - let bytes = Bytes::from_request(req, state).await?; - Ok(Self { - bytes, - _marker: PhantomData, - }) - } else { - Err(MissingJsonContentType.into()) - } - } -} - -impl<'de, 'a: 'de, T> JsonDeserializer -where - T: Deserialize<'de>, -{ - /// Deserialize the request body into the target type. - /// See [`JsonDeserializer`] for more details. - pub fn deserialize(&'a self) -> Result { - let value = json_from_bytes(&self.bytes)?; - Ok(value) - } -} - #[cfg(test)] mod tests { use super::*; use crate::{routing::post, test_helpers::*, Router}; use serde::Deserialize; use serde_json::{json, Value}; - use std::borrow::Cow; #[crate::test] async fn deserialize_body() { @@ -343,111 +232,6 @@ mod tests { assert_eq!(body, "bar"); } - #[crate::test] - async fn deserializer_deserialize_body() { - #[derive(Debug, Deserialize)] - struct Input<'a> { - #[serde(borrow)] - foo: Cow<'a, str>, - } - - async fn handler(deserializer: JsonDeserializer>) -> Response { - match deserializer.deserialize() { - Ok(input) => { - assert!(matches!(input.foo, Cow::Borrowed(_))); - input.foo.into_owned().into_response() - } - Err(e) => e.into_response(), - } - } - - let app = Router::new().route("/", post(handler)); - - let client = TestClient::new(app); - let res = client.post("/").json(&json!({ "foo": "bar" })).send().await; - let body = res.text().await; - - assert_eq!(body, "bar"); - } - - #[crate::test] - async fn deserializer_deserialize_body_escaped_to_cow() { - #[derive(Debug, Deserialize)] - struct Input<'a> { - #[serde(borrow)] - foo: Cow<'a, str>, - } - - async fn handler(deserializer: JsonDeserializer>) -> Response { - match deserializer.deserialize() { - Ok(Input { foo }) => { - let Cow::Owned(foo) = foo else { - panic!("Deserializer is expected to fallback to Cow::Owned when encountering escaped characters") - }; - - foo.into_response() - } - Err(e) => e.into_response(), - } - } - - let app = Router::new().route("/", post(handler)); - - let client = TestClient::new(app); - - // The escaped characters prevent serde_json from borrowing. - let res = client - .post("/") - .json(&json!({ "foo": "\"bar\"" })) - .send() - .await; - - let body = res.text().await; - - assert_eq!(body, r#""bar""#); - } - - #[crate::test] - async fn deserializer_deserialize_body_escaped_to_str() { - #[derive(Debug, Deserialize)] - struct Input<'a> { - // Explicit `#[serde(borrow)]` attribute is not required for `&str` or &[u8]. - // See: https://serde.rs/lifetimes.html#borrowing-data-in-a-derived-impl - foo: &'a str, - } - - async fn route_fn(deserializer: JsonDeserializer>) -> Response { - match deserializer.deserialize() { - Ok(Input { foo }) => foo.to_owned().into_response(), - Err(e) => e.into_response(), - } - } - - let app = Router::new().route("/", post(route_fn)); - - let client = TestClient::new(app); - - let res = client - .post("/") - .json(&json!({ "foo": "good" })) - .send() - .await; - let body = res.text().await; - assert_eq!(body, "good"); - - let res = client - .post("/") - .json(&json!({ "foo": "\"bad\"" })) - .send() - .await; - assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); - let body_text = res.text().await; - assert_eq!( - body_text, - "Failed to deserialize the JSON body into the target type: foo: invalid type: string \"\\\"bad\\\"\", expected a borrowed string at line 1 column 16" - ); - } - #[crate::test] async fn consume_body_to_json_requires_json_content_type() { #[derive(Debug, Deserialize)] From 38bcab01541a2826da441cbd9d6d6892b28a00ad Mon Sep 17 00:00:00 2001 From: future-highway <113635015+future-highway@users.noreply.github.com> Date: Sun, 17 Dec 2023 11:53:53 -0500 Subject: [PATCH 3/9] Add `JsonDeserializer` extractor to axum-extra --- axum-extra/Cargo.toml | 1 + axum-extra/src/extract/json_deserializer.rs | 231 ++++++++++++++++++++ axum-extra/src/extract/mod.rs | 6 + axum/Cargo.toml | 3 + axum/src/extract/mod.rs | 13 ++ axum/src/json.rs | 61 +++--- 6 files changed, 288 insertions(+), 27 deletions(-) create mode 100644 axum-extra/src/extract/json_deserializer.rs diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index d4bb1f4141..f40e3cf744 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -21,6 +21,7 @@ cookie-signed = ["cookie", "cookie?/signed"] cookie-key-expansion = ["cookie", "cookie?/key-expansion"] erased-json = ["dep:serde_json"] form = ["dep:serde_html_form"] +json-deserializer = ["dep:serde_json", "axum/json", "axum/__private"] json-lines = [ "dep:serde_json", "dep:tokio-util", diff --git a/axum-extra/src/extract/json_deserializer.rs b/axum-extra/src/extract/json_deserializer.rs new file mode 100644 index 0000000000..092c5d6e5d --- /dev/null +++ b/axum-extra/src/extract/json_deserializer.rs @@ -0,0 +1,231 @@ +use axum::async_trait; +use axum::extract::{json_helpers::*, rejection::JsonRejection, FromRequest, Request}; +use bytes::Bytes; +use serde::Deserialize; +use std::marker::PhantomData; + +/// JSON Extractor for zero-copy deserialization. +/// +/// Deserialize request bodies into some type that implements [`serde::Deserialize<'de>`]. +/// Parsing JSON is delayed until [`deserialize`](JsonDeserializer::deserialize) is called. +/// If the type implements [`serde::de::DeserializeOwned`], the [`Json`] extractor should +/// be preferred. +/// +/// The request will be rejected (and a [`JsonRejection`] will be returned) if: +/// +/// - The request doesn't have a `Content-Type: application/json` (or similar) header. +/// - Buffering the request body fails. +/// +/// Additionally, a `JsonRejection` error will be returned, when calling `deserialize` if: +/// +/// - The body doesn't contain syntactically valid JSON. +/// - The body contains syntactically valid JSON, but it couldn't be deserialized into the target +/// type. +/// - Attempting to deserialize escaped JSON into a type that must be borrowed (e.g. `&'a str`). +/// +/// ⚠️ `serde` will implicitly try to borrow for `&str` and `&[u8]` types, but will error if the +/// input contains escaped characters. Use `Cow<'a, str>` or `Cow<'a, [u8]>`, with the +/// `#[serde(borrow)]` attribute, to allow serde to fall back to an owned type when encountering +/// escaped characters. +/// +/// ⚠️ Since parsing JSON requires consuming the request body, the `Json` extractor must be +/// *last* if there are multiple extractors in a handler. +/// See ["the order of extractors"][order-of-extractors] +/// +/// # Example +/// +/// ```rust,no_run +/// use axum::{ +/// routing::post, +/// Router, +/// response::{IntoResponse, Response} +/// }; +/// use axum_extra::extract::JsonDeserializer; +/// use serde::Deserialize; +/// use std::borrow::Cow; +/// use http::StatusCode; +/// +/// #[derive(Deserialize)] +/// struct Data<'a> { +/// #[serde(borrow)] +/// borrow_text: Cow<'a, str>, +/// #[serde(borrow)] +/// borrow_bytes: Cow<'a, [u8]>, +/// borrow_dangerous: &'a str, +/// not_borrowed: String, +/// } +/// +/// async fn upload(deserializer: JsonDeserializer>) -> Response { +/// let data = match deserializer.deserialize() { +/// Ok(data) => data, +/// Err(e) => return e.into_response(), +/// }; +/// +/// // payload is a `Data` with borrowed data from `deserializer`, +/// // which owns the request body (`Bytes`). +/// +/// StatusCode::OK.into_response() +/// } +/// +/// let app = Router::new().route("/upload", post(upload)); +/// # let _: Router = app; +/// ``` +#[derive(Debug, Clone, Default)] +#[cfg_attr(docsrs, doc(cfg(feature = "json")))] +pub struct JsonDeserializer { + bytes: Bytes, + _marker: PhantomData, +} + +#[async_trait] +impl FromRequest for JsonDeserializer +where + T: Deserialize<'static>, + S: Send + Sync, +{ + type Rejection = JsonRejection; + + async fn from_request(req: Request, state: &S) -> Result { + if json_content_type(req.headers()) { + let bytes = Bytes::from_request(req, state).await?; + Ok(Self { + bytes, + _marker: PhantomData, + }) + } else { + Err(missing_json_content_type().into()) + } + } +} + +impl<'de, 'a: 'de, T> JsonDeserializer +where + T: Deserialize<'de>, +{ + /// Deserialize the request body into the target type. + /// See [`JsonDeserializer`] for more details. + pub fn deserialize(&'a self) -> Result { + let value = json_from_bytes(&self.bytes)?; + Ok(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::*; + use axum::{ + response::{IntoResponse, Response}, + routing::post, + Router, + }; + use http::StatusCode; + use serde::Deserialize; + use serde_json::json; + use std::borrow::Cow; + + #[tokio::test] + async fn deserialize_body() { + #[derive(Debug, Deserialize)] + struct Input<'a> { + #[serde(borrow)] + foo: Cow<'a, str>, + } + + async fn handler(deserializer: JsonDeserializer>) -> Response { + match deserializer.deserialize() { + Ok(input) => { + assert!(matches!(input.foo, Cow::Borrowed(_))); + input.foo.into_owned().into_response() + } + Err(e) => e.into_response(), + } + } + + let app = Router::new().route("/", post(handler)); + + let client = TestClient::new(app); + let res = client.post("/").json(&json!({ "foo": "bar" })).send().await; + let body = res.text().await; + + assert_eq!(body, "bar"); + } + + #[crate::test] + async fn deserialize_body_escaped_to_cow() { + #[derive(Debug, Deserialize)] + struct Input<'a> { + #[serde(borrow)] + foo: Cow<'a, str>, + } + + async fn handler(deserializer: JsonDeserializer>) -> Response { + match deserializer.deserialize() { + Ok(Input { foo }) => { + let Cow::Owned(foo) = foo else { + panic!("Deserializer is expected to fallback to Cow::Owned when encountering escaped characters") + }; + + foo.into_response() + } + Err(e) => e.into_response(), + } + } + + let app = Router::new().route("/", post(handler)); + + let client = TestClient::new(app); + + // The escaped characters prevent serde_json from borrowing. + let res = client + .post("/") + .json(&json!({ "foo": "\"bar\"" })) + .send() + .await; + + let body = res.text().await; + + assert_eq!(body, r#""bar""#); + } + + #[crate::test] + async fn deserialize_body_escaped_to_str() { + #[derive(Debug, Deserialize)] + struct Input<'a> { + // Explicit `#[serde(borrow)]` attribute is not required for `&str` or &[u8]. + // See: https://serde.rs/lifetimes.html#borrowing-data-in-a-derived-impl + foo: &'a str, + } + + async fn handler(deserializer: JsonDeserializer>) -> Response { + match deserializer.deserialize() { + Ok(Input { foo }) => foo.to_owned().into_response(), + Err(e) => e.into_response(), + } + } + + let app = Router::new().route("/", post(handler)); + + let client = TestClient::new(app); + + let res = client + .post("/") + .json(&json!({ "foo": "good" })) + .send() + .await; + let body = res.text().await; + assert_eq!(body, "good"); + + let res = client + .post("/") + .json(&json!({ "foo": "\"bad\"" })) + .send() + .await; + assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body_text = res.text().await; + assert_eq!( + body_text, + "Failed to deserialize the JSON body into the target type: foo: invalid type: string \"\\\"bad\\\"\", expected a borrowed string at line 1 column 16" + ); + } +} diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index 8435fc8422..eb8ea5da91 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -10,6 +10,9 @@ mod form; #[cfg(feature = "cookie")] pub mod cookie; +#[cfg(feature = "json-deserializer")] +mod json_deserializer; + #[cfg(feature = "query")] mod query; @@ -36,6 +39,9 @@ pub use self::query::{OptionalQuery, OptionalQueryRejection, Query, QueryRejecti #[cfg(feature = "multipart")] pub use self::multipart::Multipart; +#[cfg(feature = "json-deserializer")] +pub use self::json_deserializer::JsonDeserializer; + #[cfg(feature = "json-lines")] #[doc(no_inline)] pub use crate::json_lines::JsonLines; diff --git a/axum/Cargo.toml b/axum/Cargo.toml index f8e1aac9b0..c5fbbf5765 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -30,6 +30,8 @@ ws = ["dep:hyper", "tokio", "dep:tokio-tungstenite", "dep:sha1", "dep:base64"] # Required for intra-doc links to resolve correctly __private_docs = ["tower/full", "dep:tower-http"] +__private = ["dep:visibility"] + [dependencies] async-trait = "0.1.67" axum-core = { path = "../axum-core", version = "0.4.1" } @@ -63,6 +65,7 @@ sha1 = { version = "0.10", optional = true } tokio = { package = "tokio", version = "1.25.0", features = ["time"], optional = true } tokio-tungstenite = { version = "0.20", optional = true } tracing = { version = "0.1", default-features = false, optional = true } +visibility = { version = "0.1.0", optional = true } [dependencies.tower-http] version = "0.5.0" diff --git a/axum/src/extract/mod.rs b/axum/src/extract/mod.rs index 719083d11f..76fec6953c 100644 --- a/axum/src/extract/mod.rs +++ b/axum/src/extract/mod.rs @@ -42,6 +42,19 @@ pub use self::connect_info::ConnectInfo; #[cfg(feature = "json")] pub use crate::Json; +#[cfg(all(feature = "json", feature = "__private"))] +pub mod json_helpers { + #![allow(missing_docs)] + + use crate::extract::rejection::MissingJsonContentType; + pub use crate::json::{json_content_type, json_from_bytes}; + + #[inline(always)] + pub fn missing_json_content_type() -> MissingJsonContentType { + MissingJsonContentType + } +} + #[doc(no_inline)] pub use crate::Extension; diff --git a/axum/src/json.rs b/axum/src/json.rs index ebff242dd4..8d9aca3b59 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -7,17 +7,17 @@ use http::{ header::{self, HeaderMap, HeaderValue}, StatusCode, }; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; /// JSON Extractor / Response. /// /// When used as an extractor, it can deserialize request bodies into some type that -/// implements [`serde::Deserialize`]. The request will be rejected (and a [`JsonRejection`] will +/// implements [`serde::de::DeserializeOwned`]. The request will be rejected (and a [`JsonRejection`] will /// be returned) if: /// /// - The request doesn't have a `Content-Type: application/json` (or similar) header. /// - The body doesn't contain syntactically valid JSON. -/// - The body contains syntactically valid JSON but it couldn't be deserialized into the target +/// - The body contains syntactically valid JSON, but it couldn't be deserialized into the target /// type. /// - Buffering the request body fails. /// @@ -110,6 +110,8 @@ where } } +#[cfg_attr(feature = "__private", visibility::make(pub))] +#[cfg_attr(feature = "__private", allow(missing_docs))] fn json_content_type(headers: &HeaderMap) -> bool { let content_type = if let Some(content_type) = headers.get(header::CONTENT_TYPE) { content_type @@ -135,6 +137,34 @@ fn json_content_type(headers: &HeaderMap) -> bool { is_json_content_type } +#[cfg_attr(feature = "__private", visibility::make(pub))] +#[cfg_attr(feature = "__private", allow(missing_docs))] +fn json_from_bytes<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Result { + let deserializer = &mut serde_json::Deserializer::from_slice(bytes); + + match serde_path_to_error::deserialize(deserializer) { + Ok(value) => Ok(value), + Err(err) => { + let rejection = match err.inner().classify() { + serde_json::error::Category::Data => JsonDataError::from_err(err).into(), + serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { + JsonSyntaxError::from_err(err).into() + } + serde_json::error::Category::Io => { + if cfg!(debug_assertions) { + // we don't use `serde_json::from_reader` and instead always buffer + // bodies first, so we shouldn't encounter any IO errors + unreachable!() + } else { + JsonSyntaxError::from_err(err).into() + } + } + }; + Err(rejection) + } + } +} + axum_core::__impl_deref!(Json); impl From for Json { @@ -151,30 +181,7 @@ where /// but special cases may require first extracting a `Request` into `Bytes` then optionally /// constructing a `Json`. pub fn from_bytes(bytes: &[u8]) -> Result { - let deserializer = &mut serde_json::Deserializer::from_slice(bytes); - - let value = match serde_path_to_error::deserialize(deserializer) { - Ok(value) => value, - Err(err) => { - let rejection = match err.inner().classify() { - serde_json::error::Category::Data => JsonDataError::from_err(err).into(), - serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { - JsonSyntaxError::from_err(err).into() - } - serde_json::error::Category::Io => { - if cfg!(debug_assertions) { - // we don't use `serde_json::from_reader` and instead always buffer - // bodies first, so we shouldn't encounter any IO errors - unreachable!() - } else { - JsonSyntaxError::from_err(err).into() - } - } - }; - return Err(rejection); - } - }; - + let value = json_from_bytes(bytes)?; Ok(Json(value)) } } From d3ce7b8fdcd47f78f61c8b6a07346fc938547625 Mon Sep 17 00:00:00 2001 From: future-highway <113635015+future-highway@users.noreply.github.com> Date: Sun, 17 Dec 2023 12:12:50 -0500 Subject: [PATCH 4/9] `JsonDeserializer` docs --- axum-extra/src/extract/json_deserializer.rs | 6 +++++- axum-extra/src/lib.rs | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/axum-extra/src/extract/json_deserializer.rs b/axum-extra/src/extract/json_deserializer.rs index 092c5d6e5d..2ddfdd2010 100644 --- a/axum-extra/src/extract/json_deserializer.rs +++ b/axum-extra/src/extract/json_deserializer.rs @@ -8,7 +8,7 @@ use std::marker::PhantomData; /// /// Deserialize request bodies into some type that implements [`serde::Deserialize<'de>`]. /// Parsing JSON is delayed until [`deserialize`](JsonDeserializer::deserialize) is called. -/// If the type implements [`serde::de::DeserializeOwned`], the [`Json`] extractor should +/// If the type implements [`serde::de::DeserializeOwned`], the [`Json`](axum::Json) extractor should /// be preferred. /// /// The request will be rejected (and a [`JsonRejection`] will be returned) if: @@ -32,6 +32,10 @@ use std::marker::PhantomData; /// *last* if there are multiple extractors in a handler. /// See ["the order of extractors"][order-of-extractors] /// +/// [order-of-extractors]: axum::extract#the-order-of-extractors +/// +/// See [`JsonRejection`] for more details. +/// /// # Example /// /// ```rust,no_run diff --git a/axum-extra/src/lib.rs b/axum-extra/src/lib.rs index 12aa2801fa..eb93b0a312 100644 --- a/axum-extra/src/lib.rs +++ b/axum-extra/src/lib.rs @@ -16,6 +16,7 @@ //! `cookie-key-expansion` | Enables the `Key::derive_from` method | No //! `erased-json` | Enables the `ErasedJson` response | No //! `form` | Enables the `Form` extractor | No +//! `json-deserializer` | Enables the `JsonDeserializer` extractor | No //! `json-lines` | Enables the `JsonLines` extractor and response | No //! `multipart` | Enables the `Multipart` extractor | No //! `protobuf` | Enables the `Protobuf` extractor and response | No From 5a3e1240109e9084cb3ff245a0b266f6e5f282bc Mon Sep 17 00:00:00 2001 From: future-highway <113635015+future-highway@users.noreply.github.com> Date: Sun, 17 Dec 2023 15:07:41 -0500 Subject: [PATCH 5/9] Prefer code duplication over visibility toggle --- axum-extra/Cargo.toml | 3 +- axum-extra/src/extract/json_deserializer.rs | 226 +++++++++++++++++++- axum-extra/src/extract/mod.rs | 5 +- axum/Cargo.toml | 3 - axum/src/extract/mod.rs | 13 -- axum/src/json.rs | 57 +++-- 6 files changed, 248 insertions(+), 59 deletions(-) diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index f40e3cf744..c9992e52ae 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -21,7 +21,7 @@ cookie-signed = ["cookie", "cookie?/signed"] cookie-key-expansion = ["cookie", "cookie?/key-expansion"] erased-json = ["dep:serde_json"] form = ["dep:serde_html_form"] -json-deserializer = ["dep:serde_json", "axum/json", "axum/__private"] +json-deserializer = ["dep:serde_json", "dep:serde_path_to_error"] json-lines = [ "dep:serde_json", "dep:tokio-util", @@ -61,6 +61,7 @@ percent-encoding = { version = "2.1", optional = true } prost = { version = "0.12", optional = true } serde_html_form = { version = "0.2.0", optional = true } serde_json = { version = "1.0.71", optional = true } +serde_path_to_error = { version = "0.1.8", optional = true } tokio = { version = "1.19", optional = true } tokio-stream = { version = "0.1.9", optional = true } tokio-util = { version = "0.7", optional = true } diff --git a/axum-extra/src/extract/json_deserializer.rs b/axum-extra/src/extract/json_deserializer.rs index 2ddfdd2010..98feb0a06e 100644 --- a/axum-extra/src/extract/json_deserializer.rs +++ b/axum-extra/src/extract/json_deserializer.rs @@ -1,6 +1,10 @@ use axum::async_trait; -use axum::extract::{json_helpers::*, rejection::JsonRejection, FromRequest, Request}; +use axum::extract::{FromRequest, Request}; +use axum_core::__composite_rejection as composite_rejection; +use axum_core::__define_rejection as define_rejection; +use axum_core::extract::rejection::BytesRejection; use bytes::Bytes; +use http::{header, HeaderMap}; use serde::Deserialize; use std::marker::PhantomData; @@ -11,7 +15,7 @@ use std::marker::PhantomData; /// If the type implements [`serde::de::DeserializeOwned`], the [`Json`](axum::Json) extractor should /// be preferred. /// -/// The request will be rejected (and a [`JsonRejection`] will be returned) if: +/// The request will be rejected (and a [`JsonDeserializerRejection`] will be returned) if: /// /// - The request doesn't have a `Content-Type: application/json` (or similar) header. /// - Buffering the request body fails. @@ -34,7 +38,7 @@ use std::marker::PhantomData; /// /// [order-of-extractors]: axum::extract#the-order-of-extractors /// -/// See [`JsonRejection`] for more details. +/// See [`JsonDeserializerRejection`] for more details. /// /// # Example /// @@ -75,7 +79,7 @@ use std::marker::PhantomData; /// # let _: Router = app; /// ``` #[derive(Debug, Clone, Default)] -#[cfg_attr(docsrs, doc(cfg(feature = "json")))] +#[cfg_attr(docsrs, doc(cfg(feature = "json-deserializer")))] pub struct JsonDeserializer { bytes: Bytes, _marker: PhantomData, @@ -87,7 +91,7 @@ where T: Deserialize<'static>, S: Send + Sync, { - type Rejection = JsonRejection; + type Rejection = JsonDeserializerRejection; async fn from_request(req: Request, state: &S) -> Result { if json_content_type(req.headers()) { @@ -97,7 +101,7 @@ where _marker: PhantomData, }) } else { - Err(missing_json_content_type().into()) + Err(MissingJsonContentType.into()) } } } @@ -108,12 +112,104 @@ where { /// Deserialize the request body into the target type. /// See [`JsonDeserializer`] for more details. - pub fn deserialize(&'a self) -> Result { - let value = json_from_bytes(&self.bytes)?; + pub fn deserialize(&'a self) -> Result { + let deserializer = &mut serde_json::Deserializer::from_slice(&self.bytes); + + let value = match serde_path_to_error::deserialize(deserializer) { + Ok(value) => value, + Err(err) => { + let rejection = match err.inner().classify() { + serde_json::error::Category::Data => JsonDataError::from_err(err).into(), + serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { + JsonSyntaxError::from_err(err).into() + } + serde_json::error::Category::Io => { + if cfg!(debug_assertions) { + // we don't use `serde_json::from_reader` and instead always buffer + // bodies first, so we shouldn't encounter any IO errors + unreachable!() + } else { + JsonSyntaxError::from_err(err).into() + } + } + }; + return Err(rejection); + } + }; + Ok(value) } } +define_rejection! { + #[status = UNPROCESSABLE_ENTITY] + #[body = "Failed to deserialize the JSON body into the target type"] + #[cfg_attr(docsrs, doc(cfg(feature = "json-deserializer")))] + /// Rejection type for [`JsonDeserializer`]. + /// + /// This rejection is used if the request body is syntactically valid JSON but couldn't be + /// deserialized into the target type. + pub struct JsonDataError(Error); +} + +define_rejection! { + #[status = BAD_REQUEST] + #[body = "Failed to parse the request body as JSON"] + #[cfg_attr(docsrs, doc(cfg(feature = "json-deserializer")))] + /// Rejection type for [`JsonDeserializer`]. + /// + /// This rejection is used if the request body didn't contain syntactically valid JSON. + pub struct JsonSyntaxError(Error); +} + +define_rejection! { + #[status = UNSUPPORTED_MEDIA_TYPE] + #[body = "Expected request with `Content-Type: application/json`"] + #[cfg_attr(docsrs, doc(cfg(feature = "json-deserializer")))] + /// Rejection type for [`JsonDeserializer`] used if the `Content-Type` + /// header is missing. + pub struct MissingJsonContentType; +} + +composite_rejection! { + /// Rejection used for [`JsonDeserializer`]. + /// + /// Contains one variant for each way the [`JsonDeserializer`] extractor + /// can fail. + #[cfg_attr(docsrs, doc(cfg(feature = "json-deserializer")))] + pub enum JsonDeserializerRejection { + JsonDataError, + JsonSyntaxError, + MissingJsonContentType, + BytesRejection, + } +} + +fn json_content_type(headers: &HeaderMap) -> bool { + let content_type = if let Some(content_type) = headers.get(header::CONTENT_TYPE) { + content_type + } else { + return false; + }; + + let content_type = if let Ok(content_type) = content_type.to_str() { + content_type + } else { + return false; + }; + + let mime = if let Ok(mime) = content_type.parse::() { + mime + } else { + return false; + }; + + let is_json_content_type = mime.type_() == "application" + && (mime.subtype() == "json" || mime.suffix().map_or(false, |name| name == "json")); + + is_json_content_type +} + #[cfg(test)] mod tests { use super::*; @@ -125,7 +221,7 @@ mod tests { }; use http::StatusCode; use serde::Deserialize; - use serde_json::json; + use serde_json::{json, Value}; use std::borrow::Cow; #[tokio::test] @@ -232,4 +328,116 @@ mod tests { "Failed to deserialize the JSON body into the target type: foo: invalid type: string \"\\\"bad\\\"\", expected a borrowed string at line 1 column 16" ); } + + #[crate::test] + async fn consume_body_to_json_requires_json_content_type() { + #[derive(Debug, Deserialize)] + struct Input<'a> { + #[allow(dead_code)] + foo: Cow<'a, str>, + } + + async fn handler(_deserializer: JsonDeserializer>) -> Response { + panic!("This handler should not be called") + } + + let app = Router::new().route("/", post(handler)); + + let client = TestClient::new(app); + let res = client.post("/").body(r#"{ "foo": "bar" }"#).send().await; + + let status = res.status(); + + assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + #[crate::test] + async fn json_content_types() { + async fn valid_json_content_type(content_type: &str) -> bool { + println!("testing {content_type:?}"); + + async fn handler(_deserializer: JsonDeserializer) -> Response { + StatusCode::OK.into_response() + } + + let app = Router::new().route("/", post(handler)); + + let res = TestClient::new(app) + .post("/") + .header("content-type", content_type) + .body("{}") + .send() + .await; + + res.status() == StatusCode::OK + } + + assert!(valid_json_content_type("application/json").await); + assert!(valid_json_content_type("application/json; charset=utf-8").await); + assert!(valid_json_content_type("application/json;charset=utf-8").await); + assert!(valid_json_content_type("application/cloudevents+json").await); + assert!(!valid_json_content_type("text/json").await); + } + + #[crate::test] + async fn invalid_json_syntax() { + async fn handler(deserializer: JsonDeserializer) -> Response { + match deserializer.deserialize() { + Ok(_) => panic!("Should have matched `Err`"), + Err(e) => e.into_response(), + } + } + + let app = Router::new().route("/", post(handler)); + + let client = TestClient::new(app); + let res = client + .post("/") + .body("{") + .header("content-type", "application/json") + .send() + .await; + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + } + + #[derive(Deserialize)] + struct Foo { + #[allow(dead_code)] + a: i32, + #[allow(dead_code)] + b: Vec, + } + + #[derive(Deserialize)] + struct Bar { + #[allow(dead_code)] + x: i32, + #[allow(dead_code)] + y: i32, + } + + #[crate::test] + async fn invalid_json_data() { + async fn handler(_deserializer: JsonDeserializer) -> Response { + panic!("This handler should not be called") + } + + let app = Router::new().route("/", post(handler)); + + let client = TestClient::new(app); + let res = client + .post("/") + .body("{\"a\": 1, \"b\": [{\"x\": 2}]}") + .header("content-type", "application/json") + .send() + .await; + + assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body_text = res.text().await; + assert_eq!( + body_text, + "Failed to deserialize the JSON body into the target type: b[0]: missing field `y` at line 1 column 23" + ); + } } diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index eb8ea5da91..1f9974de02 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -40,7 +40,10 @@ pub use self::query::{OptionalQuery, OptionalQueryRejection, Query, QueryRejecti pub use self::multipart::Multipart; #[cfg(feature = "json-deserializer")] -pub use self::json_deserializer::JsonDeserializer; +pub use self::json_deserializer::{ + JsonDataError, JsonDeserializer, JsonDeserializerRejection, JsonSyntaxError, + MissingJsonContentType, +}; #[cfg(feature = "json-lines")] #[doc(no_inline)] diff --git a/axum/Cargo.toml b/axum/Cargo.toml index c5fbbf5765..f8e1aac9b0 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -30,8 +30,6 @@ ws = ["dep:hyper", "tokio", "dep:tokio-tungstenite", "dep:sha1", "dep:base64"] # Required for intra-doc links to resolve correctly __private_docs = ["tower/full", "dep:tower-http"] -__private = ["dep:visibility"] - [dependencies] async-trait = "0.1.67" axum-core = { path = "../axum-core", version = "0.4.1" } @@ -65,7 +63,6 @@ sha1 = { version = "0.10", optional = true } tokio = { package = "tokio", version = "1.25.0", features = ["time"], optional = true } tokio-tungstenite = { version = "0.20", optional = true } tracing = { version = "0.1", default-features = false, optional = true } -visibility = { version = "0.1.0", optional = true } [dependencies.tower-http] version = "0.5.0" diff --git a/axum/src/extract/mod.rs b/axum/src/extract/mod.rs index 76fec6953c..719083d11f 100644 --- a/axum/src/extract/mod.rs +++ b/axum/src/extract/mod.rs @@ -42,19 +42,6 @@ pub use self::connect_info::ConnectInfo; #[cfg(feature = "json")] pub use crate::Json; -#[cfg(all(feature = "json", feature = "__private"))] -pub mod json_helpers { - #![allow(missing_docs)] - - use crate::extract::rejection::MissingJsonContentType; - pub use crate::json::{json_content_type, json_from_bytes}; - - #[inline(always)] - pub fn missing_json_content_type() -> MissingJsonContentType { - MissingJsonContentType - } -} - #[doc(no_inline)] pub use crate::Extension; diff --git a/axum/src/json.rs b/axum/src/json.rs index 8d9aca3b59..e96be5b848 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -7,7 +7,7 @@ use http::{ header::{self, HeaderMap, HeaderValue}, StatusCode, }; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Serialize}; /// JSON Extractor / Response. /// @@ -110,8 +110,6 @@ where } } -#[cfg_attr(feature = "__private", visibility::make(pub))] -#[cfg_attr(feature = "__private", allow(missing_docs))] fn json_content_type(headers: &HeaderMap) -> bool { let content_type = if let Some(content_type) = headers.get(header::CONTENT_TYPE) { content_type @@ -137,34 +135,6 @@ fn json_content_type(headers: &HeaderMap) -> bool { is_json_content_type } -#[cfg_attr(feature = "__private", visibility::make(pub))] -#[cfg_attr(feature = "__private", allow(missing_docs))] -fn json_from_bytes<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Result { - let deserializer = &mut serde_json::Deserializer::from_slice(bytes); - - match serde_path_to_error::deserialize(deserializer) { - Ok(value) => Ok(value), - Err(err) => { - let rejection = match err.inner().classify() { - serde_json::error::Category::Data => JsonDataError::from_err(err).into(), - serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { - JsonSyntaxError::from_err(err).into() - } - serde_json::error::Category::Io => { - if cfg!(debug_assertions) { - // we don't use `serde_json::from_reader` and instead always buffer - // bodies first, so we shouldn't encounter any IO errors - unreachable!() - } else { - JsonSyntaxError::from_err(err).into() - } - } - }; - Err(rejection) - } - } -} - axum_core::__impl_deref!(Json); impl From for Json { @@ -181,7 +151,30 @@ where /// but special cases may require first extracting a `Request` into `Bytes` then optionally /// constructing a `Json`. pub fn from_bytes(bytes: &[u8]) -> Result { - let value = json_from_bytes(bytes)?; + let deserializer = &mut serde_json::Deserializer::from_slice(bytes); + + let value = match serde_path_to_error::deserialize(deserializer) { + Ok(value) => value, + Err(err) => { + let rejection = match err.inner().classify() { + serde_json::error::Category::Data => JsonDataError::from_err(err).into(), + serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { + JsonSyntaxError::from_err(err).into() + } + serde_json::error::Category::Io => { + if cfg!(debug_assertions) { + // we don't use `serde_json::from_reader` and instead always buffer + // bodies first, so we shouldn't encounter any IO errors + unreachable!() + } else { + JsonSyntaxError::from_err(err).into() + } + } + }; + return Err(rejection); + } + }; + Ok(Json(value)) } } From 4e6e888ba11503464d6b1f980f4b51d9ed7fd7ed Mon Sep 17 00:00:00 2001 From: future-highway <113635015+future-highway@users.noreply.github.com> Date: Sun, 17 Dec 2023 15:57:04 -0500 Subject: [PATCH 6/9] Fix `JsonDeserializer` tests --- axum-extra/src/extract/json_deserializer.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/axum-extra/src/extract/json_deserializer.rs b/axum-extra/src/extract/json_deserializer.rs index 98feb0a06e..84259745e3 100644 --- a/axum-extra/src/extract/json_deserializer.rs +++ b/axum-extra/src/extract/json_deserializer.rs @@ -251,7 +251,7 @@ mod tests { assert_eq!(body, "bar"); } - #[crate::test] + #[tokio::test] async fn deserialize_body_escaped_to_cow() { #[derive(Debug, Deserialize)] struct Input<'a> { @@ -288,7 +288,7 @@ mod tests { assert_eq!(body, r#""bar""#); } - #[crate::test] + #[tokio::test] async fn deserialize_body_escaped_to_str() { #[derive(Debug, Deserialize)] struct Input<'a> { @@ -329,7 +329,7 @@ mod tests { ); } - #[crate::test] + #[tokio::test] async fn consume_body_to_json_requires_json_content_type() { #[derive(Debug, Deserialize)] struct Input<'a> { @@ -351,7 +351,7 @@ mod tests { assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE); } - #[crate::test] + #[tokio::test] async fn json_content_types() { async fn valid_json_content_type(content_type: &str) -> bool { println!("testing {content_type:?}"); @@ -379,7 +379,7 @@ mod tests { assert!(!valid_json_content_type("text/json").await); } - #[crate::test] + #[tokio::test] async fn invalid_json_syntax() { async fn handler(deserializer: JsonDeserializer) -> Response { match deserializer.deserialize() { @@ -417,10 +417,13 @@ mod tests { y: i32, } - #[crate::test] + #[tokio::test] async fn invalid_json_data() { - async fn handler(_deserializer: JsonDeserializer) -> Response { - panic!("This handler should not be called") + async fn handler(deserializer: JsonDeserializer) -> Response { + match deserializer.deserialize() { + Ok(_) => panic!("Should have matched `Err`"), + Err(e) => e.into_response(), + } } let app = Router::new().route("/", post(handler)); From d7b0a1f1658e8683e879c96d49d9e760bb1a64ad Mon Sep 17 00:00:00 2001 From: future-highway <113635015+future-highway@users.noreply.github.com> Date: Sun, 17 Dec 2023 18:45:07 -0500 Subject: [PATCH 7/9] Fix `JsonDeserializer` test pt. 2 --- axum-extra/src/extract/json_deserializer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/axum-extra/src/extract/json_deserializer.rs b/axum-extra/src/extract/json_deserializer.rs index 84259745e3..0a30798755 100644 --- a/axum-extra/src/extract/json_deserializer.rs +++ b/axum-extra/src/extract/json_deserializer.rs @@ -419,7 +419,7 @@ mod tests { #[tokio::test] async fn invalid_json_data() { - async fn handler(deserializer: JsonDeserializer) -> Response { + async fn handler(deserializer: JsonDeserializer) -> Response { match deserializer.deserialize() { Ok(_) => panic!("Should have matched `Err`"), Err(e) => e.into_response(), From 99ff1d7b4ab24ffa2191a3fb98b99c2c1687e84d Mon Sep 17 00:00:00 2001 From: future-highway <113635015+future-highway@users.noreply.github.com> Date: Thu, 28 Dec 2023 20:01:38 -0500 Subject: [PATCH 8/9] Update axum-extra changelog --- axum-extra/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index d9070635df..615f2c320e 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning]. # Unreleased -- None. +- **added:** `JsonDeserializer` extractor ([#2431]) # 0.9.0 (27. November, 2023) From 1093cb846f5018249d22989ed17d2165485fbc17 Mon Sep 17 00:00:00 2001 From: future-highway <113635015+future-highway@users.noreply.github.com> Date: Thu, 28 Dec 2023 20:05:02 -0500 Subject: [PATCH 9/9] Fix changelog link --- axum-extra/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index 615f2c320e..44d570e9ff 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning]. - **added:** `JsonDeserializer` extractor ([#2431]) +[#2431]: https://github.com/tokio-rs/axum/pull/2431 + # 0.9.0 (27. November, 2023) - **added:** `OptionalQuery` extractor ([#2310])