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}