diff --git a/ruma-client-api/src/error.rs b/ruma-client-api/src/error.rs index e322e3e7..21bfb1d1 100644 --- a/ruma-client-api/src/error.rs +++ b/ruma-client-api/src/error.rs @@ -1,6 +1,7 @@ //! Errors that can be sent from the homeserver. use std::{ + collections::BTreeMap, fmt::{self, Display, Formatter}, time::Duration, }; @@ -8,189 +9,174 @@ use std::{ use ruma_api::{error::ResponseDeserializationError, EndpointError}; use ruma_identifiers::RoomVersionId; use serde::{Deserialize, Serialize}; -use serde_json::{from_slice as from_json_slice, to_vec as to_json_vec}; -use strum::{AsRefStr, Display}; +use serde_json::{from_slice as from_json_slice, to_vec as to_json_vec, Value as JsonValue}; + +/// Deserialize and Serialize implementations for ErrorKind. +/// Separate module because it's a lot of code. +mod kind_serde; /// An enum for the error kind. Items may contain additional information. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, AsRefStr, Display)] -#[serde(tag = "errcode")] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ErrorKind { /// M_FORBIDDEN - #[serde(rename = "M_FORBIDDEN")] - #[strum(to_string = "M_FORBIDDEN")] Forbidden, /// M_UNKNOWN_TOKEN - #[serde(rename = "M_UNKNOWN_TOKEN")] - #[strum(to_string = "M_UNKNOWN_TOKEN")] UnknownToken { /// If this is `true`, the client can acquire a new access token by specifying the device ID /// it is already using to the login API. For more information, see [the spec]. /// /// [the spec]: https://matrix.org/docs/spec/client_server/r0.6.1#soft-logout - #[serde(default, skip_serializing_if = "ruma_serde::is_default")] soft_logout: bool, }, /// M_MISSING_TOKEN - #[serde(rename = "M_MISSING_TOKEN")] - #[strum(to_string = "M_MISSING_TOKEN")] MissingToken, /// M_BAD_JSON - #[serde(rename = "M_BAD_JSON")] - #[strum(to_string = "M_BAD_JSON")] BadJson, /// M_NOT_JSON - #[serde(rename = "M_NOT_JSON")] - #[strum(to_string = "M_NOT_JSON")] NotJson, /// M_NOT_FOUND - #[serde(rename = "M_NOT_FOUND")] - #[strum(to_string = "M_NOT_FOUND")] NotFound, /// M_LIMIT_EXCEEDED - #[serde(rename = "M_LIMIT_EXCEEDED")] - #[strum(to_string = "M_LIMIT_EXCEEDED")] LimitExceeded { /// How long a client should wait in milliseconds before they can try again. - #[serde(with = "ruma_serde::duration::opt_ms")] retry_after_ms: Option, }, /// M_UNKNOWN - #[serde(rename = "M_UNKNOWN")] - #[strum(to_string = "M_UNKNOWN")] Unknown, /// M_UNRECOGNIZED - #[serde(rename = "M_UNRECOGNIZED")] - #[strum(to_string = "M_UNRECOGNIZED")] Unrecognized, /// M_UNAUTHORIZED - #[serde(rename = "M_UNAUTHORIZED")] - #[strum(to_string = "M_UNAUTHORIZED")] Unauthorized, /// M_USER_DEACTIVATED - #[serde(rename = "M_USER_DEACTIVATED")] - #[strum(to_string = "M_USER_DEACTIVATED")] UserDeactivated, /// M_USER_IN_USE - #[serde(rename = "M_USER_IN_USE")] - #[strum(to_string = "M_USER_IN_USE")] UserInUse, /// M_INVALID_USERNAME - #[serde(rename = "M_INVALID_USERNAME")] - #[strum(to_string = "M_INVALID_USERNAME")] InvalidUsername, /// M_ROOM_IN_USE - #[serde(rename = "M_ROOM_IN_USE")] - #[strum(to_string = "M_ROOM_IN_USE")] RoomInUse, /// M_INVALID_ROOM_STATE - #[serde(rename = "M_INVALID_ROOM_STATE")] - #[strum(to_string = "M_INVALID_ROOM_STATE")] InvalidRoomState, /// M_THREEPID_IN_USE - #[serde(rename = "M_THREEPID_IN_USE")] - #[strum(to_string = "M_THREEPID_IN_USE")] ThreepidInUse, /// M_THREEPID_NOT_FOUND - #[serde(rename = "M_THREEPID_NOT_FOUND")] - #[strum(to_string = "M_THREEPID_NOT_FOUND")] ThreepidNotFound, /// M_THREEPID_AUTH_FAILED - #[serde(rename = "M_THREEPID_AUTH_FAILED")] - #[strum(to_string = "M_THREEPID_AUTH_FAILED")] ThreepidAuthFailed, /// M_THREEPID_DENIED - #[serde(rename = "M_THREEPID_DENIED")] - #[strum(to_string = "M_THREEPID_DENIED")] ThreepidDenied, /// M_SERVER_NOT_TRUSTED - #[serde(rename = "M_SERVER_NOT_TRUSTED")] - #[strum(to_string = "M_SERVER_NOT_TRUSTED")] ServerNotTrusted, /// M_UNSUPPORTED_ROOM_VERSION - #[serde(rename = "M_UNSUPPORTED_ROOM_VERSION")] - #[strum(to_string = "M_UNSUPPORTED_ROOM_VERSION")] UnsupportedRoomVersion, /// M_INCOMPATIBLE_ROOM_VERSION - #[serde(rename = "M_INCOMPATIBLE_ROOM_VERSION")] - #[strum(to_string = "M_INCOMPATIBLE_ROOM_VERSION")] IncompatibleRoomVersion { /// The room's version. room_version: RoomVersionId, }, /// M_BAD_STATE - #[serde(rename = "M_BAD_STATE")] - #[strum(to_string = "M_BAD_STATE")] BadState, /// M_GUEST_ACCESS_FORBIDDEN - #[serde(rename = "M_GUEST_ACCESS_FORBIDDEN")] - #[strum(to_string = "M_GUEST_ACCESS_FORBIDDEN")] GuestAccessForbidden, /// M_CAPTCHA_NEEDED - #[serde(rename = "M_CAPTCHA_NEEDED")] - #[strum(to_string = "M_CAPTCHA_NEEDED")] CaptchaNeeded, /// M_CAPTCHA_INVALID - #[serde(rename = "M_CAPTCHA_INVALID")] - #[strum(to_string = "M_CAPTCHA_INVALID")] CaptchaInvalid, /// M_MISSING_PARAM - #[serde(rename = "M_MISSING_PARAM")] - #[strum(to_string = "M_MISSING_PARAM")] MissingParam, /// M_INVALID_PARAM - #[serde(rename = "M_INVALID_PARAM")] - #[strum(to_string = "M_INVALID_PARAM")] InvalidParam, /// M_TOO_LARGE - #[serde(rename = "M_TOO_LARGE")] - #[strum(to_string = "M_TOO_LARGE")] TooLarge, /// M_EXCLUSIVE - #[serde(rename = "M_EXCLUSIVE")] - #[strum(to_string = "M_EXCLUSIVE")] Exclusive, /// M_RESOURCE_LIMIT_EXCEEDED - #[serde(rename = "M_RESOURCE_LIMIT_EXCEEDED")] - #[strum(to_string = "M_RESOURCE_LIMIT_EXCEEDED")] ResourceLimitExceeded { /// A URI giving a contact method for the server administrator. admin_contact: String, }, /// M_CANNOT_LEAVE_SERVER_NOTICE_ROOM - #[serde(rename = "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM")] - #[strum(to_string = "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM")] CannotLeaveServerNoticeRoom, + + #[doc(hidden)] + _Custom { errcode: String, extra: BTreeMap }, +} + +impl AsRef for ErrorKind { + fn as_ref(&self) -> &str { + match self { + Self::Forbidden => "M_FORBIDDEN", + Self::UnknownToken { .. } => "M_UNKNOWN_TOKEN", + Self::MissingToken => "M_MISSING_TOKEN", + Self::BadJson => "M_BAD_JSON", + Self::NotJson => "M_NOT_JSON", + Self::NotFound => "M_NOT_FOUND", + Self::LimitExceeded { .. } => "M_LIMIT_EXCEEDED", + Self::Unknown => "M_UNKNOWN", + Self::Unrecognized => "M_UNRECOGNIZED", + Self::Unauthorized => "M_UNAUTHORIZED", + Self::UserDeactivated => "M_USER_DEACTIVATED", + Self::UserInUse => "M_USER_IN_USE", + Self::InvalidUsername => "M_INVALID_USERNAME", + Self::RoomInUse => "M_ROOM_IN_USE", + Self::InvalidRoomState => "M_INVALID_ROOM_STATE", + Self::ThreepidInUse => "M_THREEPID_IN_USE", + Self::ThreepidNotFound => "M_THREEPID_NOT_FOUND", + Self::ThreepidAuthFailed => "M_THREEPID_AUTH_FAILED", + Self::ThreepidDenied => "M_THREEPID_DENIED", + Self::ServerNotTrusted => "M_SERVER_NOT_TRUSTED", + Self::UnsupportedRoomVersion => "M_UNSUPPORTED_ROOM_VERSION", + Self::IncompatibleRoomVersion { .. } => "M_INCOMPATIBLE_ROOM_VERSION", + Self::BadState => "M_BAD_STATE", + Self::GuestAccessForbidden => "M_GUEST_ACCESS_FORBIDDEN", + Self::CaptchaNeeded => "M_CAPTCHA_NEEDED", + Self::CaptchaInvalid => "M_CAPTCHA_INVALID", + Self::MissingParam => "M_MISSING_PARAM", + Self::InvalidParam => "M_INVALID_PARAM", + Self::TooLarge => "M_TOO_LARGE", + Self::Exclusive => "M_EXCLUSIVE", + Self::ResourceLimitExceeded { .. } => "M_RESOURCE_LIMIT_EXCEEDED", + Self::CannotLeaveServerNoticeRoom => "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM", + Self::_Custom { errcode, .. } => &errcode, + } + } +} + +impl Display for ErrorKind { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_ref()) + } } /// A Matrix Error without a status code @@ -260,3 +246,27 @@ impl From for http::Response> { .unwrap() } } + +#[cfg(test)] +mod tests { + use serde_json::{from_value as from_json_value, json}; + + use super::{ErrorBody, ErrorKind}; + + #[test] + fn deserialize_forbidden() { + let deserialized: ErrorBody = from_json_value(json!({ + "errcode": "M_FORBIDDEN", + "error": "You are not authorized to ban users in this room.", + })) + .unwrap(); + + assert_eq!( + deserialized, + ErrorBody { + kind: ErrorKind::Forbidden, + message: "You are not authorized to ban users in this room.".into(), + } + ); + } +} diff --git a/ruma-client-api/src/error/kind_serde.rs b/ruma-client-api/src/error/kind_serde.rs new file mode 100644 index 00000000..b2deef11 --- /dev/null +++ b/ruma-client-api/src/error/kind_serde.rs @@ -0,0 +1,380 @@ +use std::{ + borrow::Cow, + collections::btree_map::{BTreeMap, Entry}, + convert::TryFrom, + fmt, + time::Duration, +}; + +use js_int::UInt; +use serde::{ + de::{self, Deserialize, Deserializer, MapAccess, Visitor}, + ser::{self, Serialize, SerializeMap, Serializer}, +}; +use serde_json::from_value as from_json_value; + +use super::ErrorKind; + +enum Field<'de> { + ErrCode, + SoftLogout, + RetryAfterMs, + RoomVersion, + AdminContact, + Other(Cow<'de, str>), +} + +impl<'de> Field<'de> { + fn new(s: Cow<'de, str>) -> Field<'de> { + match s.as_ref() { + "errcode" => Self::ErrCode, + "soft_logout" => Self::SoftLogout, + "retry_after_ms" => Self::RetryAfterMs, + "room_version" => Self::RoomVersion, + "admin_contact" => Self::AdminContact, + _ => Self::Other(s), + } + } +} + +impl<'de> Deserialize<'de> for Field<'de> { + fn deserialize(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + struct FieldVisitor; + + impl<'de> Visitor<'de> for FieldVisitor { + type Value = Field<'de>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("any struct field") + } + + fn visit_str(self, value: &str) -> Result, E> + where + E: de::Error, + { + Ok(Field::new(Cow::Owned(value.to_owned()))) + } + + fn visit_borrowed_str(self, value: &'de str) -> Result, E> + where + E: de::Error, + { + Ok(Field::new(Cow::Borrowed(value))) + } + + fn visit_string(self, value: String) -> Result, E> + where + E: de::Error, + { + Ok(Field::new(Cow::Owned(value))) + } + } + + deserializer.deserialize_identifier(FieldVisitor) + } +} + +struct ErrorKindVisitor; + +impl<'de> Visitor<'de> for ErrorKindVisitor { + type Value = ErrorKind; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("enum ErrorKind") + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut errcode = None; + let mut soft_logout = None; + let mut retry_after_ms = None; + let mut room_version = None; + let mut admin_contact = None; + let mut extra = BTreeMap::new(); + + macro_rules! set_field { + (errcode) => { + set_field!(@inner errcode); + }; + ($field:ident) => { + match errcode { + Some(set_field!(@variant_containing $field)) | None => { + set_field!(@inner $field); + } + // if we already know we're deserializing a different variant to the one + // containing this field, ignore its value. + Some(_) => { + let _ = map.next_value::()?; + }, + } + }; + (@variant_containing soft_logout) => { ErrCode::UnknownToken }; + (@variant_containing retry_after_ms) => { ErrCode::LimitExceeded }; + (@variant_containing room_version) => { ErrCode::IncompatibleRoomVersion }; + (@variant_containing admin_contact) => { ErrCode::ResourceLimitExceeded }; + (@inner $field:ident) => { + { + if $field.is_some() { + return Err(de::Error::duplicate_field(stringify!($field))); + } + $field = Some(map.next_value()?); + } + }; + } + + while let Some(key) = map.next_key()? { + match key { + Field::ErrCode => set_field!(errcode), + Field::SoftLogout => set_field!(soft_logout), + Field::RetryAfterMs => set_field!(retry_after_ms), + Field::RoomVersion => set_field!(room_version), + Field::AdminContact => set_field!(admin_contact), + Field::Other(other) => match extra.entry(other.into_owned()) { + Entry::Vacant(v) => { + v.insert(map.next_value()?); + } + Entry::Occupied(o) => { + return Err(de::Error::custom(format!("duplicate field `{}`", o.key()))); + } + }, + } + } + + let errcode = errcode.ok_or_else(|| de::Error::missing_field("errcode"))?; + Ok(match errcode { + ErrCode::Forbidden => ErrorKind::Forbidden, + ErrCode::UnknownToken => ErrorKind::UnknownToken { + soft_logout: soft_logout + .map(from_json_value) + .transpose() + .map_err(de::Error::custom)? + .unwrap_or_default(), + }, + ErrCode::MissingToken => ErrorKind::MissingToken, + ErrCode::BadJson => ErrorKind::BadJson, + ErrCode::NotJson => ErrorKind::NotJson, + ErrCode::NotFound => ErrorKind::NotFound, + ErrCode::LimitExceeded => ErrorKind::LimitExceeded { + retry_after_ms: retry_after_ms + .map(from_json_value::) + .transpose() + .map_err(de::Error::custom)? + .map(Into::into) + .map(Duration::from_millis), + }, + ErrCode::Unknown => ErrorKind::Unknown, + ErrCode::Unrecognized => ErrorKind::Unrecognized, + ErrCode::Unauthorized => ErrorKind::Unauthorized, + ErrCode::UserDeactivated => ErrorKind::UserDeactivated, + ErrCode::UserInUse => ErrorKind::UserInUse, + ErrCode::InvalidUsername => ErrorKind::InvalidUsername, + ErrCode::RoomInUse => ErrorKind::RoomInUse, + ErrCode::InvalidRoomState => ErrorKind::InvalidRoomState, + ErrCode::ThreepidInUse => ErrorKind::ThreepidInUse, + ErrCode::ThreepidNotFound => ErrorKind::ThreepidNotFound, + ErrCode::ThreepidAuthFailed => ErrorKind::ThreepidAuthFailed, + ErrCode::ThreepidDenied => ErrorKind::ThreepidDenied, + ErrCode::ServerNotTrusted => ErrorKind::ServerNotTrusted, + ErrCode::UnsupportedRoomVersion => ErrorKind::UnsupportedRoomVersion, + ErrCode::IncompatibleRoomVersion => ErrorKind::IncompatibleRoomVersion { + room_version: from_json_value( + room_version.ok_or_else(|| de::Error::missing_field("room_version"))?, + ) + .map_err(de::Error::custom)?, + }, + ErrCode::BadState => ErrorKind::BadState, + ErrCode::GuestAccessForbidden => ErrorKind::GuestAccessForbidden, + ErrCode::CaptchaNeeded => ErrorKind::CaptchaNeeded, + ErrCode::CaptchaInvalid => ErrorKind::CaptchaInvalid, + ErrCode::MissingParam => ErrorKind::MissingParam, + ErrCode::InvalidParam => ErrorKind::InvalidParam, + ErrCode::TooLarge => ErrorKind::TooLarge, + ErrCode::Exclusive => ErrorKind::Exclusive, + ErrCode::ResourceLimitExceeded => ErrorKind::ResourceLimitExceeded { + admin_contact: from_json_value( + admin_contact.ok_or_else(|| de::Error::missing_field("admin_contact"))?, + ) + .map_err(de::Error::custom)?, + }, + ErrCode::CannotLeaveServerNoticeRoom => ErrorKind::CannotLeaveServerNoticeRoom, + ErrCode::_Custom(errcode) => ErrorKind::_Custom { errcode, extra }, + }) + } +} + +// FIXME: Derive FromString once available +enum ErrCode { + Forbidden, + UnknownToken, + MissingToken, + BadJson, + NotJson, + NotFound, + LimitExceeded, + Unknown, + Unrecognized, + Unauthorized, + UserDeactivated, + UserInUse, + InvalidUsername, + RoomInUse, + InvalidRoomState, + ThreepidInUse, + ThreepidNotFound, + ThreepidAuthFailed, + ThreepidDenied, + ServerNotTrusted, + UnsupportedRoomVersion, + IncompatibleRoomVersion, + BadState, + GuestAccessForbidden, + CaptchaNeeded, + CaptchaInvalid, + MissingParam, + InvalidParam, + TooLarge, + Exclusive, + ResourceLimitExceeded, + CannotLeaveServerNoticeRoom, + _Custom(String), +} + +impl<'de> Deserialize<'de> for ErrCode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = Cow::<'de, str>::deserialize(deserializer)?; + Ok(s.into()) + } +} + +impl From for ErrCode +where + T: AsRef + Into, +{ + fn from(s: T) -> Self { + match s.as_ref() { + "M_FORBIDDEN" => Self::Forbidden, + "M_UNKNOWN_TOKEN" => Self::UnknownToken, + "M_MISSING_TOKEN" => Self::MissingToken, + "M_BAD_JSON" => Self::BadJson, + "M_NOT_JSON" => Self::NotJson, + "M_NOT_FOUND" => Self::NotFound, + "M_LIMIT_EXCEEDED" => Self::LimitExceeded, + "M_UNKNOWN" => Self::Unknown, + "M_UNRECOGNIZED" => Self::Unrecognized, + "M_UNAUTHORIZED" => Self::Unauthorized, + "M_USER_DEACTIVATED" => Self::UserDeactivated, + "M_USER_IN_USE" => Self::UserInUse, + "M_INVALID_USERNAME" => Self::InvalidUsername, + "M_ROOM_IN_USE" => Self::RoomInUse, + "M_INVALID_ROOM_STATE" => Self::InvalidRoomState, + "M_THREEPID_IN_USE" => Self::ThreepidInUse, + "M_THREEPID_NOT_FOUND" => Self::ThreepidNotFound, + "M_THREEPID_AUTH_FAILED" => Self::ThreepidAuthFailed, + "M_THREEPID_DENIED" => Self::ThreepidDenied, + "M_SERVER_NOT_TRUSTED" => Self::ServerNotTrusted, + "M_UNSUPPORTED_ROOM_VERSION" => Self::UnsupportedRoomVersion, + "M_INCOMPATIBLE_ROOM_VERSION" => Self::IncompatibleRoomVersion, + "M_BAD_STATE" => Self::BadState, + "M_GUEST_ACCESS_FORBIDDEN" => Self::GuestAccessForbidden, + "M_CAPTCHA_NEEDED" => Self::CaptchaNeeded, + "M_CAPTCHA_INVALID" => Self::CaptchaInvalid, + "M_MISSING_PARAM" => Self::MissingParam, + "M_INVALID_PARAM" => Self::InvalidParam, + "M_TOO_LARGE" => Self::TooLarge, + "M_EXCLUSIVE" => Self::Exclusive, + "M_RESOURCE_LIMIT_EXCEEDED" => Self::ResourceLimitExceeded, + "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM" => Self::CannotLeaveServerNoticeRoom, + _ => Self::_Custom(s.into()), + } + } +} + +impl<'de> Deserialize<'de> for ErrorKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(ErrorKindVisitor) + } +} + +impl Serialize for ErrorKind { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut st = serializer.serialize_map(None)?; + st.serialize_entry("errcode", self.as_ref())?; + match self { + Self::UnknownToken { soft_logout: true } => { + st.serialize_entry("soft_logout", &true)?; + } + Self::LimitExceeded { retry_after_ms: Some(duration) } => { + st.serialize_entry( + "retry_after_ms", + &UInt::try_from(duration.as_millis()).map_err(ser::Error::custom)?, + )?; + } + Self::IncompatibleRoomVersion { room_version } => { + st.serialize_entry("room_version", room_version)?; + } + Self::ResourceLimitExceeded { admin_contact } => { + st.serialize_entry("admin_contact", admin_contact)?; + } + Self::_Custom { extra, .. } => { + for (k, v) in extra { + st.serialize_entry(k, v)?; + } + } + _ => {} + } + st.end() + } +} + +#[cfg(test)] +mod tests { + use ruma_identifiers::room_version_id; + use serde_json::{from_value as from_json_value, json}; + + use super::ErrorKind; + + #[test] + fn deserialize_forbidden() { + let deserialized: ErrorKind = from_json_value(json!({ "errcode": "M_FORBIDDEN" })).unwrap(); + assert_eq!(deserialized, ErrorKind::Forbidden); + } + + #[test] + fn deserialize_forbidden_with_extra_fields() { + let deserialized: ErrorKind = from_json_value(json!({ + "errcode": "M_FORBIDDEN", + "error": "…", + })) + .unwrap(); + + assert_eq!(deserialized, ErrorKind::Forbidden); + } + + #[test] + fn deserialize_incompatible_room_version() { + let deserialized: ErrorKind = from_json_value(json!({ + "errcode": "M_INCOMPATIBLE_ROOM_VERSION", + "room_version": "7", + })) + .unwrap(); + + assert_eq!( + deserialized, + ErrorKind::IncompatibleRoomVersion { room_version: room_version_id!("7") } + ); + } +}