diff options
-rw-r--r-- | .github/workflows/ci.yml | 73 | ||||
-rw-r--r-- | .github/workflows/release-plz.yml | 53 | ||||
-rw-r--r-- | CHANGELOG.md | 19 | ||||
-rw-r--r-- | Cargo.toml | 23 | ||||
-rw-r--r-- | README.md | 14 | ||||
-rw-r--r-- | examples/csv_vectors.rs | 56 | ||||
-rw-r--r-- | examples/introduction.rs | 2 | ||||
-rw-r--r-- | src/actix.rs | 156 | ||||
-rw-r--r-- | src/axum.rs | 88 | ||||
-rw-r--r-- | src/de/mod.rs | 16 | ||||
-rw-r--r-- | src/de/parse.rs | 4 | ||||
-rw-r--r-- | src/lib.rs | 11 | ||||
-rw-r--r-- | src/ser.rs | 67 | ||||
-rw-r--r-- | tests/test_actix.rs | 30 | ||||
-rw-r--r-- | tests/test_axum.rs | 42 | ||||
-rw-r--r-- | tests/test_deserialize.rs | 2 | ||||
-rw-r--r-- | tests/test_warp.rs | 6 |
17 files changed, 515 insertions, 147 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a478db..8353c96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,12 @@ name: Rust CI checks on: push: + pull_request: + +env: + CARGO_TERM_COLOR: always +# Make sure CI fails on all warnings, including Clippy lints + RUSTFLAGS: "-Dwarnings" jobs: lint: @@ -8,58 +14,67 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - rust: - - stable - - 1.36.0 + feature: + - actix4,warp,axum + - actix3 steps: - uses: actions/checkout@v2 - name: Install Rust stable toolchain - uses: actions-rs/toolchain@v1 + uses: dtolnay/rust-toolchain@master with: - profile: minimal toolchain: stable - override: true components: rustfmt, clippy - - name: Check Rust formatting - uses: actions-rs/cargo@v1 - with: - command: fmt - args: --all -- --check - - name: Check clippy - uses: actions-rs/clippy-check@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --all-targets -- -D warnings + - name: Run formatting + run: cargo fmt --all --check + - name: Run Clippy + run: cargo clippy --all-targets -F "${{ matrix.feature }}" test: name: Run tests runs-on: ubuntu-latest strategy: matrix: + # NOTE: this crate's MSRC if 1.63 + # However, many of the web frameworks that + # this provides support for have greater/missing MSRVs. + # We attempt to test the MSRV here if it is known. rust: - stable - - 1.36.0 + - 1.63.0 feature: - "" - actix4 - actix3 - - actix2 - warp - axum + + # test frameworks for specific MSRVs (where known) + include: + # axum 0.8 has a MSRV of 1.75.0 + - rust: 1.75.0 + feature: axum + + # exclude frameworks that have a different/unknown MSRV + exclude: + - rust: 1.63.0 + feature: axum + # actix does not seem to actually adhere + # to its MSRV -- it states 1.75 but has a dependency `zerofrom` + # that requires Rust 1.81. We'll punt on testing it + - rust: 1.63.0 + feature: actix4 + - rust: 1.63.0 + feature: actix3 + # warp does not list an MSRV + # see: https://github.com/seanmonstar/warp/issues/1077 + - rust: 1.63.0 + feature: warp steps: - - uses: actions/checkout@v2 - - uses: actions/cache@v2 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-test-${{ hashFiles('Cargo.toml') }} + - uses: actions/checkout@v4 - name: Install Rust stable toolchain - uses: actions-rs/toolchain@v1 + uses: dtolnay/rust-toolchain@stable with: - profile: minimal - toolchain: stable + toolchain: ${{ matrix.rust }} - name: Run test ${{ matrix.feature }} run: | cargo test --all-targets --features "${{ matrix.feature }}" diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 0000000..6ee6e4a --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,53 @@ +name: Release-plz + +on: + push: + branches: + - main + +jobs: + release-plz-release: + name: Release-plz release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PLZ_TOKEN }} + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Run release-plz + uses: release-plz/action@v0.5 + with: + command: release + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + release-plz-pr: + name: Release-plz PR + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: write + concurrency: + group: release-plz-${{ github.ref }} + cancel-in-progress: false + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PLZ_TOKEN }} + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Run release-plz + uses: release-plz/action@v0.5 + with: + command: release-pr + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6325d16 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## [0.14.0](https://github.com/samscott89/serde_qs/compare/v0.13.0...v0.14.0) - 2025-03-04 + +### Other + +- Add release plz CI ([#125](https://github.com/samscott89/serde_qs/pull/125)) +- Update CI config ([#124](https://github.com/samscott89/serde_qs/pull/124)) +- update axum to v0.8 ([#118](https://github.com/samscott89/serde_qs/pull/118)) +- :multiple_bound_locations ([#103](https://github.com/samscott89/serde_qs/pull/103)) +- Add axum::OptionalQsQuery ([#102](https://github.com/samscott89/serde_qs/pull/102)) +- generate docs for axum support as well ([#100](https://github.com/samscott89/serde_qs/pull/100)) +- Update README.md + +## Version 0.13.0 + +- Bump `axum` support to 0.7 +- Remove support for `actix-web 2.0` +- Add support for extracting form data in actix via `QsForm` @@ -9,25 +9,24 @@ license = "MIT/Apache-2.0" name = "serde_qs" repository = "https://github.com/samscott89/serde_qs" readme = "README.md" -version = "0.12.0" -rust-version = "1.36" +version = "0.14.0" +rust-version = "1.63" [dependencies] actix-web4 = { version = "4.0", optional = true, package = "actix-web", default-features = false } actix-web3 = { version = "3.3", optional = true, package = "actix-web", default-features = false } -actix-web2 = { version = "2.0", optional = true, package = "actix-web", default-features = false } futures = { version = "0.3", optional = true } percent-encoding = "2.1" -serde = "1.0" -thiserror = "1.0" +serde = { version = "1.0", features = ["derive"] } +thiserror = "2.0" tracing = { version = "0.1", optional = true } warp-framework = { package = "warp", version = "0.3", default-features = false, optional = true } -axum-framework = { package = "axum", version = "0.6", default-features = false, optional = true } +axum-framework = { package = "axum", version = "0.8", default-features = false, optional = true } [dev-dependencies] chrono = { version = "0.4", features = ["serde"] } -csv = "1.1" -rand = "0.8" +csv = "=1.2.2" +rand = "0.9" serde_derive = "1.0" serde_urlencoded = "0.7" serde_with = "2.0" @@ -36,11 +35,15 @@ serde_with = "2.0" default = [] actix4 = ["actix-web4", "futures"] actix3 = ["actix-web3", "futures"] -actix2 = ["actix-web2", "futures"] # deprecated feature -- used to return a warning +actix2 = [] actix = [] warp = ["futures", "tracing", "warp-framework"] axum = ["axum-framework", "futures"] [package.metadata.docs.rs] -features = ["actix4", "warp"] +features = ["actix4", "warp", "axum"] + +[[example]] +name = "csv_vectors" +test = true @@ -13,9 +13,11 @@ querystrings. This crate is designed to extend [`serde_urlencoded`][urlencoded] when using nested parameters, similar to those used by [qs][qs] for Node, and commonly used by Ruby on Rails via [Rack][Rack]. -The core of the library was inspired by -[`serde_urlencoded`][urlencoded], which should be preferred -over this crate whenever non-nested query parameters are sufficient. It is built +The core of the library was inspired by [`serde_urlencoded`][urlencoded]. +In order to support abitrarily nested structs encoded in arbitrary orders, we +perform two passes over the input string. This likely adds a non-trivial amount +of memory and compute. Due to this `serde_urlencoded` should be preferred +over this crate whenever non-nested query parameters are sufficient. The crate is built upon [Serde], a high performance generic serialization framework and [rust-url], a URL parser for Rust. @@ -33,10 +35,12 @@ This crate works with Cargo and can be found on ```toml [dependencies] -serde_qs = "0.12" +serde_qs = "0.13" ``` -Minimum supported Rust version is 1.36. +Minimum supported Rust version is 1.63 due to dependencies. + +For older versions of Rust, `serde_qs` versions `<= 0.11` support Rust 1.36. [crates.io]: https://crates.io/crates/serde_qs diff --git a/examples/csv_vectors.rs b/examples/csv_vectors.rs index c193c64..2e44566 100644 --- a/examples/csv_vectors.rs +++ b/examples/csv_vectors.rs @@ -3,12 +3,10 @@ extern crate serde; #[macro_use] extern crate serde_derive; extern crate serde_qs as qs; - use serde::de::DeserializeOwned; - use std::default::Default; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Deserialize, Serialize)] struct Query { #[serde(deserialize_with = "from_csv")] r: Vec<u8>, @@ -21,24 +19,39 @@ fn main() { println!("{:?}", q); } +#[test] +fn deserialize_sequence() { + let q = "s=12&r=1,2,3"; + let q: Query = qs::from_str(q).unwrap(); + let expected = Query { + r: vec![1, 2, 3], + s: 12, + }; + assert_eq!(q, expected); +} + fn from_csv<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error> where D: serde::Deserializer<'de>, - T: DeserializeOwned, + T: DeserializeOwned + std::str::FromStr, + <T as std::str::FromStr>::Err: std::fmt::Debug, { deserializer.deserialize_str(CSVVecVisitor::<T>::default()) } /// Visits a string value of the form "v1,v2,v3" into a vector of bytes Vec<u8> -struct CSVVecVisitor<T: DeserializeOwned>(std::marker::PhantomData<T>); +struct CSVVecVisitor<T: DeserializeOwned + std::str::FromStr>(std::marker::PhantomData<T>); -impl<T: DeserializeOwned> Default for CSVVecVisitor<T> { +impl<T: DeserializeOwned + std::str::FromStr> Default for CSVVecVisitor<T> { fn default() -> Self { CSVVecVisitor(std::marker::PhantomData) } } -impl<'de, T: DeserializeOwned> serde::de::Visitor<'de> for CSVVecVisitor<T> { +impl<T: DeserializeOwned + std::str::FromStr> serde::de::Visitor<'_> for CSVVecVisitor<T> +where + <T as std::str::FromStr>::Err: std::fmt::Debug, // handle the parse error in a generic way +{ type Value = Vec<T>; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { @@ -49,12 +62,31 @@ impl<'de, T: DeserializeOwned> serde::de::Visitor<'de> for CSVVecVisitor<T> { where E: serde::de::Error, { + // Treat the comma-separated string as a single record in a CSV. + let mut rdr = csv::ReaderBuilder::new() + .has_headers(false) + .from_reader(s.as_bytes()); + + // Try to get the record and collect its values into a vector. let mut output = Vec::new(); - let mut items = csv::Reader::from_reader(s.as_bytes()); - for res in items.deserialize() { - let item: T = res - .map_err(|e| E::custom(format!("could not deserialize sequence value: {:?}", e)))?; - output.push(item); + for result in rdr.records() { + match result { + Ok(record) => { + for field in record.iter() { + output.push( + field + .parse::<T>() + .map_err(|_| E::custom("Failed to parse field"))?, + ); + } + } + Err(e) => { + return Err(E::custom(format!( + "could not deserialize sequence value: {:?}", + e + ))); + } + } } Ok(output) diff --git a/examples/introduction.rs b/examples/introduction.rs index 10e7a8b..bf4c508 100644 --- a/examples/introduction.rs +++ b/examples/introduction.rs @@ -101,7 +101,7 @@ fn main() { "user_ids[3]=4", ]; - let mut rng = rand::thread_rng(); + let mut rng = rand::rng(); for _ in 0..10 { let mut acc = String::new(); inputs.shuffle(&mut rng); diff --git a/src/actix.rs b/src/actix.rs index a185212..02e9ae8 100644 --- a/src/actix.rs +++ b/src/actix.rs @@ -5,25 +5,25 @@ use crate::de::Config as QsConfig; use crate::error::Error as QsError; -#[cfg(feature = "actix2")] -use actix_web2 as actix_web; #[cfg(feature = "actix3")] use actix_web3 as actix_web; #[cfg(feature = "actix4")] use actix_web4 as actix_web; use actix_web::dev::Payload; -#[cfg(any(feature = "actix2", feature = "actix3"))] +#[cfg(feature = "actix3")] use actix_web::HttpResponse; -use actix_web::{Error as ActixError, FromRequest, HttpRequest, ResponseError}; -use futures::future::{ready, Ready}; +use actix_web::{web, Error as ActixError, FromRequest, HttpRequest, ResponseError}; +use futures::future::{ready, FutureExt, LocalBoxFuture, Ready}; +use futures::StreamExt; use serde::de; +use serde::de::DeserializeOwned; use std::fmt; use std::fmt::{Debug, Display}; use std::ops::{Deref, DerefMut}; use std::sync::Arc; -#[cfg(any(feature = "actix2", feature = "actix3"))] +#[cfg(feature = "actix3")] impl ResponseError for QsError { fn error_response(&self) -> HttpResponse { HttpResponse::BadRequest().finish() @@ -48,8 +48,6 @@ impl ResponseError for QsError { /// # use actix_web4 as actix_web; /// # #[cfg(feature = "actix3")] /// # use actix_web3 as actix_web; -/// # #[cfg(feature = "actix2")] -/// # use actix_web2 as actix_web; /// use actix_web::{web, App, HttpResponse}; /// use serde_qs::actix::QsQuery; /// @@ -74,6 +72,12 @@ impl ResponseError for QsError { /// ``` pub struct QsQuery<T>(T); +impl<T> QsQuery<T> { + /// Unwrap into inner T value + pub fn into_inner(self) -> T { + self.0 + } +} impl<T> Deref for QsQuery<T> { type Target = T; @@ -88,13 +92,6 @@ impl<T> DerefMut for QsQuery<T> { } } -impl<T> QsQuery<T> { - /// Deconstruct to a inner value - pub fn into_inner(self) -> T { - self.0 - } -} - impl<T: Debug> Debug for QsQuery<T> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { self.0.fmt(f) @@ -113,25 +110,19 @@ where { type Error = ActixError; type Future = Ready<Result<Self, ActixError>>; - #[cfg(any(feature = "actix2", feature = "actix3"))] + #[cfg(feature = "actix3")] type Config = QsQueryConfig; #[inline] fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { - let query_config = req.app_data::<QsQueryConfig>(); - - let error_handler = query_config.map(|c| c.ehandler.clone()).unwrap_or(None); + let query_config = req.app_data::<QsQueryConfig>().unwrap_or(&DEFAULT_CONFIG); - let default_qsconfig = QsConfig::default(); - let qsconfig = query_config - .map(|c| &c.qs_config) - .unwrap_or(&default_qsconfig); - - let res = qsconfig + let res = query_config + .qs_config .deserialize_str::<T>(req.query_string()) .map(|val| Ok(QsQuery(val))) .unwrap_or_else(move |e| { - let e = if let Some(error_handler) = error_handler { + let e = if let Some(error_handler) = &query_config.ehandler { (error_handler)(e, req) } else { e.into() @@ -143,6 +134,8 @@ where } } +type ActixErrorHandler = Option<Arc<dyn Fn(QsError, &HttpRequest) -> ActixError + Send + Sync>>; + /// Query extractor configuration /// /// ```rust @@ -151,8 +144,6 @@ where /// # use actix_web4 as actix_web; /// # #[cfg(feature = "actix3")] /// # use actix_web3 as 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; @@ -184,11 +175,17 @@ where /// ); /// } /// ``` +#[derive(Clone, Default)] pub struct QsQueryConfig { - ehandler: Option<Arc<dyn Fn(QsError, &HttpRequest) -> ActixError + Send + Sync>>, + ehandler: ActixErrorHandler, qs_config: QsConfig, } +static DEFAULT_CONFIG: QsQueryConfig = QsQueryConfig { + ehandler: None, + qs_config: crate::de::DEFAULT_CONFIG, +}; + impl QsQueryConfig { /// Set custom error handler pub fn error_handler<F>(mut self, f: F) -> Self @@ -206,11 +203,102 @@ impl QsQueryConfig { } } -impl Default for QsQueryConfig { - fn default() -> Self { - QsQueryConfig { - ehandler: None, - qs_config: QsConfig::default(), +#[derive(PartialEq, Eq, PartialOrd, Ord)] +/// Extract typed information from from the request's form data. +/// +/// ## Example +/// +/// ```rust +/// # #[macro_use] extern crate serde_derive; +/// # #[cfg(feature = "actix4")] +/// # use actix_web4 as actix_web; +/// # #[cfg(feature = "actix3")] +/// # use actix_web3 as actix_web; +/// use actix_web::{web, App, HttpResponse}; +/// use serde_qs::actix::QsForm; +/// +/// #[derive(Debug, Deserialize)] +/// pub struct UsersFilter { +/// id: Vec<u64>, +/// } +/// +/// // Use `QsForm` extractor for Form information. +/// // Content-Type: application/x-www-form-urlencoded +/// // The correct request payload for this handler would be `id[]=1124&id[]=88` +/// async fn filter_users(info: QsForm<UsersFilter>) -> HttpResponse { +/// HttpResponse::Ok().body( +/// info.id.iter().map(|i| i.to_string()).collect::<Vec<String>>().join(", ") +/// ) +/// } +/// +/// fn main() { +/// let app = App::new().service( +/// web::resource("/users") +/// .route(web::get().to(filter_users))); +/// } +/// ``` +#[derive(Debug)] +pub struct QsForm<T>(T); + +impl<T> QsForm<T> { + /// Unwrap into inner T value + pub fn into_inner(self) -> T { + self.0 + } +} + +impl<T> Deref for QsForm<T> { + type Target = T; + + fn deref(&self) -> &T { + &self.0 + } +} + +impl<T> DerefMut for QsForm<T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.0 + } +} + +impl<T> FromRequest for QsForm<T> +where + T: DeserializeOwned + Debug, +{ + type Error = ActixError; + type Future = LocalBoxFuture<'static, Result<Self, ActixError>>; + #[cfg(feature = "actix3")] + type Config = QsQueryConfig; + + fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { + let mut stream = payload.take(); + let req_clone = req.clone(); + + let query_config: QsQueryConfig = req + .app_data::<QsQueryConfig>() + .unwrap_or(&DEFAULT_CONFIG) + .clone(); + async move { + let mut bytes = web::BytesMut::new(); + + while let Some(item) = stream.next().await { + bytes.extend_from_slice(&item.unwrap()); + } + + query_config + .qs_config + .deserialize_bytes::<T>(&bytes) + .map(|val| Ok(QsForm(val))) + .unwrap_or_else(|e| { + let e = if let Some(error_handler) = &query_config.ehandler { + (error_handler)(e, &req_clone) + } else { + e.into() + }; + + Err(e) + }) } + .boxed_local() } } diff --git a/src/axum.rs b/src/axum.rs index bba133c..3b603f3 100644 --- a/src/axum.rs +++ b/src/axum.rs @@ -43,7 +43,7 @@ use axum::{ /// } /// /// fn main() { -/// let app = Router::<(), Body>::new() +/// let app = Router::<()>::new() /// .route("/users", get(filter_users)); /// } pub struct QsQuery<T>(pub T); @@ -68,7 +68,6 @@ impl<T: std::fmt::Debug> std::fmt::Debug for QsQuery<T> { } } -#[axum::async_trait] impl<T, S> FromRequestParts<S> for QsQuery<T> where T: serde::de::DeserializeOwned, @@ -96,6 +95,89 @@ where } } +/// Extractor that differentiates between the absence and presence of the query string +/// using `Option<T>`. Absence of query string encoded as `None`. Otherwise, it behaves +/// identical to the `QsQuery`. +/// +/// ## Example +/// +/// ```rust +/// # extern crate axum_framework as axum; +/// use serde_qs::axum::OptionalQsQuery; +/// use serde_qs::Config; +/// use axum::{response::IntoResponse, routing::get, Router, body::Body}; +/// +/// #[derive(serde::Deserialize)] +/// pub struct UsersFilter { +/// id: Vec<u64>, +/// } +/// +/// async fn filter_users( +/// OptionalQsQuery(info): OptionalQsQuery<UsersFilter> +/// ) -> impl IntoResponse { +/// match info { +/// Some(info) => todo!("Select users based on query string"), +/// None => { todo!("No query string provided")} +/// } +/// } +/// +/// fn main() { +/// let app = Router::<()>::new() +/// .route("/users", get(filter_users)); +/// } +#[derive(Clone, Copy, Default)] +pub struct OptionalQsQuery<T>(pub Option<T>); + +impl<T> std::ops::Deref for OptionalQsQuery<T> { + type Target = Option<T>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<T> std::ops::DerefMut for OptionalQsQuery<T> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl<T: std::fmt::Debug> std::fmt::Debug for OptionalQsQuery<T> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl<T, S> FromRequestParts<S> for OptionalQsQuery<T> +where + T: serde::de::DeserializeOwned, + S: Send + Sync, +{ + type Rejection = QsQueryRejection; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &S, + ) -> Result<Self, Self::Rejection> { + let Extension(qs_config) = Extension::<QsQueryConfig>::from_request_parts(parts, state) + .await + .unwrap_or_else(|_| Extension(QsQueryConfig::default())); + if let Some(query) = parts.uri.query() { + let error_handler = qs_config.error_handler.clone(); + let config: QsConfig = qs_config.into(); + config + .deserialize_str::<T>(query) + .map(|query| OptionalQsQuery(Some(query))) + .map_err(|err| match error_handler { + Some(handler) => handler(err), + None => QsQueryRejection::new(err, StatusCode::BAD_REQUEST), + }) + } else { + Ok(OptionalQsQuery(None)) + } + } +} + #[derive(Debug)] /// Rejection type for extractors that deserialize query strings pub struct QsQueryRejection { @@ -178,7 +260,7 @@ impl std::error::Error for QsQueryRejection { /// } /// /// fn main() { -/// let app = Router::<(), Body>::new() +/// let app = Router::<()>::new() /// .route("/users", get(filter_users)) /// .layer(Extension(QsQueryConfig::new(5, false) /// .error_handler(|err| { diff --git a/src/de/mod.rs b/src/de/mod.rs index 150f6fa..969351e 100644 --- a/src/de/mod.rs +++ b/src/de/mod.rs @@ -81,6 +81,7 @@ use std::iter::Peekable; /// assert_eq!(map.get("a").unwrap().get("b").unwrap().get("c").unwrap(), "1"); /// ``` /// +#[derive(Clone, Copy)] pub struct Config { /// Specifies the maximum depth key that `serde_qs` will attempt to /// deserialize. Default is 5. @@ -89,9 +90,14 @@ pub struct Config { strict: bool, } +pub const DEFAULT_CONFIG: Config = Config { + max_depth: 5, + strict: true, +}; + impl Default for Config { fn default() -> Self { - Self::new(5, true) + DEFAULT_CONFIG } } @@ -190,7 +196,7 @@ pub fn from_str<'de, T: de::Deserialize<'de>>(input: &'de str) -> Result<T> { /// A deserializer for the querystring format. /// /// Supported top-level outputs are structs and maps. -pub(crate) struct QsDeserializer<'a> { +pub struct QsDeserializer<'a> { iter: Peekable<IntoIter<Cow<'a, str>, Level<'a>>>, value: Option<Level<'a>>, } @@ -214,9 +220,13 @@ impl<'a> QsDeserializer<'a> { } /// Returns a new `QsDeserializer<'a>`. - fn with_config(config: &Config, input: &'a [u8]) -> Result<Self> { + pub fn with_config(config: &Config, input: &'a [u8]) -> Result<Self> { parse::Parser::new(input, config.max_depth(), config.strict).as_deserializer() } + + pub fn new(input: &'a [u8]) -> Result<Self> { + Self::with_config(&Config::default(), input) + } } impl<'de> de::Deserializer<'de> for QsDeserializer<'de> { diff --git a/src/de/parse.rs b/src/de/parse.rs index 0080d02..3be7aa2 100644 --- a/src/de/parse.rs +++ b/src/de/parse.rs @@ -184,7 +184,7 @@ impl<'a> Iterator for Parser<'a> { } } -impl<'a> Parser<'a> { +impl Parser<'_> { #[inline] fn peek(&mut self) -> Option<<Self as Iterator>::Item> { if self.peeked.is_some() { @@ -268,7 +268,7 @@ impl<'a> Parser<'a> { Cow::Owned(owned) => Ok(Cow::Owned(owned)), }; self.clear_acc(); - ret.map_err(Error::from) + ret } /// In some ways the main way to use a `Parser`, this runs the parsing step @@ -186,7 +186,7 @@ #[macro_use] extern crate serde; -#[cfg(any(feature = "actix4", feature = "actix3", feature = "actix2"))] +#[cfg(any(feature = "actix4", feature = "actix3"))] pub mod actix; #[cfg(feature = "actix")] @@ -201,15 +201,20 @@ serde_qs = { version = "0.9", features = ["actix4"] } "# ); +#[cfg(feature = "actix2")] +compile_error!( + r#"The `actix2` feature was removed in v0.13 due to CI issues and minimal interest in continuing support"# +); + mod de; mod error; mod ser; pub(crate) mod utils; #[doc(inline)] -pub use de::Config; -#[doc(inline)] pub use de::{from_bytes, from_str}; +#[doc(inline)] +pub use de::{Config, QsDeserializer as Deserializer}; pub use error::Error; #[doc(inline)] pub use ser::{to_string, to_writer, Serializer}; @@ -279,7 +279,7 @@ impl<'a, W: 'a + Write> QsSerializer<'a, W> { write!( self.writer, "{}{}={}", - amp.then_some("&").unwrap_or_default(), + if amp { "&" } else { "" }, key, percent_encode(value, QS_ENCODE_SET) .map(replace_space) @@ -294,16 +294,11 @@ impl<'a, W: 'a + Write> QsSerializer<'a, W> { fn write_unit(&mut self) -> Result<()> { let amp = !self.first.swap(false, Ordering::Relaxed); if let Some(ref key) = self.key { - write!( - self.writer, - "{}{}=", - amp.then_some("&").unwrap_or_default(), - key, - ) - .map_err(Error::from) + write!(self.writer, "{}{}=", if amp { "&" } else { "" }, key,).map_err(Error::from) + } else if amp { + write!(self.writer, "&").map_err(Error::from) } else { - // For top level unit types - write!(self.writer, "{}", amp.then_some("&").unwrap_or_default(),).map_err(Error::from) + Ok(()) } } @@ -463,12 +458,12 @@ pub struct QsSeq<'a, W: 'a + Write>(QsSerializer<'a, W>, usize); #[doc(hidden)] pub struct QsMap<'a, W: 'a + Write>(QsSerializer<'a, W>, Option<Cow<'a, str>>); -impl<'a, W: Write> ser::SerializeTuple for QsSeq<'a, W> { +impl<W: Write> ser::SerializeTuple for QsSeq<'_, W> { type Ok = (); type Error = Error; - fn serialize_element<T: ?Sized>(&mut self, value: &T) -> Result<()> + fn serialize_element<T>(&mut self, value: &T) -> Result<()> where - T: ser::Serialize, + T: ser::Serialize + ?Sized, { let key = self.1.to_string(); self.1 += 1; @@ -482,12 +477,12 @@ impl<'a, W: Write> ser::SerializeTuple for QsSeq<'a, W> { } } -impl<'a, W: Write> ser::SerializeSeq for QsSeq<'a, W> { +impl<W: Write> ser::SerializeSeq for QsSeq<'_, W> { type Ok = (); type Error = Error; - fn serialize_element<T: ?Sized>(&mut self, value: &T) -> Result<()> + fn serialize_element<T>(&mut self, value: &T) -> Result<()> where - T: ser::Serialize, + T: ser::Serialize + ?Sized, { let mut serializer = QsSerializer::new_from_ref(&mut self.0); serializer.extend_key(&self.1.to_string()); @@ -499,12 +494,12 @@ impl<'a, W: Write> ser::SerializeSeq for QsSeq<'a, W> { } } -impl<'a, W: Write> ser::SerializeStruct for QsSerializer<'a, W> { +impl<W: Write> ser::SerializeStruct for QsSerializer<'_, W> { type Ok = (); type Error = Error; - fn serialize_field<T: ?Sized>(&mut self, key: &'static str, value: &T) -> Result<()> + fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()> where - T: ser::Serialize, + T: ser::Serialize + ?Sized, { let mut serializer = QsSerializer::new_from_ref(self); serializer.extend_key(key); @@ -515,13 +510,13 @@ impl<'a, W: Write> ser::SerializeStruct for QsSerializer<'a, W> { } } -impl<'a, W: Write> ser::SerializeStructVariant for QsSerializer<'a, W> { +impl<W: Write> ser::SerializeStructVariant for QsSerializer<'_, W> { type Ok = (); type Error = Error; - fn serialize_field<T: ?Sized>(&mut self, key: &'static str, value: &T) -> Result<()> + fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()> where - T: ser::Serialize, + T: ser::Serialize + ?Sized, { let mut serializer = QsSerializer::new_from_ref(self); serializer.extend_key(key); @@ -533,13 +528,13 @@ impl<'a, W: Write> ser::SerializeStructVariant for QsSerializer<'a, W> { } } -impl<'a, W: Write> ser::SerializeTupleVariant for QsSeq<'a, W> { +impl<W: Write> ser::SerializeTupleVariant for QsSeq<'_, W> { type Ok = (); type Error = Error; - fn serialize_field<T: ?Sized>(&mut self, value: &T) -> Result<()> + fn serialize_field<T>(&mut self, value: &T) -> Result<()> where - T: ser::Serialize, + T: ser::Serialize + ?Sized, { let mut serializer = QsSerializer::new_from_ref(&mut self.0); serializer.extend_key(&self.1.to_string()); @@ -552,13 +547,13 @@ impl<'a, W: Write> ser::SerializeTupleVariant for QsSeq<'a, W> { } } -impl<'a, W: Write> ser::SerializeTupleStruct for QsSeq<'a, W> { +impl<W: Write> ser::SerializeTupleStruct for QsSeq<'_, W> { type Ok = (); type Error = Error; - fn serialize_field<T: ?Sized>(&mut self, value: &T) -> Result<()> + fn serialize_field<T>(&mut self, value: &T) -> Result<()> where - T: ser::Serialize, + T: ser::Serialize + ?Sized, { let mut serializer = QsSerializer::new_from_ref(&mut self.0); serializer.extend_key(&self.1.to_string()); @@ -571,21 +566,21 @@ impl<'a, W: Write> ser::SerializeTupleStruct for QsSeq<'a, W> { } } -impl<'a, W: Write> ser::SerializeMap for QsMap<'a, W> { +impl<W: Write> ser::SerializeMap for QsMap<'_, W> { type Ok = (); type Error = Error; - fn serialize_key<T: ?Sized>(&mut self, key: &T) -> Result<()> + fn serialize_key<T>(&mut self, key: &T) -> Result<()> where - T: ser::Serialize, + T: ser::Serialize + ?Sized, { self.1 = Some(Cow::from(key.serialize(StringSerializer)?)); Ok(()) } - fn serialize_value<T: ?Sized>(&mut self, value: &T) -> Result<()> + fn serialize_value<T>(&mut self, value: &T) -> Result<()> where - T: ser::Serialize, + T: ser::Serialize + ?Sized, { let mut serializer = QsSerializer::new_from_ref(&mut self.0); if let Some(ref key) = self.1 { @@ -601,10 +596,10 @@ impl<'a, W: Write> ser::SerializeMap for QsMap<'a, W> { Ok(()) } - fn serialize_entry<K: ?Sized, V: ?Sized>(&mut self, key: &K, value: &V) -> Result<()> + fn serialize_entry<K, V>(&mut self, key: &K, value: &V) -> Result<()> where - K: ser::Serialize, - V: ser::Serialize, + K: ser::Serialize + ?Sized, + V: ser::Serialize + ?Sized, { let mut serializer = QsSerializer::new_from_ref(&mut self.0); serializer.extend_key(&key.serialize(StringSerializer)?); diff --git a/tests/test_actix.rs b/tests/test_actix.rs index 792afb2..dcc5980 100644 --- a/tests/test_actix.rs +++ b/tests/test_actix.rs @@ -16,7 +16,7 @@ use actix_web::error::InternalError; use actix_web::http::StatusCode; use actix_web::test::TestRequest; use actix_web::{FromRequest, HttpResponse}; -use qs::actix::{QsQuery, QsQueryConfig}; +use qs::actix::{QsForm, QsQuery, QsQueryConfig}; use qs::Config as QsConfig; use serde::de::Error; @@ -26,7 +26,7 @@ where S: std::str::FromStr, { let s = <&str as serde::Deserialize>::deserialize(deserializer)?; - S::from_str(&s).map_err(|_| D::Error::custom("could not parse string")) + S::from_str(s).map_err(|_| D::Error::custom("could not parse string")) } #[derive(Deserialize, Serialize, Debug, PartialEq)] @@ -103,7 +103,7 @@ fn test_composite_querystring_extractor() { assert_eq!(s.bars, vec![0, 1]); assert_eq!(s.common.limit, 100); assert_eq!(s.common.offset, 50); - assert_eq!(s.common.remaining, true); + assert!(s.common.remaining); }) } @@ -140,6 +140,28 @@ fn test_custom_qs_config() { assert_eq!(s.bars, vec![3]); assert_eq!(s.common.limit, 100); assert_eq!(s.common.offset, 50); - assert_eq!(s.common.remaining, true); + assert!(s.common.remaining); + }) +} + +#[test] +fn test_form_extractor() { + futures::executor::block_on(async { + let test_data = Query { + foo: 1, + bars: vec![0, 1], + common: CommonParams { + limit: 100, + offset: 50, + remaining: true, + }, + }; + let req = TestRequest::with_uri("/test") + .set_payload(serde_qs::to_string(&test_data).unwrap()) + .to_srv_request(); + let (req, mut pl) = req.into_parts(); + + let s = QsForm::<Query>::from_request(&req, &mut pl).await.unwrap(); + assert_eq!(s.into_inner(), test_data); }) } diff --git a/tests/test_axum.rs b/tests/test_axum.rs index 16449b9..dd86a9e 100644 --- a/tests/test_axum.rs +++ b/tests/test_axum.rs @@ -8,7 +8,7 @@ extern crate axum_framework as axum; extern crate serde_qs as qs; use axum::{extract::FromRequestParts, http::StatusCode, response::IntoResponse}; -use qs::axum::{QsQuery, QsQueryConfig, QsQueryRejection}; +use qs::axum::{OptionalQsQuery, QsQuery, QsQueryConfig, QsQueryRejection}; use serde::de::Error; fn from_str<'de, D, S>(deserializer: D) -> Result<S, D::Error> @@ -132,3 +132,43 @@ fn test_custom_qs_config() { assert!(s.common.remaining); }) } + +#[test] +fn test_optional_query_none() { + futures::executor::block_on(async { + let req = axum::http::Request::builder() + .uri("/test") + .body(()) + .unwrap(); + let (mut req_parts, _) = req.into_parts(); + + let OptionalQsQuery(s) = OptionalQsQuery::<Query>::from_request_parts(&mut req_parts, &()) + .await + .unwrap(); + + assert!(s.is_none()); + }) +} + +#[test] +fn test_optional_query_some() { + futures::executor::block_on(async { + let req = axum::http::Request::builder() + .uri("/test?foo=1&bars%5B%5D=3&limit=100&offset=50&remaining=true") + .extension(QsQueryConfig::new(5, false)) + .body(()) + .unwrap(); + + let (mut req_parts, _) = req.into_parts(); + let OptionalQsQuery(s) = OptionalQsQuery::<Query>::from_request_parts(&mut req_parts, &()) + .await + .unwrap(); + + let query = s.unwrap(); + assert_eq!(query.foo, 1); + assert_eq!(query.bars, vec![3]); + assert_eq!(query.common.limit, 100); + assert_eq!(query.common.offset, 50); + assert!(query.common.remaining); + }) +} diff --git a/tests/test_deserialize.rs b/tests/test_deserialize.rs index 12c4a8c..2e79ef0 100644 --- a/tests/test_deserialize.rs +++ b/tests/test_deserialize.rs @@ -81,7 +81,7 @@ fn deserialize_struct() { user_ids: vec![1, 2, 3, 4], }; - for config in vec![qs::Config::new(5, true), qs::Config::new(5, false)] { + for config in [qs::Config::new(5, true), qs::Config::new(5, false)] { // standard parameters let rec_params: QueryParams = config .deserialize_str( diff --git a/tests/test_warp.rs b/tests/test_warp.rs index 76bfd80..ca56ded 100644 --- a/tests/test_warp.rs +++ b/tests/test_warp.rs @@ -17,7 +17,7 @@ where S: std::str::FromStr, { let s = <&str as serde::Deserialize>::deserialize(deserializer)?; - S::from_str(&s).map_err(|_| D::Error::custom("could not parse string")) + S::from_str(s).map_err(|_| D::Error::custom("could not parse string")) } #[derive(Deserialize, Serialize, Debug, PartialEq)] @@ -65,7 +65,7 @@ fn test_composite_querystring_extractor() { assert_eq!(s.bars, vec![0, 1]); assert_eq!(s.common.limit, 100); assert_eq!(s.common.offset, 50); - assert_eq!(s.common.remaining, true); + assert!(s.common.remaining); }) } @@ -99,6 +99,6 @@ fn test_custom_qs_config() { assert_eq!(s.bars, vec![3]); assert_eq!(s.common.limit, 100); assert_eq!(s.common.offset, 50); - assert_eq!(s.common.remaining, true); + assert!(s.common.remaining); }) } |