From b4d557f4e34d291cc4de6da0b9d349c988ac5ff5 Mon Sep 17 00:00:00 2001 From: Romain Lebran Date: Tue, 4 May 2021 23:25:24 +0200 Subject: [PATCH 1/6] Add serde qs --- Cargo.toml | 1 + src/error.rs | 8 ++ src/lib.rs | 1 + src/qsquery.rs | 210 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+) create mode 100644 src/qsquery.rs diff --git a/Cargo.toml b/Cargo.toml index 193d18f..b2f5182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ validator = { version = ">=0.11, <=0.12", features = ["derive"] } serde = "1" serde_urlencoded = "0.7" serde_json = "1" +serde_qs = "0" log = "0.4" futures = "0.3" mime = "0.3" diff --git a/src/error.rs b/src/error.rs index 021360b..0069d61 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,6 +12,8 @@ pub enum Error { Deserialize(DeserializeErrors), #[display(fmt = "Payload error: {}", _0)] JsonPayloadError(actix_web::error::JsonPayloadError), + #[display(fmt = "Payload error: {}", _0)] + QsError(serde_qs::Error), } #[derive(Display, Debug)] @@ -30,6 +32,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: serde_qs::Error) -> Self { + Error::QsError(error) + } +} + impl From for Error { fn from(error: serde_urlencoded::de::Error) -> Self { Error::Deserialize(DeserializeErrors::DeserializeQuery(error)) diff --git a/src/lib.rs b/src/lib.rs index 895b5c9..28b6c3e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,7 @@ pub mod error; mod json; mod path; mod query; +mod qsquery; pub use error::Error; pub use json::*; pub use path::*; diff --git a/src/qsquery.rs b/src/qsquery.rs new file mode 100644 index 0000000..acb3d2c --- /dev/null +++ b/src/qsquery.rs @@ -0,0 +1,210 @@ +//! Query extractor. +use crate::error::Error; +use std::ops::Deref; +use std::sync::Arc; +use std::{fmt, ops}; + +use actix_web::{FromRequest, HttpRequest}; +use futures::future::{err, ok, Ready}; +use serde::de; +use validator::Validate; +use serde_qs::Config as QsConfig; + +#[derive(PartialEq, Eq, PartialOrd, Ord)] +pub struct QsQuery(pub T); + +impl AsRef for QsQuery { + fn as_ref(&self) -> &T { + &self.0 + } +} + +impl Deref for QsQuery { + type Target = T; + + fn deref(&self) -> &T { + &self.0 + } +} + +impl ops::DerefMut for QsQuery { + fn deref_mut(&mut self) -> &mut T { + &mut self.0 + } +} + +impl fmt::Debug for QsQuery { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl fmt::Display for QsQuery { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl QsQuery +where + T: Validate, +{ + /// Deconstruct to an inner value. + pub fn into_inner(self) -> T { + self.0 + } +} + +/// Extract typed information from the request's query. +/// +/// ## Example +/// +/// ```rust +/// use actix_web::{web, App}; +/// use serde_derive::Deserialize; +/// use actix_web_validator::{Query, Validate}; +/// +/// #[derive(Debug, Deserialize)] +/// pub enum ResponseType { +/// Token, +/// Code +/// } +/// +/// #[derive(Deserialize, Validate)] +/// pub struct AuthRequest { +/// #[validate(range(min = 1000, max = 9999))] +/// id: u64, +/// response_type: ResponseType, +/// } +/// +/// // Use `Query` extractor for query information (and destructure it within the signature). +/// // This handler gets called only if the request's query string contains a `id` and +/// // `response_type` fields. +/// // The correct request for this handler would be `/index.html?id=19&response_type=Code"`. +/// async fn index(web::Query(info): web::Query) -> String { +/// format!("Authorization request for client with id={} and type={:?}!", info.id, info.response_type) +/// } +/// +/// fn main() { +/// let app = App::new().service( +/// web::resource("/index.html").route(web::get().to(index))); // <- use `Query` extractor +/// } +/// ``` +impl FromRequest for QsQuery +where + T: de::DeserializeOwned + Validate, +{ + type Error = actix_web::Error; + type Future = Ready>; + type Config = QsQueryConfig; + + /// Builds Query struct from request and provides validation mechanism + #[inline] + fn from_request( + req: &actix_web::web::HttpRequest, + _: &mut actix_web::dev::Payload, + ) -> Self::Future { + let query_config = req.app_data::(); + + let error_handler = query_config.map(|c| c.ehandler.clone()) + .unwrap_or(None); + + let default_qsconfig = QsConfig::default(); + let qsconfig = query_config + .map(|c| &c.qs_config) + .unwrap_or(&default_qsconfig); + + qsconfig + .deserialize_str::(req.query_string()) + .map_err(Error::from) + .and_then(|value| { + value + .validate() + .map(move |_| value) + .map_err(Error::Validate) + }) + .map_err(move |e| { + log::debug!( + "Failed during Query extractor validation. \ + Request path: {:?}", + req.path() + ); + if let Some(error_handler) = error_handler { + (error_handler)(e, req) + } else { + e.into() + } + }) + .map(|value| ok(QsQuery(value))) + .unwrap_or_else(|e| err(e)) + } +} + +/// Query extractor configuration +/// +/// ```rust +/// # #[macro_use] extern crate serde_derive; +/// # #[cfg(feature = "actix")] +/// # use actix_web; +/// # #[cfg(feature = "actix2")] +/// # use actix_web2 as actix_web; +/// use actix_web::{error, web, App, FromRequest, HttpResponse}; +/// use serde_qs::actix::QsQuery; +/// use serde_qs::Config as QsConfig; +/// +/// #[derive(Deserialize)] +/// struct Info { +/// username: String, +/// } +/// +/// /// deserialize `Info` from request's querystring +/// fn index(info: QsQuery) -> HttpResponse { +/// format!("Welcome {}!", info.username).into() +/// } +/// +/// fn main() { +/// let app = App::new().service( +/// web::resource("/index.html").app_data( +/// // change query extractor configuration +/// QsQuery::::configure(|cfg| { +/// cfg.error_handler(|err, req| { // <- create custom error response +/// error::InternalError::from_response( +/// err, HttpResponse::Conflict().finish()).into() +/// }) +/// .qs_config(QsConfig::default()) +/// })) +/// .route(web::post().to(index)) +/// ); +/// } +/// ``` + +pub struct QsQueryConfig { + ehandler: Option actix_web::Error + Send + Sync>>, + qs_config: QsConfig, +} + +impl QsQueryConfig { + /// Set custom error handler + pub fn error_handler(mut self, f: F) -> Self + where + F: Fn(Error, &HttpRequest) -> actix_web::Error + Send + Sync + 'static, + { + self.ehandler = Some(Arc::new(f)); + self + } + + /// Set custom serialization parameters + pub fn qs_config(mut self, config: QsConfig) -> Self { + self.qs_config = config; + self + } +} + +impl Default for QsQueryConfig { + fn default() -> Self { + QsQueryConfig { + ehandler: None, + qs_config: QsConfig::default(), + } + } +} From 97cd274f64c47566f0624a7b20292d3aac2e4208 Mon Sep 17 00:00:00 2001 From: Romain Lebran Date: Tue, 4 May 2021 23:33:19 +0200 Subject: [PATCH 2/6] Add use for qsquery --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index 28b6c3e..499a198 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,4 +46,5 @@ pub use error::Error; pub use json::*; pub use path::*; pub use query::*; +pub use qsquery::*; pub use validator::Validate; From 3437e8c645a22e802eaf3b5d806664a4f8af3cb0 Mon Sep 17 00:00:00 2001 From: Romain Lebran Date: Thu, 6 May 2021 09:01:20 +0200 Subject: [PATCH 3/6] Add simple test for serde qs --- Cargo.toml | 2 +- tests/test_qsquery_validation.rs | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/test_qsquery_validation.rs diff --git a/Cargo.toml b/Cargo.toml index b2f5182..5003987 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ validator = { version = ">=0.11, <=0.12", features = ["derive"] } serde = "1" serde_urlencoded = "0.7" serde_json = "1" -serde_qs = "0" +serde_qs = { version = "0", features = ["actix"] } log = "0.4" futures = "0.3" mime = "0.3" diff --git a/tests/test_qsquery_validation.rs b/tests/test_qsquery_validation.rs new file mode 100644 index 0000000..fa1a37d --- /dev/null +++ b/tests/test_qsquery_validation.rs @@ -0,0 +1,79 @@ +use actix_web::{error, http::StatusCode, test, test::call_service, web, App, HttpResponse}; +use actix_web_validator::{Validate, Error, QsQuery}; +use serde_derive::Deserialize; + +#[derive(Debug, Validate, Deserialize, PartialEq)] +struct QueryParams { + #[validate(range(min = 8, max = 28))] + id: u8, +} + +async fn test_handler(_query: QsQuery) -> HttpResponse { + HttpResponse::Ok().finish() +} + +#[actix_rt::test] +async fn test_qsquery_validation() { + let mut app = + test::init_service(App::new().service(web::resource("/test").to(test_handler))).await; + + // Test 400 status + let req = test::TestRequest::with_uri("/test?id=42").to_request(); + let resp = call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Test 200 status + let req = test::TestRequest::with_uri("/test?id=28").to_request(); + let resp = call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); +} + +#[actix_rt::test] +async fn test_custom_qsquery_validation_error() { + let mut app = test::init_service( + App::new() + .app_data( + actix_web_validator::QsQueryConfig::default().error_handler(|err, _req| { + assert!(matches!(err, Error::Validate(_))); + error::InternalError::from_response(err, HttpResponse::Conflict().finish()) + .into() + }), + ) + .service(web::resource("/test").to(test_handler)), + ) + .await; + + let req = test::TestRequest::with_uri("/test?id=42").to_request(); + let resp = call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::CONFLICT); +} + +#[actix_rt::test] +async fn test_deref_validated_qsquery() { + let mut app = test::init_service(App::new().service(web::resource("/test").to( + |query: QsQuery| { + assert_eq!(query.id, 28); + HttpResponse::Ok().finish() + }, + ))) + .await; + + let req = test::TestRequest::with_uri("/test?id=28").to_request(); + call_service(&mut app, req).await; +} + +#[actix_rt::test] +async fn test_qsquery_implementation() { + async fn test_handler(query: QsQuery) -> HttpResponse { + let reference = QueryParams { id: 28 }; + assert_eq!(query.as_ref(), &reference); + assert_eq!(query.into_inner(), reference); + HttpResponse::Ok().finish() + } + + let mut app = + test::init_service(App::new().service(web::resource("/test").to(test_handler))).await; + let req = test::TestRequest::with_uri("/test?id=28").to_request(); + let resp = call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); +} From a4c6ab8be78394eb40460fc8c377f55d210de37a Mon Sep 17 00:00:00 2001 From: Romain Lebran Date: Thu, 6 May 2021 09:12:54 +0200 Subject: [PATCH 4/6] Add doc for qsquery --- src/qsquery.rs | 180 ++++++++++++++++++++++++++++++------------------- 1 file changed, 109 insertions(+), 71 deletions(-) diff --git a/src/qsquery.rs b/src/qsquery.rs index acb3d2c..18715e0 100644 --- a/src/qsquery.rs +++ b/src/qsquery.rs @@ -10,6 +10,113 @@ use serde::de; use validator::Validate; use serde_qs::Config as QsConfig; +/// Query extractor configuration +/// +/// ```rust +/// # #[macro_use] extern crate serde_derive; +/// # #[cfg(feature = "actix")] +/// # use actix_web; +/// # #[cfg(feature = "actix2")] +/// # use actix_web2 as actix_web; +/// use actix_web::{error, web, App, FromRequest, HttpResponse}; +/// use serde_qs::actix::QsQuery; +/// use serde_qs::Config as QsConfig; +/// +/// #[derive(Deserialize)] +/// struct Info { +/// username: String, +/// } +/// +/// /// deserialize `Info` from request's querystring +/// fn index(info: QsQuery) -> HttpResponse { +/// format!("Welcome {}!", info.username).into() +/// } +/// +/// fn main() { +/// let app = App::new().service( +/// web::resource("/index.html").app_data( +/// // change query extractor configuration +/// QsQuery::::configure(|cfg| { +/// cfg.error_handler(|err, req| { // <- create custom error response +/// error::InternalError::from_response( +/// err, HttpResponse::Conflict().finish()).into() +/// }) +/// .qs_config(QsConfig::default()) +/// })) +/// .route(web::post().to(index)) +/// ); +/// } +/// ``` + +pub struct QsQueryConfig { + ehandler: Option actix_web::Error + Send + Sync>>, + qs_config: QsConfig, +} + +impl QsQueryConfig { + /// Set custom error handler + pub fn error_handler(mut self, f: F) -> Self + where + F: Fn(Error, &HttpRequest) -> actix_web::Error + Send + Sync + 'static, + { + self.ehandler = Some(Arc::new(f)); + self + } + + /// Set custom serialization parameters + pub fn qs_config(mut self, config: QsConfig) -> Self { + self.qs_config = config; + self + } +} + +impl Default for QsQueryConfig { + fn default() -> Self { + QsQueryConfig { + ehandler: None, + qs_config: QsConfig::default(), + } + } +} + +/// Extract and validate typed information from the request's query. +/// +/// For query decoding uses *serde_urlencoded* crate +/// [**QueryConfig**](struct.QueryConfig.html) allows to configure extraction process. +/// +/// ## Example +/// +/// ```rust +/// use actix_web::{web, App}; +/// use serde_derive::Deserialize; +/// use actix_web_validator::{QsQuery, Validate}; +/// +/// #[derive(Debug, Deserialize)] +/// pub enum ResponseType { +/// Token, +/// Code +/// } +/// +/// #[derive(Deserialize, Validate)] +/// pub struct AuthRequest { +/// #[validate(range(min = 1000, max = 9999))] +/// id: u64, +/// response_type: ResponseType, +/// } +/// +/// // Use `Query` extractor for query information (and destructure it within the signature). +/// // This handler gets called only if the request's query string contains a `id` and +/// // `response_type` fields. +/// // The correct request for this handler would be `/index.html?id=1234&response_type=Code"`. +/// async fn index(info: QsQuery) -> String { +/// format!("Authorization request for client with id={} and type={:?}!", info.id, info.response_type) +/// } +/// +/// fn main() { +/// let app = App::new().service( +/// web::resource("/index.html").route(web::get().to(index))); // <- use `Query` extractor +/// } +/// ``` #[derive(PartialEq, Eq, PartialOrd, Ord)] pub struct QsQuery(pub T); @@ -62,7 +169,7 @@ where /// ```rust /// use actix_web::{web, App}; /// use serde_derive::Deserialize; -/// use actix_web_validator::{Query, Validate}; +/// use actix_web_validator::{QsQuery, Validate}; /// /// #[derive(Debug, Deserialize)] /// pub enum ResponseType { @@ -81,7 +188,7 @@ where /// // This handler gets called only if the request's query string contains a `id` and /// // `response_type` fields. /// // The correct request for this handler would be `/index.html?id=19&response_type=Code"`. -/// async fn index(web::Query(info): web::Query) -> String { +/// async fn index(QsQuery(info): QsQuery) -> String { /// format!("Authorization request for client with id={} and type={:?}!", info.id, info.response_type) /// } /// @@ -139,72 +246,3 @@ where .unwrap_or_else(|e| err(e)) } } - -/// Query extractor configuration -/// -/// ```rust -/// # #[macro_use] extern crate serde_derive; -/// # #[cfg(feature = "actix")] -/// # use actix_web; -/// # #[cfg(feature = "actix2")] -/// # use actix_web2 as actix_web; -/// use actix_web::{error, web, App, FromRequest, HttpResponse}; -/// use serde_qs::actix::QsQuery; -/// use serde_qs::Config as QsConfig; -/// -/// #[derive(Deserialize)] -/// struct Info { -/// username: String, -/// } -/// -/// /// deserialize `Info` from request's querystring -/// fn index(info: QsQuery) -> HttpResponse { -/// format!("Welcome {}!", info.username).into() -/// } -/// -/// fn main() { -/// let app = App::new().service( -/// web::resource("/index.html").app_data( -/// // change query extractor configuration -/// QsQuery::::configure(|cfg| { -/// cfg.error_handler(|err, req| { // <- create custom error response -/// error::InternalError::from_response( -/// err, HttpResponse::Conflict().finish()).into() -/// }) -/// .qs_config(QsConfig::default()) -/// })) -/// .route(web::post().to(index)) -/// ); -/// } -/// ``` - -pub struct QsQueryConfig { - ehandler: Option actix_web::Error + Send + Sync>>, - qs_config: QsConfig, -} - -impl QsQueryConfig { - /// Set custom error handler - pub fn error_handler(mut self, f: F) -> Self - where - F: Fn(Error, &HttpRequest) -> actix_web::Error + Send + Sync + 'static, - { - self.ehandler = Some(Arc::new(f)); - self - } - - /// Set custom serialization parameters - pub fn qs_config(mut self, config: QsConfig) -> Self { - self.qs_config = config; - self - } -} - -impl Default for QsQueryConfig { - fn default() -> Self { - QsQueryConfig { - ehandler: None, - qs_config: QsConfig::default(), - } - } -} From 5a1d133c909728a883debae42158a719f6788aab Mon Sep 17 00:00:00 2001 From: Romain Lebran Date: Thu, 6 May 2021 11:53:04 +0200 Subject: [PATCH 5/6] Update Readme with qsquery --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e3eba88..ea7f131 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,11 @@ This crate works with Cargo and can be found on actix-web-validator = "2.0.3" ``` -## Supported `actix_web::web` extractors: -* `web::Json` -* `web::Query` -* `web::Path` +## Supported extractors: +* `actix_web::web::Json` +* `actix_web::web::Query` +* `actix_web::web::Path` +* `serde_qs::actix::QsQuery` ### Supported `actix_web` versions: * For actix-web-validator `0.*` supported version of actix-web is `1.*` From c531ddac5c00615e487d9240c513ce84e778fc5b Mon Sep 17 00:00:00 2001 From: Romain Lebran Date: Mon, 17 May 2021 07:26:47 +0200 Subject: [PATCH 6/6] Fix serde_qs minor version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 5003987..33c38ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ validator = { version = ">=0.11, <=0.12", features = ["derive"] } serde = "1" serde_urlencoded = "0.7" serde_json = "1" -serde_qs = { version = "0", features = ["actix"] } +serde_qs = { version = "0.8", features = ["actix"] } log = "0.4" futures = "0.3" mime = "0.3"