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