Skip to main content

unifly_api/model/
entity_id.rs

1// ── Core identity types ──
2//
3// EntityId and MacAddress form the foundation of every domain type.
4// They unify UUID-based (Integration API) and string-based (Session API)
5// identifiers behind a single ergonomic interface.
6
7use serde::{Deserialize, Serialize};
8use std::error::Error as StdError;
9use std::fmt;
10use std::str::FromStr;
11use uuid::Uuid;
12
13// ── EntityId ────────────────────────────────────────────────────────
14
15/// Canonical identifier for any UniFi entity.
16///
17/// Transparently wraps either a UUID (Integration API) or a session
18/// MongoDB ObjectId string (Session API). Consumers never care which.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(untagged)]
21pub enum EntityId {
22    Uuid(Uuid),
23    Legacy(String),
24}
25
26impl EntityId {
27    pub fn as_uuid(&self) -> Option<&Uuid> {
28        match self {
29            Self::Uuid(u) => Some(u),
30            Self::Legacy(_) => None,
31        }
32    }
33
34    pub fn as_legacy(&self) -> Option<&str> {
35        match self {
36            Self::Legacy(s) => Some(s),
37            Self::Uuid(_) => None,
38        }
39    }
40}
41
42impl fmt::Display for EntityId {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Uuid(u) => write!(f, "{u}"),
46            Self::Legacy(s) => write!(f, "{s}"),
47        }
48    }
49}
50
51impl FromStr for EntityId {
52    type Err = std::convert::Infallible;
53
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        Ok(Self::from(s.to_owned()))
56    }
57}
58
59impl From<Uuid> for EntityId {
60    fn from(u: Uuid) -> Self {
61        Self::Uuid(u)
62    }
63}
64
65impl From<String> for EntityId {
66    fn from(s: String) -> Self {
67        match Uuid::parse_str(&s) {
68            Ok(u) => Self::Uuid(u),
69            Err(_) => Self::Legacy(s),
70        }
71    }
72}
73
74impl From<&str> for EntityId {
75    fn from(s: &str) -> Self {
76        Self::from(s.to_owned())
77    }
78}
79
80// ── MacAddress ──────────────────────────────────────────────────────
81
82/// MAC address, normalized to lowercase colon-separated format (aa:bb:cc:dd:ee:ff).
83#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
84pub struct MacAddress(String);
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct ParseMacAddressError;
88
89impl MacAddress {
90    /// Create a normalized MAC address from any common format.
91    /// Accepts colon-separated, dash-separated, or bare hex.
92    ///
93    /// For strict validation, use [`MacAddress::try_new`] or `str::parse`.
94    pub fn new(raw: impl AsRef<str>) -> Self {
95        let raw = raw.as_ref();
96        Self::try_new(raw).unwrap_or_else(|_| Self(raw.trim().to_lowercase().replace('-', ":")))
97    }
98
99    pub fn try_new(raw: impl AsRef<str>) -> Result<Self, ParseMacAddressError> {
100        normalize_mac(raw.as_ref())
101            .map(Self)
102            .ok_or(ParseMacAddressError)
103    }
104
105    pub fn as_str(&self) -> &str {
106        &self.0
107    }
108}
109
110fn normalize_mac(raw: &str) -> Option<String> {
111    let compact = raw
112        .trim()
113        .chars()
114        .filter(|ch| *ch != ':' && *ch != '-')
115        .collect::<String>()
116        .to_ascii_lowercase();
117
118    if compact.len() != 12 || !compact.chars().all(|ch| ch.is_ascii_hexdigit()) {
119        return None;
120    }
121
122    let bytes = compact.as_bytes();
123    let mut normalized = String::with_capacity(17);
124    for (idx, pair) in bytes.chunks_exact(2).enumerate() {
125        if idx > 0 {
126            normalized.push(':');
127        }
128        normalized.push(char::from(pair[0]));
129        normalized.push(char::from(pair[1]));
130    }
131
132    Some(normalized)
133}
134
135impl fmt::Display for MacAddress {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(f, "{}", self.0)
138    }
139}
140
141impl fmt::Display for ParseMacAddressError {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(f, "expected a 12-digit hexadecimal MAC address")
144    }
145}
146
147impl StdError for ParseMacAddressError {}
148
149impl FromStr for MacAddress {
150    type Err = ParseMacAddressError;
151
152    fn from_str(s: &str) -> Result<Self, Self::Err> {
153        Self::try_new(s)
154    }
155}
156
157#[cfg(test)]
158#[allow(clippy::unwrap_used)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn entity_id_from_uuid_string() {
164        let id = EntityId::from("550e8400-e29b-41d4-a716-446655440000".to_owned());
165        assert!(id.as_uuid().is_some());
166    }
167
168    #[test]
169    fn entity_id_from_legacy_string() {
170        let id = EntityId::from("507f1f77bcf86cd799439011".to_owned());
171        assert!(id.as_legacy().is_some());
172    }
173
174    #[test]
175    fn entity_id_display() {
176        let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
177        let id = EntityId::Uuid(uuid);
178        assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
179    }
180
181    #[test]
182    fn entity_id_from_str() {
183        let id: EntityId = "507f1f77bcf86cd799439011".parse().unwrap();
184        assert!(id.as_legacy().is_some());
185    }
186
187    #[test]
188    fn mac_address_normalizes_dashes() {
189        let mac = MacAddress::new("AA-BB-CC-DD-EE-FF");
190        assert_eq!(mac.as_str(), "aa:bb:cc:dd:ee:ff");
191    }
192
193    #[test]
194    fn mac_address_normalizes_case() {
195        let mac = MacAddress::new("AA:BB:CC:DD:EE:FF");
196        assert_eq!(mac.as_str(), "aa:bb:cc:dd:ee:ff");
197    }
198
199    #[test]
200    fn mac_address_from_str() {
201        let mac: MacAddress = "AA-BB-CC-DD-EE-FF".parse().unwrap();
202        assert_eq!(mac.to_string(), "aa:bb:cc:dd:ee:ff");
203    }
204
205    #[test]
206    fn mac_address_normalizes_bare_hex() {
207        let mac = MacAddress::new("AABBCCDDEEFF");
208        assert_eq!(mac.as_str(), "aa:bb:cc:dd:ee:ff");
209    }
210
211    #[test]
212    fn mac_address_parse_rejects_invalid_input() {
213        assert!("not-a-mac".parse::<MacAddress>().is_err());
214        assert!("AA:BB:CC:DD:EE".parse::<MacAddress>().is_err());
215    }
216}