From 26dd58c1b6f0e18ec20043cc136481b59bcc6032 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Thu, 2 Feb 2023 10:59:48 +0100 Subject: [PATCH 1/2] feat: add axum support --- Cargo.toml | 6 +- examples/actix_web_server/Cargo.toml | 3 +- examples/axum/Cargo.toml | 13 +++ examples/axum/src/main.rs | 125 +++++++++++++++++++++++++++ src/axum/mod.rs | 5 ++ src/axum/serde_json.rs | 85 ++++++++++++++++++ src/impls.rs | 4 +- src/lib.rs | 2 + src/serde_cs.rs | 4 +- tests/attributes/from.rs | 9 +- tests/attributes/try_from.rs | 9 +- 11 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 examples/axum/Cargo.toml create mode 100644 examples/axum/src/main.rs create mode 100644 src/axum/mod.rs create mode 100644 src/axum/serde_json.rs diff --git a/Cargo.toml b/Cargo.toml index ceb04c3..aa0c6ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,16 +12,20 @@ edition = "2021" [dependencies] serde_json = { version = "1.0", optional = true } +serde = { version = "1.0", optional = true } serde-cs = { version = "0.2.4", optional = true } actix-web = { version = "4.3.0", optional = true } +axum = { version = "0.6.4", features = ["json"], optional = true } +http = { version = "0.2.8" , optional = true} futures = { version = "0.3.25", optional = true } deserr-internal = { version = "=0.3.0", path = "derive" } [features] -default = ["serde-json", "serde-cs", "actix-web"] +default = ["serde-json", "serde-cs", "actix-web", "axum"] serde-json = ["serde_json"] serde-cs = ["dep:serde-cs"] actix-web = ["dep:actix-web", "futures"] +axum = ["dep:axum", "http", "serde"] [dev-dependencies] automod = "1.0" diff --git a/examples/actix_web_server/Cargo.toml b/examples/actix_web_server/Cargo.toml index 1e1025f..6faa202 100644 --- a/examples/actix_web_server/Cargo.toml +++ b/examples/actix_web_server/Cargo.toml @@ -2,8 +2,7 @@ name = "actix_web_server" version = "0.1.0" edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +publish = false [dependencies] actix-http = { version = "3.2.2", default-features = false, features = ["compress-brotli", "compress-gzip", "rustls"] } diff --git a/examples/axum/Cargo.toml b/examples/axum/Cargo.toml new file mode 100644 index 0000000..910e107 --- /dev/null +++ b/examples/axum/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "example_axum" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +deserr = { path = "../../" } +axum = { version = "0.6.4", features = ["json"]} +tokio = { version = "1.0", features = ["full"] } +serde = { version = "1.0.152", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/axum/src/main.rs b/examples/axum/src/main.rs new file mode 100644 index 0000000..a2cafcd --- /dev/null +++ b/examples/axum/src/main.rs @@ -0,0 +1,125 @@ +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::Json; +use axum::Router; +use deserr::axum::AxumJson; +use deserr::take_cf_content; +use deserr::DeserializeError; +use deserr::Deserr; +use deserr::ErrorKind; +use deserr::JsonError; +use deserr::ValuePointerRef; +use serde::Deserialize; +use serde::Serialize; +use std::convert::Infallible; +use std::net::SocketAddr; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[derive(Debug, Serialize, Deserialize, Deserr)] +#[serde(deny_unknown_fields)] +#[deserr(deny_unknown_fields)] +struct Query { + name: String, + + // deserr don't do anything strange with `Option`, if you don't + // want to make the `Option` mandatory specify it. + #[deserr(default)] + number: Option, + + // you can put expression in the default values + #[serde(default = "default_range")] + #[deserr(default = Range { min: 2, max: 4 })] + range: Range, + + // serde support a wide variety of enums, but deserr only support + // tagged enums, or unit enum as value. + #[serde(rename = "return")] + #[deserr(rename = "return")] + returns: Return, +} + +fn default_range() -> Range { + Range { min: 2, max: 4 } +} + +#[derive(Debug, Serialize, Deserialize, Deserr)] +#[serde(deny_unknown_fields)] +#[deserr(deny_unknown_fields, validate = validate_range -> __Deserr_E)] +struct Range { + min: u8, + max: u8, +} + +// Here we could specify the error type we're going to return or stay entirely generic so the +// final caller can decide which implementation of error handler will generate the error message. +fn validate_range( + range: Range, + location: ValuePointerRef, +) -> Result { + if range.min > range.max { + Err(take_cf_content(E::error::( + None, + ErrorKind::Unexpected { + msg: format!( + "`max` (`{}`) should be greater than `min` (`{}`)", + range.max, range.min + ), + }, + location, + ))) + } else { + Ok(range) + } +} + +#[derive(Debug, Serialize, Deserialize, Deserr)] +#[serde(rename_all = "camelCase")] +#[deserr(rename_all = camelCase)] +enum Return { + Name, + Number, +} + +/// This handler uses the official `axum::Json` extractor +async fn serde(Json(item): Json) -> Result, impl IntoResponse> { + if item.range.min > item.range.max { + Err(( + StatusCode::BAD_REQUEST, + format!( + "`max` (`{}`) should be greater than `min` (`{}`)", + item.range.max, item.range.min + ), + ) + .into_response()) + } else { + Ok(Json(item)) + } +} + +/// This handler uses the official `AxumJson` deserr +async fn deserr(item: AxumJson) -> AxumJson { + item +} + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "example_axum=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let app = Router::new() + .route("/serde", post(serde)) + .route("/deserr", post(deserr)); + + let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); + tracing::debug!("listening on {}", addr); + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap(); +} diff --git a/src/axum/mod.rs b/src/axum/mod.rs new file mode 100644 index 0000000..0459db3 --- /dev/null +++ b/src/axum/mod.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "serde-json")] +mod serde_json; + +#[cfg(feature = "serde-json")] +pub use self::serde_json::AxumJson; diff --git a/src/axum/serde_json.rs b/src/axum/serde_json.rs new file mode 100644 index 0000000..b001198 --- /dev/null +++ b/src/axum/serde_json.rs @@ -0,0 +1,85 @@ +use crate::{DeserializeError, Deserr, JsonError}; +use axum::async_trait; +use axum::body::HttpBody; +use axum::extract::rejection::JsonRejection; +use axum::extract::FromRequest; +use axum::response::IntoResponse; +use axum::{BoxError, Json}; +use http::{Request, StatusCode}; +use std::marker::PhantomData; + +/// Extractor for typed data from Json request payloads +/// deserialised by deserr. +/// +/// ## Extractor +/// To extract typed data from a request body, the inner type `T` must implement the +/// [`deserr::Deserr`] trait. The inner type `E` must implement the +/// [`DeserializeError`] trait. +/// +/// ## Response +/// [`axum::IntoResponse`] is implemented for any `AxumJson` +/// where `T` implement [`serde::Serialize`]. +#[derive(Debug)] +pub struct AxumJson(pub T, PhantomData); + +#[derive(Debug)] +pub enum AxumJsonRejection { + DeserrError(JsonError), + JsonRejection(JsonRejection), +} + +impl IntoResponse for AxumJson +where + T: serde::Serialize, +{ + fn into_response(self) -> axum::response::Response { + Json(self.0).into_response() + } +} + +#[async_trait] +impl FromRequest for AxumJson +where + T: Deserr, + E: DeserializeError + 'static, + B: HttpBody + Send + 'static, + B::Data: Send, + B::Error: Into, + S: Send + Sync, + AxumJsonRejection: std::convert::From, +{ + type Rejection = AxumJsonRejection; + + async fn from_request(req: Request, state: &S) -> Result { + let Json(value): Json = Json::from_request(req, state).await?; + let data = deserr::deserialize::<_, _, _>(value)?; + Ok(AxumJson(data, PhantomData)) + } +} + +impl From for AxumJsonRejection { + fn from(value: JsonError) -> Self { + AxumJsonRejection::DeserrError(value) + } +} + +impl From for AxumJsonRejection { + fn from(value: JsonRejection) -> Self { + AxumJsonRejection::JsonRejection(value) + } +} + +impl IntoResponse for AxumJsonRejection { + fn into_response(self) -> axum::response::Response { + match self { + AxumJsonRejection::DeserrError(e) => e.into_response(), + AxumJsonRejection::JsonRejection(e) => e.into_response(), + } + } +} + +impl IntoResponse for JsonError { + fn into_response(self) -> axum::response::Response { + (StatusCode::BAD_REQUEST, self.to_string()).into_response() + } +} diff --git a/src/impls.rs b/src/impls.rs index de436bd..8364a24 100644 --- a/src/impls.rs +++ b/src/impls.rs @@ -1,6 +1,6 @@ use crate::{ - take_cf_content, DeserializeError, Deserr, ErrorKind, IntoValue, Map, Sequence, - Value, ValueKind, ValuePointerRef, + take_cf_content, DeserializeError, Deserr, ErrorKind, IntoValue, Map, Sequence, Value, + ValueKind, ValuePointerRef, }; use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, diff --git a/src/lib.rs b/src/lib.rs index 0c43fa3..f816e7e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,8 @@ #[cfg(feature = "actix-web")] pub mod actix_web; +// #[cfg(feature = "axum")] +pub mod axum; mod impls; mod json; mod query_params; diff --git a/src/serde_cs.rs b/src/serde_cs.rs index ca292ea..4fdbd37 100644 --- a/src/serde_cs.rs +++ b/src/serde_cs.rs @@ -3,8 +3,8 @@ use std::str::FromStr; use serde_cs::vec::CS; use crate::{ - take_cf_content, DeserializeError, Deserr, ErrorKind, IntoValue, Value, - ValueKind, ValuePointerRef, + take_cf_content, DeserializeError, Deserr, ErrorKind, IntoValue, Value, ValueKind, + ValuePointerRef, }; impl Deserr for CS diff --git a/tests/attributes/from.rs b/tests/attributes/from.rs index 3bf25e2..bd03832 100644 --- a/tests/attributes/from.rs +++ b/tests/attributes/from.rs @@ -30,8 +30,7 @@ fn from_container_attribute() { ) "###); - let data = deserialize::(json!("🥺")) -.unwrap(); + let data = deserialize::(json!("🥺")).unwrap(); assert_debug_snapshot!(data, @r###" Invalid( @@ -56,8 +55,7 @@ fn from_container_attribute() { } "###); - let data = deserialize::(json!({ "doggo": "👉 👈"})) - .unwrap(); + let data = deserialize::(json!({ "doggo": "👉 👈"})).unwrap(); assert_debug_snapshot!(data, @r###" Struct { @@ -104,8 +102,7 @@ fn from_field_attribute() { } "###); - let data = deserialize::(json!({ "doggo": "👉 👈"})) - .unwrap(); + let data = deserialize::(json!({ "doggo": "👉 👈"})).unwrap(); assert_debug_snapshot!(data, @r###" Struct { diff --git a/tests/attributes/try_from.rs b/tests/attributes/try_from.rs index 72929a1..ef8720f 100644 --- a/tests/attributes/try_from.rs +++ b/tests/attributes/try_from.rs @@ -70,8 +70,7 @@ fn from_container_attribute() { ) "###); - let data = deserialize::(json!("🥺")) -.unwrap_err(); + let data = deserialize::(json!("🥺")).unwrap_err(); assert_display_snapshot!(data, @"Invalid value: Encountered invalid character: `🥺`, only ascii characters are accepted"); @@ -92,8 +91,7 @@ fn from_container_attribute() { } "###); - let data = deserialize::(json!({ "doggo": "👉 👈"})) - .unwrap_err(); + let data = deserialize::(json!({ "doggo": "👉 👈"})).unwrap_err(); assert_display_snapshot!(data, @"Invalid value at `.doggo`: Encountered invalid character: `👉`, only ascii characters are accepted"); } @@ -133,8 +131,7 @@ fn from_field_attribute() { } "###); - let data = deserialize::(json!({ "doggo": "👉 👈"})) - .unwrap_err(); + let data = deserialize::(json!({ "doggo": "👉 👈"})).unwrap_err(); assert_display_snapshot!(data, @"Invalid value at `.doggo`: Encountered invalid character: `👉`, only ascii characters are accepted"); } From ee31ea47dd2305dbb2a01b1e0e9f43a06b3f4a91 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Thu, 2 Feb 2023 11:05:02 +0100 Subject: [PATCH 2/2] docs: add crates.io and doc.rs badges --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c33f6a6..b4f1208 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # Deserr +![Crates.io](https://img.shields.io/crates/v/deserr) +![docs.rs](https://img.shields.io/docsrs/deserr) ## Introduction