From 73b26c64b3f84939b8c63bf0b851a14be4856e58 Mon Sep 17 00:00:00 2001 From: Patrick Elsen Date: Tue, 8 Aug 2023 16:21:49 +0200 Subject: [PATCH] Introduces the FromQuery and IntoQuery traits. 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 | 34 +++++----- crates/history/src/browser.rs | 50 +++++++------- crates/history/src/error.rs | 2 +- crates/history/src/hash.rs | 48 +++++++------ crates/history/src/history.rs | 28 ++++---- crates/history/src/lib.rs | 2 + crates/history/src/location.rs | 13 ++-- crates/history/src/memory.rs | 52 +++++++------- crates/history/src/query.rs | 120 +++++++++++++++++++++++++++++++++ crates/history/tests/query.rs | 52 ++++++++++++++ 11 files changed, 289 insertions(+), 114 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 9b0f1c63..79625c19 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 `IntoQuery` traits to allow for customizing + how query strings are encoded and decoded in `gloo_history`. ### Version "0.2.3" diff --git a/crates/history/src/any.rs b/crates/history/src/any.rs index 822f91e5..b0ea727a 100644 --- a/crates/history/src/any.rs +++ b/crates/history/src/any.rs @@ -1,16 +1,12 @@ use std::borrow::Cow; #[cfg(feature = "query")] -use serde::Serialize; +use crate::{error::HistoryResult, query::IntoQuery}; -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; +use crate::{ + browser::BrowserHistory, hash::HashHistory, history::History, listener::HistoryListener, + location::Location, memory::MemoryHistory, +}; /// A [`History`] that provides a universal API to the underlying history type. #[derive(Clone, PartialEq, Debug)] @@ -79,9 +75,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: IntoQuery, { match self { Self::Browser(m) => m.push_with_query(route, query), @@ -94,9 +94,9 @@ impl History for AnyHistory { &self, route: impl Into>, query: Q, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, { match self { Self::Browser(m) => m.replace_with_query(route, query), @@ -111,9 +111,9 @@ impl History for AnyHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, T: 'static, { match self { @@ -129,9 +129,9 @@ impl History for AnyHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, T: 'static, { match self { diff --git a/crates/history/src/browser.rs b/crates/history/src/browser.rs index 2664f18d..75c7f38d 100644 --- a/crates/history/src/browser.rs +++ b/crates/history/src/browser.rs @@ -1,23 +1,19 @@ -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; +use crate::{error::HistoryResult, query::IntoQuery}; +use crate::{ + history::History, + listener::HistoryListener, + location::Location, + state::{HistoryState, StateMap}, + utils::WeakCallback, +}; /// A [`History`] that is implemented with [`web_sys::History`] that provides native browser /// history and state access. @@ -109,12 +105,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: IntoQuery, { let route = route.into(); - let query = serde_urlencoded::to_string(query)?; + let query = query.encode()?; let url = Self::combine_url(&route, &query); @@ -131,12 +131,12 @@ impl History for BrowserHistory { &self, route: impl Into>, query: Q, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, { let route = route.into(); - let query = serde_urlencoded::to_string(query)?; + let query = query.encode()?; let url = Self::combine_url(&route, &query); @@ -154,9 +154,9 @@ impl History for BrowserHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, T: 'static, { let (id, history_state) = Self::create_history_state(); @@ -165,7 +165,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.encode()?; let url = Self::combine_url(&route, &query); @@ -184,9 +184,9 @@ impl History for BrowserHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, T: 'static, { let (id, history_state) = Self::create_history_state(); @@ -195,7 +195,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.encode()?; 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..3b18cef1 100644 --- a/crates/history/src/hash.rs +++ b/crates/history/src/hash.rs @@ -1,19 +1,19 @@ -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}; +use crate::{error::HistoryResult, query::IntoQuery}; + +use crate::{ + browser::BrowserHistory, + history::History, + listener::HistoryListener, + location::Location, + utils::{assert_absolute_path, assert_no_query}, +}; /// A [`History`] that is implemented with [`web_sys::History`] and stores path in `#`(fragment). /// @@ -95,11 +95,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: IntoQuery, { - let query = serde_urlencoded::to_string(query)?; + let query = query.encode()?; let route = route.into(); assert_absolute_path(&route); @@ -116,11 +120,11 @@ impl History for HashHistory { &self, route: impl Into>, query: Q, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, { - let query = serde_urlencoded::to_string(query)?; + let query = query.encode()?; let route = route.into(); assert_absolute_path(&route); @@ -139,9 +143,9 @@ impl History for HashHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, T: 'static, { let route = route.into(); @@ -151,7 +155,7 @@ impl History for HashHistory { let url = Self::get_url(); - let query = serde_urlencoded::to_string(query)?; + let query = query.encode()?; url.set_hash(&format!("{route}?{query}")); self.inner.push_with_state(url.href(), state); @@ -165,9 +169,9 @@ impl History for HashHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, T: 'static, { let route = route.into(); @@ -177,7 +181,7 @@ impl History for HashHistory { let url = Self::get_url(); - let query = serde_urlencoded::to_string(query)?; + let query = query.encode()?; 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..10ce6739 100644 --- a/crates/history/src/history.rs +++ b/crates/history/src/history.rs @@ -1,12 +1,8 @@ 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; +use crate::{error::HistoryResult, query::IntoQuery}; +use crate::{listener::HistoryListener, location::Location}; /// A trait to provide [`History`] access. /// @@ -56,9 +52,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: IntoQuery; /// Same as `.replace()` but affix the queries to the end of the route. #[cfg(feature = "query")] @@ -66,9 +66,9 @@ pub trait History: Clone + PartialEq { &self, route: impl Into>, query: Q, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize; + Q: IntoQuery; /// Same as `.push_with_state()` but affix the queries to the end of the route. #[cfg(feature = "query")] @@ -77,9 +77,9 @@ pub trait History: Clone + PartialEq { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, T: 'static; /// Same as `.replace_with_state()` but affix the queries to the end of the route. @@ -89,9 +89,9 @@ pub trait History: Clone + PartialEq { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, 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..72509419 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::decode(query) } /// Returns the hash fragment of current URL. diff --git a/crates/history/src/memory.rs b/crates/history/src/memory.rs index cc19b442..5f8ea7d8 100644 --- a/crates/history/src/memory.rs +++ b/crates/history/src/memory.rs @@ -1,21 +1,15 @@ -use std::any::Any; -use std::borrow::Cow; -use std::cell::RefCell; -use std::cmp::Ordering; -use std::collections::VecDeque; -use std::fmt; -use std::rc::Rc; +use std::{ + any::Any, borrow::Cow, cell::RefCell, cmp::Ordering, collections::VecDeque, fmt, rc::Rc, +}; #[cfg(feature = "query")] -use serde::Serialize; +use crate::{error::HistoryResult, query::IntoQuery}; -#[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, +use crate::{ + history::History, + listener::HistoryListener, + location::Location, + utils::{assert_absolute_path, assert_no_fragment, assert_no_query, get_id, WeakCallback}, }; /// A History Stack. @@ -210,11 +204,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: IntoQuery, { - let query = serde_urlencoded::to_string(query)?; + let query = query.encode()?; let route = route.into(); assert_absolute_path(&route); @@ -240,11 +238,11 @@ impl History for MemoryHistory { &self, route: impl Into>, query: Q, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, { - let query = serde_urlencoded::to_string(query)?; + let query = query.encode()?; let route = route.into(); assert_absolute_path(&route); @@ -272,12 +270,12 @@ impl History for MemoryHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, T: 'static, { - let query = serde_urlencoded::to_string(query)?; + let query = query.encode()?; let route = route.into(); assert_absolute_path(&route); @@ -305,12 +303,12 @@ impl History for MemoryHistory { route: impl Into>, query: Q, state: T, - ) -> HistoryResult<()> + ) -> HistoryResult<(), Q::Error> where - Q: Serialize, + Q: IntoQuery, T: 'static, { - let query = serde_urlencoded::to_string(query)?; + let query = query.encode()?; 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..7b808f0d --- /dev/null +++ b/crates/history/src/query.rs @@ -0,0 +1,120 @@ +//! # Encoding and decoding strategies for query strings. +//! +//! There are various strategies to map Rust types into HTTP query strings. The [`FromQuery`] and +//! [`IntoQuery`] 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 [`IntoQuery`] 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(); +//! ``` +//! +//! ## Other Strategies +//! +//! Depending on the features enabled, this module defines alternative strategies using wrapper +//! types. These wrapper types can be used to wrap your struct to modify how it is encoded and +//! decode. See the documentation for the structs in this module for further information. +//! +//! ## Custom Strategy +//! +//! If desired, the [`FromQuery`] and [`IntoQuery`] 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::{Borrow, Cow}, + convert::Infallible, +}; + +/// Type that can be encoded into a query string. +pub trait IntoQuery { + /// Error that can be returned from the conversion. + type Error; + + /// Method to encode the query into a string. + fn encode(&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 decode(query: &str) -> Result; +} + +impl IntoQuery for T { + type Error = HistoryError; + + fn encode(&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 decode(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> IntoQuery for Raw { + type Error = Infallible; + + fn encode(&self) -> Result, Self::Error> { + Ok(self.0.borrow().into()) + } +} + +impl FromQuery for Raw { + type Target = String; + type Error = Infallible; + + fn decode(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..9bdf4ba2 --- /dev/null +++ b/crates/history/tests/query.rs @@ -0,0 +1,52 @@ +#![cfg(feature = "query")] +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.encode().unwrap(), "name=value&other=that"); +} + +#[test] +fn test_raw_decode_simple() { + let query = "name=value&other=that"; + let decoded = >::decode(&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.encode().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::decode(&encoded).unwrap(); + assert_eq!( + data, + SimpleQuery { + string: "test".into(), + number: 42, + optional: None, + boolean: true, + } + ); +}