Skip to main content

didbot_schema/format/
mod.rs

1//! The string formats a Lexicon `format` keyword can name.
2//!
3//! A lexicon string field may declare a `format`, and the format is the only
4//! part of a string schema that says what the string *means* rather than how
5//! long it is. Most of these already had validators in this workspace, built
6//! for the conformance suite before anything called them at a boundary; this
7//! module is the boundary, and the four that did not exist —
8//! [`mod@datetime`], [`mod@language`], [`mod@uri`] and [`mod@cid`] — are here.
9//!
10//! # An unrecognised format is not an error
11//!
12//! [`StringFormat::parse`] returns `None` for a format this build does not
13//! know, and [`check`] then applies only the string's length and enumeration
14//! rules. New formats are added to the Lexicon specification over time, and a
15//! server that refused every record carrying one would break on a schema
16//! change it is not even the author of. This is the same forward-compatibility
17//! posture as the unknown-property rule, applied one level down.
18
19pub mod cid;
20pub mod datetime;
21pub mod language;
22pub mod uri;
23
24/// A `format` a Lexicon string schema can declare.
25///
26/// Only formats with a validator appear here. `at-identifier` is the union of
27/// a handle and a DID, which is how a query parameter accepting either has to
28/// read it.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum StringFormat {
31    /// An `at://` URI.
32    AtUri,
33    /// A DID of any method.
34    Did,
35    /// A handle.
36    Handle,
37    /// Either a handle or a DID.
38    AtIdentifier,
39    /// A namespace identifier.
40    Nsid,
41    /// A content identifier.
42    Cid,
43    /// An atproto datetime.
44    Datetime,
45    /// A BCP 47 language tag.
46    Language,
47    /// A generic URI.
48    Uri,
49    /// A timestamp identifier.
50    Tid,
51    /// A record key.
52    RecordKey,
53}
54
55impl StringFormat {
56    /// The format named by a lexicon's `format` keyword, if this build knows
57    /// it.
58    ///
59    /// ```
60    /// use didbot_schema::format::StringFormat;
61    /// assert_eq!(StringFormat::parse("datetime"), Some(StringFormat::Datetime));
62    /// assert_eq!(StringFormat::parse("something-new"), None);
63    /// ```
64    pub fn parse(name: &str) -> Option<Self> {
65        Some(match name {
66            "at-uri" => Self::AtUri,
67            "did" => Self::Did,
68            "handle" => Self::Handle,
69            "at-identifier" => Self::AtIdentifier,
70            "nsid" => Self::Nsid,
71            "cid" => Self::Cid,
72            "datetime" => Self::Datetime,
73            "language" => Self::Language,
74            "uri" => Self::Uri,
75            "tid" => Self::Tid,
76            "record-key" => Self::RecordKey,
77            _ => return None,
78        })
79    }
80
81    /// The name this format is written under in a lexicon document.
82    pub fn as_str(self) -> &'static str {
83        match self {
84            Self::AtUri => "at-uri",
85            Self::Did => "did",
86            Self::Handle => "handle",
87            Self::AtIdentifier => "at-identifier",
88            Self::Nsid => "nsid",
89            Self::Cid => "cid",
90            Self::Datetime => "datetime",
91            Self::Language => "language",
92            Self::Uri => "uri",
93            Self::Tid => "tid",
94            Self::RecordKey => "record-key",
95        }
96    }
97}
98
99impl std::fmt::Display for StringFormat {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.write_str(self.as_str())
102    }
103}
104
105/// Checks a string against one format, reporting why it failed as prose.
106///
107/// The reason is flattened to a `String` rather than kept as one enum per
108/// format, because the caller is a record validator building a path-prefixed
109/// message and has nothing to do with the difference between a bad month and
110/// a bad offset beyond repeating it.
111///
112/// ```
113/// use didbot_schema::format::{check, StringFormat};
114/// assert!(check(StringFormat::Datetime, "2024-01-01T00:00:00Z").is_ok());
115/// assert!(check(StringFormat::Did, "123").is_err());
116/// ```
117pub fn check(format: StringFormat, value: &str) -> Result<(), String> {
118    match format {
119        StringFormat::AtUri => crate::AtUri::parse(value).map(|_| ()).map_err(stringly),
120        StringFormat::Did => didbot_identity::validate_did(value).map_err(stringly),
121        StringFormat::Handle => didbot_identity::validate_handle(value).map_err(stringly),
122        StringFormat::AtIdentifier => didbot_identity::validate_did(value)
123            .map_err(stringly)
124            .or_else(|did| {
125                didbot_identity::validate_handle(value)
126                    .map_err(|handle| format!("not a did ({did}); not a handle ({handle})"))
127            }),
128        StringFormat::Nsid => didbot_lexicon::nsid_syntax::validate(value).map_err(stringly),
129        StringFormat::Cid => cid::validate(value).map_err(stringly),
130        StringFormat::Datetime => datetime::validate(value).map_err(stringly),
131        StringFormat::Language => language::validate(value).map_err(stringly),
132        StringFormat::Uri => uri::validate(value).map_err(stringly),
133        StringFormat::Tid => crate::tid::validate(value).map(|_| ()).map_err(stringly),
134        StringFormat::RecordKey => crate::validate_record_key(value).map_err(stringly),
135    }
136}
137
138/// Renders one validator's error as the message a record validator repeats.
139fn stringly<E: std::fmt::Display>(error: E) -> String {
140    error.to_string()
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn every_format_name_round_trips() {
149        for format in [
150            StringFormat::AtUri,
151            StringFormat::Did,
152            StringFormat::Handle,
153            StringFormat::AtIdentifier,
154            StringFormat::Nsid,
155            StringFormat::Cid,
156            StringFormat::Datetime,
157            StringFormat::Language,
158            StringFormat::Uri,
159            StringFormat::Tid,
160            StringFormat::RecordKey,
161        ] {
162            assert_eq!(StringFormat::parse(format.as_str()), Some(format));
163        }
164    }
165
166    #[test]
167    fn an_at_identifier_accepts_either_half() {
168        assert!(check(StringFormat::AtIdentifier, "did:web:example.com").is_ok());
169        assert!(check(StringFormat::AtIdentifier, "handle.example.com").is_ok());
170        assert!(check(StringFormat::AtIdentifier, "123").is_err());
171    }
172}