Skip to main content

proto_blue_syntax/
recordkey.rs

1//! Record Key validation and types.
2//!
3//! Record keys are path-safe identifiers used in AT-URIs.
4//! See: <https://atproto.com/specs/record-key>
5
6use regex::Regex;
7use std::fmt;
8use std::str::FromStr;
9
10/// Maximum length of a record key.
11const RECORD_KEY_MAX_LENGTH: usize = 512;
12
13static RECORD_KEY_REGEX: std::sync::LazyLock<Regex> =
14    std::sync::LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_~.:\-]{1,512}$").unwrap());
15
16/// A validated record key.
17///
18/// Record keys are 1-512 character strings from the set `[a-zA-Z0-9_~.:-]`,
19/// and cannot be exactly `"."` or `".."`.
20#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub struct RecordKey(String);
22
23/// Error returned when a record key string is invalid.
24#[derive(Debug, Clone, thiserror::Error)]
25#[error("Invalid record key: {reason}")]
26pub struct InvalidRecordKeyError {
27    pub reason: String,
28}
29
30impl RecordKey {
31    /// Create a new `RecordKey` from a string, validating the format.
32    pub fn new(s: &str) -> Result<Self, InvalidRecordKeyError> {
33        ensure_valid_record_key(s)?;
34        Ok(Self(s.to_string()))
35    }
36
37    /// Check whether a string is a valid record key.
38    #[must_use]
39    pub fn is_valid(s: &str) -> bool {
40        ensure_valid_record_key(s).is_ok()
41    }
42
43    /// Return the inner string.
44    #[must_use]
45    pub fn as_str(&self) -> &str {
46        &self.0
47    }
48
49    /// Consume and return the inner string.
50    #[must_use]
51    pub fn into_inner(self) -> String {
52        self.0
53    }
54}
55
56fn ensure_valid_record_key(s: &str) -> Result<(), InvalidRecordKeyError> {
57    let err = |reason: &str| InvalidRecordKeyError {
58        reason: reason.to_string(),
59    };
60
61    if s.is_empty() {
62        return Err(err("Record key must not be empty"));
63    }
64
65    if s.len() > RECORD_KEY_MAX_LENGTH {
66        return Err(err(&format!(
67            "Record key too long ({} chars, max {})",
68            s.len(),
69            RECORD_KEY_MAX_LENGTH
70        )));
71    }
72
73    if s == "." || s == ".." {
74        return Err(err("Record key cannot be \".\" or \"..\""));
75    }
76
77    if !RECORD_KEY_REGEX.is_match(s) {
78        return Err(err(
79            "Record key must contain only [a-zA-Z0-9_~.:-] characters",
80        ));
81    }
82
83    Ok(())
84}
85
86impl fmt::Display for RecordKey {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.write_str(&self.0)
89    }
90}
91
92impl FromStr for RecordKey {
93    type Err = InvalidRecordKeyError;
94    fn from_str(s: &str) -> Result<Self, Self::Err> {
95        Self::new(s)
96    }
97}
98
99impl AsRef<str> for RecordKey {
100    fn as_ref(&self) -> &str {
101        &self.0
102    }
103}
104
105impl serde::Serialize for RecordKey {
106    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
107        self.0.serialize(serializer)
108    }
109}
110
111impl<'de> serde::Deserialize<'de> for RecordKey {
112    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
113        let s = String::deserialize(deserializer)?;
114        Self::new(&s).map_err(serde::de::Error::custom)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn valid_record_keys() {
124        let cases = ["self", "3jui7kd54zh2y", "example.com", "a", "a-b_c~d:e"];
125        for rkey in &cases {
126            assert!(RecordKey::new(rkey).is_ok(), "should be valid: {rkey}");
127        }
128    }
129
130    #[test]
131    fn invalid_record_keys() {
132        assert!(RecordKey::new("").is_err(), "empty");
133        assert!(RecordKey::new(".").is_err(), "dot");
134        assert!(RecordKey::new("..").is_err(), "double dot");
135        assert!(RecordKey::new("has space").is_err(), "space");
136        assert!(RecordKey::new("has/slash").is_err(), "slash");
137        assert!(RecordKey::new("has#hash").is_err(), "hash");
138    }
139
140    #[test]
141    fn max_length() {
142        let max = "a".repeat(RECORD_KEY_MAX_LENGTH);
143        assert!(RecordKey::new(&max).is_ok());
144        let too_long = "a".repeat(RECORD_KEY_MAX_LENGTH + 1);
145        assert!(RecordKey::new(&too_long).is_err());
146    }
147}