From 2c8ece6bf25b297efd82bdb88170bc156b28fc22 Mon Sep 17 00:00:00 2001 From: Benjamin Bouvier Date: Thu, 6 Jul 2023 14:34:43 +0200 Subject: [PATCH] events: Parse `TagInfo::order` as a f64 or a stringified f64 --- crates/ruma-common/CHANGELOG.md | 2 + crates/ruma-common/Cargo.toml | 3 ++ crates/ruma-common/src/events/tag.rs | 41 ++++++++++++++++++- crates/ruma-common/src/serde.rs | 3 +- crates/ruma-common/src/serde/strings.rs | 54 ++++++++++++++++++++++++- crates/ruma/Cargo.toml | 3 ++ xtask/src/ci.rs | 2 +- 7 files changed, 103 insertions(+), 5 deletions(-) diff --git a/crates/ruma-common/CHANGELOG.md b/crates/ruma-common/CHANGELOG.md index 33e20ef5..9b5afb3a 100644 --- a/crates/ruma-common/CHANGELOG.md +++ b/crates/ruma-common/CHANGELOG.md @@ -4,6 +4,8 @@ Bug fixes: - Set the predefined server-default `.m.rule.tombstone` push rule as enabled by default, as defined in the spec. +- Parse `m.tag` `order` as a f64 value or a stringified f64 value, if the `compat-tag-info` feature + is enabled. Breaking changes: diff --git a/crates/ruma-common/Cargo.toml b/crates/ruma-common/Cargo.toml index 1ec54ccb..9aa0d70a 100644 --- a/crates/ruma-common/Cargo.toml +++ b/crates/ruma-common/Cargo.toml @@ -69,6 +69,9 @@ compat-null = [] # mandatory. Deserialization will yield a default value like an empty string. compat-optional = [] +# Allow TagInfo to contain a stringified floating-point value for the `order` field. +compat-tag-info = [] + [dependencies] base64 = { workspace = true } bytes = "1.0.1" diff --git a/crates/ruma-common/src/events/tag.rs b/crates/ruma-common/src/events/tag.rs index 4d1d39d6..745a1bde 100644 --- a/crates/ruma-common/src/events/tag.rs +++ b/crates/ruma-common/src/events/tag.rs @@ -9,6 +9,9 @@ use serde::{Deserialize, Serialize}; use crate::{serde::deserialize_cow_str, PrivOwnedStr}; +#[cfg(feature = "compat-tag-info")] +use crate::serde::deserialize_as_optional_f64_or_string; + /// Map of tag names to tag info. pub type Tags = BTreeMap; @@ -167,11 +170,18 @@ impl Serialize for TagName { } /// Information about a tag. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] pub struct TagInfo { /// Value to use for lexicographically ordering rooms with this tag. + /// + /// If you activate the `compat-tag-info` feature, this field can be decoded as a stringified + /// floating-point value, instead of a number as it should be according to the specification. #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "compat-tag-info", + serde(default, deserialize_with = "deserialize_as_optional_f64_or_string") + )] pub order: Option, } @@ -185,7 +195,7 @@ impl TagInfo { #[cfg(test)] mod tests { use maplit::btreemap; - use serde_json::{json, to_value as to_json_value}; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; use super::{TagEventContent, TagInfo, TagName}; @@ -215,6 +225,33 @@ mod tests { ); } + #[test] + fn deserialize_tag_info() { + let json = json!({}); + assert_eq!(from_json_value::(json).unwrap(), TagInfo::default()); + + let json = json!({ "order": null }); + assert_eq!(from_json_value::(json).unwrap(), TagInfo::default()); + + let json = json!({ "order": 0.42 }); + assert_eq!(from_json_value::(json).unwrap(), TagInfo { order: Some(0.42) }); + + #[cfg(feature = "compat-tag-info")] + { + let json = json!({ "order": "0.5" }); + assert_eq!(from_json_value::(json).unwrap(), TagInfo { order: Some(0.5) }); + + let json = json!({ "order": ".5" }); + assert_eq!(from_json_value::(json).unwrap(), TagInfo { order: Some(0.5) }); + } + + #[cfg(not(feature = "compat-tag-info"))] + { + let json = json!({ "order": "0.5" }); + assert!(from_json_value::(json).is_err()); + } + } + #[test] fn display_name() { assert_eq!(TagName::Favorite.display_name(), "favourite"); diff --git a/crates/ruma-common/src/serde.rs b/crates/ruma-common/src/serde.rs index 740222f8..135dfb20 100644 --- a/crates/ruma-common/src/serde.rs +++ b/crates/ruma-common/src/serde.rs @@ -26,7 +26,8 @@ pub use self::{ cow::deserialize_cow_str, raw::Raw, strings::{ - btreemap_deserialize_v1_powerlevel_values, deserialize_v1_powerlevel, empty_string_as_none, + btreemap_deserialize_v1_powerlevel_values, deserialize_as_f64_or_string, + deserialize_as_optional_f64_or_string, deserialize_v1_powerlevel, empty_string_as_none, none_as_empty_string, }, }; diff --git a/crates/ruma-common/src/serde/strings.rs b/crates/ruma-common/src/serde/strings.rs index 9bc50f0c..dfa3c43f 100644 --- a/crates/ruma-common/src/serde/strings.rs +++ b/crates/ruma-common/src/serde/strings.rs @@ -49,6 +49,58 @@ where } } +/// Take either a floating point number or a string and deserialize to an floating-point number. +/// +/// To be used like this: +/// `#[serde(deserialize_with = "deserialize_as_f64_or_string")]` +pub fn deserialize_as_f64_or_string<'de, D>(de: D) -> Result +where + D: Deserializer<'de>, +{ + struct F64OrStringVisitor; + + impl<'de> Visitor<'de> for F64OrStringVisitor { + type Value = f64; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a double or a string") + } + + fn visit_f32(self, v: f32) -> Result + where + E: de::Error, + { + Ok(v.into()) + } + + fn visit_f64(self, v: f64) -> Result + where + E: de::Error, + { + Ok(v) + } + + fn visit_str(self, v: &str) -> Result { + v.parse().map_err(E::custom) + } + } + + de.deserialize_any(F64OrStringVisitor) +} + +#[derive(Deserialize)] +struct F64OrStringWrapper(#[serde(deserialize_with = "deserialize_as_f64_or_string")] f64); + +/// Deserializes an `Option` as encoded as a f64 or a string. +pub fn deserialize_as_optional_f64_or_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.map(|w| w.0)) +} + /// Take either an integer number or a string and deserialize to an integer number. /// /// To be used like this: @@ -160,7 +212,7 @@ where type Value = BTreeMap; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a map with integers or stings as values") + formatter.write_str("a map with integers or strings as values") } fn visit_map>(self, mut map: A) -> Result { diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml index 7155293c..18156573 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -98,6 +98,7 @@ compat = [ "compat-unset-avatar", "compat-get-3pids", "compat-signature-id", + "compat-tag-info", ] # Don't validate the version part in `KeyId`. compat-key-id = ["ruma-common/compat-key-id"] @@ -128,6 +129,8 @@ compat-get-3pids = ["ruma-client-api?/compat-get-3pids"] compat-upload-signatures = ["ruma-client-api?/compat-upload-signatures"] # Allow extra characters in signature IDs not allowed in the specification. compat-signature-id = ["ruma-signatures?/compat-signature-id"] +# Allow TagInfo to contain a stringified floating-point value for the `order` field. +compat-tag-info = ["ruma-common/compat-tag-info"] # Specific compatibility for past ring public/private key documents. ring-compat = ["dep:ruma-signatures", "ruma-signatures?/ring-compat"] diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs index 3bd98226..6480cd68 100644 --- a/xtask/src/ci.rs +++ b/xtask/src/ci.rs @@ -211,7 +211,7 @@ impl CiTask { fn test_common(&self) -> Result<()> { cmd!( "rustup run stable cargo test -p ruma-common - --features events,compat-empty-string-null,compat-user-id compat" + --features events,compat-empty-string-null,compat-user-id,compat-tag-info compat" ) .run() .map_err(Into::into)