summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci.yml73
-rw-r--r--.github/workflows/release-plz.yml53
-rw-r--r--CHANGELOG.md19
-rw-r--r--Cargo.toml23
-rw-r--r--README.md14
-rw-r--r--examples/csv_vectors.rs56
-rw-r--r--examples/introduction.rs2
-rw-r--r--src/actix.rs156
-rw-r--r--src/axum.rs88
-rw-r--r--src/de/mod.rs16
-rw-r--r--src/de/parse.rs4
-rw-r--r--src/lib.rs11
-rw-r--r--src/ser.rs67
-rw-r--r--tests/test_actix.rs30
-rw-r--r--tests/test_axum.rs42
-rw-r--r--tests/test_deserialize.rs2
-rw-r--r--tests/test_warp.rs6
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`
diff --git a/Cargo.toml b/Cargo.toml
index c657305..a667c55 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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
diff --git a/README.md b/README.md
index 5b8917b..39d0f25 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/lib.rs b/src/lib.rs
index 82ba41a..e816839 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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};
diff --git a/src/ser.rs b/src/ser.rs
index 607fa0d..5c21d92 100644
--- a/src/ser.rs
+++ b/src/ser.rs
@@ -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);
})
}