From ce9779a5b207eb79cddfcf3664740c041b9df489 Mon Sep 17 00:00:00 2001 From: Patrick Elsen Date: Fri, 11 Aug 2023 17:24:40 +0200 Subject: [PATCH] Introduces the FromQuery and IntoQuery traits. (#364) These two traits are introduced to allow for customizing the query encoding and decoding behaviour by implementing these traits for custom types. The previous behaviour of using serde_urlencoded for anything that implements Serialize and Deserialize is preserved. Customt wrapper types are added which modify the encoding and decoding behaviour. An optional feature named `query-qs` is introduced which allows for optionally encoding and decoding query strings using serde_qs. --- CHANGELOG.md | 2 + crates/history/src/any.rs | 27 ++++---- crates/history/src/browser.rs | 40 ++++++------ crates/history/src/error.rs | 2 +- crates/history/src/hash.rs | 37 +++++------ crates/history/src/history.rs | 27 ++++---- crates/history/src/lib.rs | 2 + crates/history/src/location.rs | 13 ++-- crates/history/src/memory.rs | 35 ++++++----- crates/history/src/query.rs | 112 +++++++++++++++++++++++++++++++++ crates/history/tests/query.rs | 58 +++++++++++++++++ 11 files changed, 264 insertions(+), 91 deletions(-) create mode 100644 crates/history/src/query.rs create mode 100644 crates/history/tests/query.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d37b39cf..db58f977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ### Next Version - Migrate to Edition 2021 and Apply MSRV in Cargo.toml (#360) +- Introduces the `FromQuery` and `ToQuery` traits to allow for customizing + how query strings are encoded and decoded in `gloo_history`. (#364) ### Version "0.2.3" diff --git a/crates/history/src/any.rs b/crates/history/src/any.rs index 822f91e5..74e9ec43 100644 --- a/crates/history/src/any.rs +++ b/crates/history/src/any.rs @@ -1,16 +1,13 @@ use std::borrow::Cow; -#[cfg(feature = "query")] -use serde::Serialize; - use crate::browser::BrowserHistory; -#[cfg(feature = "query")] -use crate::error::HistoryResult; use crate::hash::HashHistory; use crate::history::History; use crate::listener::HistoryListener; use crate::location::Location; use crate::memory::MemoryHistory; +#[cfg(feature = "query")] +use crate::{error::HistoryResult, query::ToQuery}; /// A [`History`] that provides a universal API to the underlying history type. #[derive(Clone, PartialEq, Debug)] @@ -79,9 +76,13 @@ impl History for AnyHistory { } #[cfg(feature = "query")] - fn push_with_query<'a, Q>(&self, route: impl Into>, query: Q) -> HistoryResult<()> + fn push_with_query<'a, Q>( + &self, + route: impl Into>, + query: Q, + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, { match self { Self::Browser(m) => m.push_with_query(route, query), @@ -94,9 +95,9 @@ impl History for AnyHistory { &self, route: impl Into>, query: Q, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, { match self { Self::Browser(m) => m.replace_with_query(route, query), @@ -111,9 +112,9 @@ impl History for AnyHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, T: 'static, { match self { @@ -129,9 +130,9 @@ impl History for AnyHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, T: 'static, { match self { diff --git a/crates/history/src/browser.rs b/crates/history/src/browser.rs index 2664f18d..c46d99e0 100644 --- a/crates/history/src/browser.rs +++ b/crates/history/src/browser.rs @@ -1,23 +1,17 @@ -use std::any::Any; -use std::borrow::Cow; -use std::cell::RefCell; -use std::fmt; -use std::rc::Rc; +use std::{any::Any, borrow::Cow, cell::RefCell, fmt, rc::Rc}; use gloo_events::EventListener; use gloo_utils::window; -#[cfg(feature = "query")] -use serde::Serialize; use wasm_bindgen::{JsValue, UnwrapThrowExt}; use web_sys::Url; -#[cfg(feature = "query")] -use crate::error::HistoryResult; use crate::history::History; use crate::listener::HistoryListener; use crate::location::Location; use crate::state::{HistoryState, StateMap}; use crate::utils::WeakCallback; +#[cfg(feature = "query")] +use crate::{error::HistoryResult, query::ToQuery}; /// A [`History`] that is implemented with [`web_sys::History`] that provides native browser /// history and state access. @@ -109,12 +103,16 @@ impl History for BrowserHistory { } #[cfg(feature = "query")] - fn push_with_query<'a, Q>(&self, route: impl Into>, query: Q) -> HistoryResult<()> + fn push_with_query<'a, Q>( + &self, + route: impl Into>, + query: Q, + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, { let route = route.into(); - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; let url = Self::combine_url(&route, &query); @@ -131,12 +129,12 @@ impl History for BrowserHistory { &self, route: impl Into>, query: Q, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, { let route = route.into(); - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; let url = Self::combine_url(&route, &query); @@ -154,9 +152,9 @@ impl History for BrowserHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, T: 'static, { let (id, history_state) = Self::create_history_state(); @@ -165,7 +163,7 @@ impl History for BrowserHistory { states.insert(id, Rc::new(state) as Rc); let route = route.into(); - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; let url = Self::combine_url(&route, &query); @@ -184,9 +182,9 @@ impl History for BrowserHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, T: 'static, { let (id, history_state) = Self::create_history_state(); @@ -195,7 +193,7 @@ impl History for BrowserHistory { states.insert(id, Rc::new(state) as Rc); let route = route.into(); - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; let url = Self::combine_url(&route, &query); diff --git a/crates/history/src/error.rs b/crates/history/src/error.rs index a4bfa4dc..c77a1e05 100644 --- a/crates/history/src/error.rs +++ b/crates/history/src/error.rs @@ -14,4 +14,4 @@ pub enum HistoryError { } /// The Result type for History. -pub type HistoryResult = std::result::Result; +pub type HistoryResult = std::result::Result; diff --git a/crates/history/src/hash.rs b/crates/history/src/hash.rs index e3192b21..792b43a6 100644 --- a/crates/history/src/hash.rs +++ b/crates/history/src/hash.rs @@ -1,19 +1,16 @@ -use std::borrow::Cow; -use std::fmt; +use std::{borrow::Cow, fmt}; use gloo_utils::window; -#[cfg(feature = "query")] -use serde::Serialize; use wasm_bindgen::UnwrapThrowExt; use web_sys::Url; use crate::browser::BrowserHistory; -#[cfg(feature = "query")] -use crate::error::HistoryResult; use crate::history::History; use crate::listener::HistoryListener; use crate::location::Location; use crate::utils::{assert_absolute_path, assert_no_query}; +#[cfg(feature = "query")] +use crate::{error::HistoryResult, query::ToQuery}; /// A [`History`] that is implemented with [`web_sys::History`] and stores path in `#`(fragment). /// @@ -95,11 +92,15 @@ impl History for HashHistory { } #[cfg(feature = "query")] - fn push_with_query<'a, Q>(&self, route: impl Into>, query: Q) -> HistoryResult<()> + fn push_with_query<'a, Q>( + &self, + route: impl Into>, + query: Q, + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, { - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; let route = route.into(); assert_absolute_path(&route); @@ -116,11 +117,11 @@ impl History for HashHistory { &self, route: impl Into>, query: Q, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, { - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; let route = route.into(); assert_absolute_path(&route); @@ -139,9 +140,9 @@ impl History for HashHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, T: 'static, { let route = route.into(); @@ -151,7 +152,7 @@ impl History for HashHistory { let url = Self::get_url(); - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; url.set_hash(&format!("{route}?{query}")); self.inner.push_with_state(url.href(), state); @@ -165,9 +166,9 @@ impl History for HashHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, T: 'static, { let route = route.into(); @@ -177,7 +178,7 @@ impl History for HashHistory { let url = Self::get_url(); - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; url.set_hash(&format!("{route}?{query}")); self.inner.replace_with_state(url.href(), state); diff --git a/crates/history/src/history.rs b/crates/history/src/history.rs index 52ee1804..d5616423 100644 --- a/crates/history/src/history.rs +++ b/crates/history/src/history.rs @@ -1,12 +1,9 @@ use std::borrow::Cow; -#[cfg(feature = "query")] -use serde::Serialize; - -#[cfg(feature = "query")] -use crate::error::HistoryResult; use crate::listener::HistoryListener; use crate::location::Location; +#[cfg(feature = "query")] +use crate::{error::HistoryResult, query::ToQuery}; /// A trait to provide [`History`] access. /// @@ -56,9 +53,13 @@ pub trait History: Clone + PartialEq { /// Same as `.push()` but affix the queries to the end of the route. #[cfg(feature = "query")] - fn push_with_query<'a, Q>(&self, route: impl Into>, query: Q) -> HistoryResult<()> + fn push_with_query<'a, Q>( + &self, + route: impl Into>, + query: Q, + ) -> HistoryResult<(), Q::Error> where - Q: Serialize; + Q: ToQuery; /// Same as `.replace()` but affix the queries to the end of the route. #[cfg(feature = "query")] @@ -66,9 +67,9 @@ pub trait History: Clone + PartialEq { &self, route: impl Into>, query: Q, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize; + Q: ToQuery; /// Same as `.push_with_state()` but affix the queries to the end of the route. #[cfg(feature = "query")] @@ -77,9 +78,9 @@ pub trait History: Clone + PartialEq { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, T: 'static; /// Same as `.replace_with_state()` but affix the queries to the end of the route. @@ -89,9 +90,9 @@ pub trait History: Clone + PartialEq { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, T: 'static; /// Creates a Listener that will be notified when current state changes. diff --git a/crates/history/src/lib.rs b/crates/history/src/lib.rs index 70aea8a2..9f508808 100644 --- a/crates/history/src/lib.rs +++ b/crates/history/src/lib.rs @@ -12,6 +12,8 @@ mod history; mod listener; mod location; mod memory; +#[cfg(feature = "query")] +pub mod query; mod state; mod utils; diff --git a/crates/history/src/location.rs b/crates/history/src/location.rs index 9c0abf80..4b97d709 100644 --- a/crates/history/src/location.rs +++ b/crates/history/src/location.rs @@ -2,10 +2,7 @@ use std::any::Any; use std::rc::Rc; #[cfg(feature = "query")] -use serde::de::DeserializeOwned; - -#[cfg(feature = "query")] -use crate::error::HistoryResult; +use crate::{error::HistoryResult, query::FromQuery}; /// A history location. /// @@ -44,12 +41,12 @@ impl Location { /// Returns the queries of current URL parsed as `T`. #[cfg(feature = "query")] - pub fn query(&self) -> HistoryResult + pub fn query(&self) -> HistoryResult where - T: DeserializeOwned, + T: FromQuery, { - let query = self.query_str(); - serde_urlencoded::from_str(query.strip_prefix('?').unwrap_or("")).map_err(|e| e.into()) + let query = self.query_str().strip_prefix('?').unwrap_or(""); + T::from_query(query) } /// Returns the hash fragment of current URL. diff --git a/crates/history/src/memory.rs b/crates/history/src/memory.rs index cc19b442..4e3b4edc 100644 --- a/crates/history/src/memory.rs +++ b/crates/history/src/memory.rs @@ -6,17 +6,14 @@ use std::collections::VecDeque; use std::fmt; use std::rc::Rc; -#[cfg(feature = "query")] -use serde::Serialize; - -#[cfg(feature = "query")] -use crate::error::HistoryResult; use crate::history::History; use crate::listener::HistoryListener; use crate::location::Location; use crate::utils::{ assert_absolute_path, assert_no_fragment, assert_no_query, get_id, WeakCallback, }; +#[cfg(feature = "query")] +use crate::{error::HistoryResult, query::ToQuery}; /// A History Stack. #[derive(Debug)] @@ -210,11 +207,15 @@ impl History for MemoryHistory { } #[cfg(feature = "query")] - fn push_with_query<'a, Q>(&self, route: impl Into>, query: Q) -> HistoryResult<()> + fn push_with_query<'a, Q>( + &self, + route: impl Into>, + query: Q, + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, { - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; let route = route.into(); assert_absolute_path(&route); @@ -240,11 +241,11 @@ impl History for MemoryHistory { &self, route: impl Into>, query: Q, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, { - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; let route = route.into(); assert_absolute_path(&route); @@ -272,12 +273,12 @@ impl History for MemoryHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, T: 'static, { - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; let route = route.into(); assert_absolute_path(&route); @@ -305,12 +306,12 @@ impl History for MemoryHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: ToQuery, T: 'static, { - let query = serde_urlencoded::to_string(query)?; + let query = query.to_query()?; let route = route.into(); assert_absolute_path(&route); diff --git a/crates/history/src/query.rs b/crates/history/src/query.rs new file mode 100644 index 00000000..1afeb16a --- /dev/null +++ b/crates/history/src/query.rs @@ -0,0 +1,112 @@ +//! # Encoding and decoding strategies for query strings. +//! +//! There are various strategies to map Rust types into HTTP query strings. The [`FromQuery`] and +//! [`ToQuery`] encode the logic for how this encoding and decoding is performed. These traits +//! are public as a form of dependency inversion, so that you can override the decoding and +//! encoding strategy being used. +//! +//! These traits are used by the [`History`](crate::History) trait, which allows for modifying the +//! history state, and the [`Location`](crate::Location) struct, which allows for extracting the +//! current location (and this query). +//! +//! ## Default Strategy +//! +//! By default, any Rust type that implements [`Serialize`] or [`Deserialize`](serde::Deserialize) +//! has an implementation of [`ToQuery`] or [`FromQuery`], respectively. This implementation uses +//! the `serde_urlencoded` crate, which implements a standards-compliant `x-www-form-urlencoded` +//! encoder and decoder. Some patterns are not supported by this crate, for example it is not +//! possible to serialize arrays at the moment. If this is an issue for you, consider using the +//! `serde_qs` crate. +//! +//! Example: +//! +//! ```rust,no_run +//! use serde::{Serialize, Deserialize}; +//! use gloo_history::{MemoryHistory, History}; +//! +//! #[derive(Serialize)] +//! struct Query { +//! name: String, +//! } +//! +//! let query = Query { +//! name: "user".into(), +//! }; +//! +//! let history = MemoryHistory::new(); +//! history.push_with_query("index.html", &query).unwrap(); +//! ``` +//! +//! ## Custom Strategy +//! +//! If desired, the [`FromQuery`] and [`ToQuery`] traits can also be manually implemented on +//! types to customize the encoding and decoding strategies. See the documentation for these traits +//! for more detail on how this can be done. +use crate::error::HistoryError; +use serde::{de::DeserializeOwned, Serialize}; +use std::borrow::Cow; +use std::convert::{AsRef, Infallible}; + +/// Type that can be encoded into a query string. +pub trait ToQuery { + /// Error that can be returned from the conversion. + type Error; + + /// Method to encode the query into a string. + fn to_query(&self) -> Result, Self::Error>; +} + +/// Type that can be decoded from a query string. +pub trait FromQuery { + /// Target type after parsing. + type Target; + /// Error that can occur while parsing. + type Error; + + /// Decode this query string into the target type. + fn from_query(query: &str) -> Result; +} + +impl ToQuery for T { + type Error = HistoryError; + + fn to_query(&self) -> Result, Self::Error> { + serde_urlencoded::to_string(self) + .map(Into::into) + .map_err(Into::into) + } +} + +impl FromQuery for T { + type Target = T; + type Error = HistoryError; + + fn from_query(query: &str) -> Result { + serde_urlencoded::from_str(query).map_err(Into::into) + } +} + +/// # Encoding for raw query strings. +/// +/// The [`Raw`] wrapper allows for specifying a query string directly, bypassing the encoding. If +/// you use this strategy, you need to take care to escape characters that are not allowed to +/// appear in query strings yourself. +#[derive(Debug, Clone)] +pub struct Raw(pub T); + +impl> ToQuery for Raw { + type Error = Infallible; + + fn to_query(&self) -> Result, Self::Error> { + Ok(self.0.as_ref().into()) + } +} + +impl From<&'a str>> FromQuery for Raw { + type Target = T; + type Error = Infallible; + + fn from_query(query: &str) -> Result { + Ok(query.into()) + } +} diff --git a/crates/history/tests/query.rs b/crates/history/tests/query.rs new file mode 100644 index 00000000..d721d7d4 --- /dev/null +++ b/crates/history/tests/query.rs @@ -0,0 +1,58 @@ +#![cfg(feature = "query")] + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure}; +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test_configure!(run_in_browser); + +use gloo_history::query::*; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +struct SimpleQuery { + string: String, + number: u64, + optional: Option, + boolean: bool, +} + +#[test] +fn test_raw_encode_simple() { + let query = Raw("name=value&other=that"); + assert_eq!(query.to_query().unwrap(), "name=value&other=that"); +} + +#[test] +fn test_raw_decode_simple() { + let query = "name=value&other=that"; + let decoded = >::from_query(&query).unwrap(); + assert_eq!(decoded, query); +} + +#[test] +fn test_urlencoded_encode_simple() { + let query = SimpleQuery { + string: "test".into(), + number: 42, + optional: None, + boolean: true, + }; + + let encoded = query.to_query().unwrap(); + assert_eq!(encoded, "string=test&number=42&boolean=true"); +} + +#[test] +fn test_urlencoded_decode_simple() { + let encoded = "string=test&number=42&boolean=true"; + let data = SimpleQuery::from_query(&encoded).unwrap(); + assert_eq!( + data, + SimpleQuery { + string: "test".into(), + number: 42, + optional: None, + boolean: true, + } + ); +}