Skip to main content

didbot_lexicon/
lexicon.rs

1//! The lexicon documents this project defines, embedded in the binary.
2//!
3//! The JSON under `lexicons/` is the source of truth for the record shapes
4//! this project defines and for the `bot.did.*` XRPC methods its server
5//! answers. It is compiled in with [`include_str!`] rather than read from disk so that a
6//! deployed binary cannot disagree with the schemas it was built from, and so
7//! that the consistency checks below run as ordinary unit tests.
8//!
9//! This module deliberately does not validate *records* or *responses*. It
10//! validates that the schema documents themselves are well formed and that
11//! their identifiers agree with their paths and with [`crate::nsid`]. Record
12//! validation belongs with the repository layer, against a real lexicon
13//! resolver; a route's answer is checked against its document by
14//! `crates/didbot/tests/conformance/bot_did.rs`.
15
16use serde::{Deserialize, Deserializer};
17
18use crate::nsid_syntax;
19
20/// A lexicon document as published under `lexicons/`.
21#[derive(Debug, Clone, Deserialize)]
22pub struct LexiconDoc {
23    /// Lexicon language version. Always `1` today.
24    pub lexicon: u32,
25    /// The NSID this document defines.
26    ///
27    /// Checked against [`nsid_syntax::validate`] while deserializing, so a
28    /// `LexiconDoc` that exists at all names something that could be a
29    /// lexicon somewhere in atproto. Upstream's `lexicon-invalid.json`
30    /// carries `"id": "one-two-three"` for exactly this.
31    #[serde(deserialize_with = "nsid_id")]
32    pub id: String,
33    /// Human-readable description of the document as a whole.
34    #[serde(default)]
35    pub description: Option<String>,
36    /// The definitions in this document, keyed by name. `main` is the primary.
37    pub defs: serde_json::Map<String, serde_json::Value>,
38}
39
40/// Deserializes a document `id`, refusing anything that is not an NSID.
41fn nsid_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
42    let id = String::deserialize(deserializer)?;
43    nsid_syntax::validate(&id).map_err(serde::de::Error::custom)?;
44    Ok(id)
45}
46
47impl LexiconDoc {
48    /// The primary definition, if this document has one.
49    pub fn main(&self) -> Option<&serde_json::Value> {
50        self.defs.get("main")
51    }
52
53    /// The `type` of the primary definition, for example `record` or `query`.
54    pub fn main_type(&self) -> Option<&str> {
55        self.main()?.get("type")?.as_str()
56    }
57
58    /// The record key strategy, for a document whose primary def is a record.
59    pub fn record_key(&self) -> Option<&str> {
60        self.main()?.get("key")?.as_str()
61    }
62}
63
64/// Failure to load one of the embedded lexicon documents.
65#[derive(Debug, thiserror::Error)]
66pub enum LexiconError {
67    /// The document is not valid JSON, or does not match [`LexiconDoc`].
68    #[error("lexicon {path} did not parse: {source}")]
69    Parse {
70        /// Path the document was embedded from.
71        path: &'static str,
72        /// The underlying serde error.
73        #[source]
74        source: serde_json::Error,
75    },
76}
77
78/// One embedded lexicon: the path it came from and its raw JSON text.
79#[derive(Debug, Clone, Copy)]
80pub struct Embedded {
81    /// Repository-relative path, used in error messages and path/id checks.
82    pub path: &'static str,
83    /// The raw document text.
84    pub source: &'static str,
85}
86
87impl Embedded {
88    /// Parses this document.
89    pub fn parse(&self) -> Result<LexiconDoc, LexiconError> {
90        serde_json::from_str(self.source).map_err(|source| LexiconError::Parse {
91            path: self.path,
92            source,
93        })
94    }
95}
96
97macro_rules! embed {
98    ($($path:literal),* $(,)?) => {
99        /// Every lexicon document this project defines.
100        pub const EMBEDDED: &[Embedded] = &[
101            $(Embedded { path: concat!("lexicons/", $path), source: include_str!(concat!("../../../lexicons/", $path)) }),*
102        ];
103    };
104}
105
106embed! {
107    "bot/did/registration.json",
108    "bot/did/operator.json",
109    "bot/did/credential.json",
110    "bot/did/policy.json",
111    "bot/did/policyBinding.json",
112    "bot/did/activateAccount.json",
113    "bot/did/approveAuthorization.json",
114    "bot/did/createAccount.json",
115    "bot/did/createBrowserSignIn.json",
116    "bot/did/createSession.json",
117    "bot/did/deactivateAccount.json",
118    "bot/did/declineAuthorization.json",
119    "bot/did/deleteAccount.json",
120    "bot/did/describeParkedKey.json",
121    "bot/did/freezeAccount.json",
122    "bot/did/getAccountLedger.json",
123    "bot/did/getAuthorization.json",
124    "bot/did/listAccountLedgers.json",
125    "bot/did/listAccounts.json",
126    "bot/did/listPendingAuthorizations.json",
127    "bot/did/parkKey.json",
128    "bot/did/pollOperatorClaim.json",
129    "bot/did/stats.json",
130    "bot/did/unfreezeAccount.json",
131    "bot/did/updateCredentials.json",
132}
133
134/// Parses every embedded document.
135///
136/// ```
137/// let docs = didbot_lexicon::lexicon::load_all().expect("embedded lexicons parse");
138/// assert_eq!(docs.len(), 25);
139/// ```
140pub fn load_all() -> Result<Vec<LexiconDoc>, LexiconError> {
141    EMBEDDED.iter().map(Embedded::parse).collect()
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::nsid;
148
149    fn docs() -> Vec<LexiconDoc> {
150        load_all().expect("every embedded lexicon parses")
151    }
152
153    #[test]
154    fn every_document_declares_lexicon_version_one() {
155        for doc in docs() {
156            assert_eq!(doc.lexicon, 1, "{} declares an unexpected version", doc.id);
157        }
158    }
159
160    #[test]
161    fn document_id_matches_its_path() {
162        // lexicons/bot/did/operator.json must declare
163        // id = bot.did.operator. A mismatch here is the failure
164        // mode where a resolver fetches one schema and gets another.
165        for embedded in EMBEDDED {
166            let doc = embedded.parse().expect("parses");
167            let expected = embedded
168                .path
169                .trim_start_matches("lexicons/")
170                .trim_end_matches(".json")
171                .replace('/', ".");
172            assert_eq!(doc.id, expected, "id/path mismatch at {}", embedded.path);
173        }
174    }
175
176    #[test]
177    fn every_document_id_is_a_known_nsid() {
178        let mut ids: Vec<String> = docs().into_iter().map(|d| d.id).collect();
179        ids.sort();
180        let mut known: Vec<String> = nsid::ALL
181            .iter()
182            .chain(nsid::METHODS)
183            .map(|s| s.to_string())
184            .collect();
185        known.sort();
186        assert_eq!(
187            ids, known,
188            "lexicons/ and nsid::ALL + nsid::METHODS disagree"
189        );
190    }
191
192    #[test]
193    fn every_document_has_a_main_definition() {
194        for doc in docs() {
195            assert!(doc.main().is_some(), "{} has no main def", doc.id);
196        }
197    }
198
199    #[test]
200    fn every_primary_def_is_a_record_a_query_or_a_procedure() {
201        for doc in docs() {
202            let kind = doc
203                .main_type()
204                .unwrap_or_else(|| panic!("{} has no primary type", doc.id));
205            assert!(
206                matches!(kind, "record" | "query" | "procedure"),
207                "{} is a {kind}, which this project does not define",
208                doc.id
209            );
210        }
211    }
212
213    #[test]
214    fn every_record_declares_a_known_key_strategy() {
215        for doc in docs()
216            .iter()
217            .filter(|doc| doc.main_type() == Some("record"))
218        {
219            let key = doc
220                .record_key()
221                .unwrap_or_else(|| panic!("{} has no key", doc.id));
222            assert!(
223                key == "tid" || key == "any" || key.starts_with("literal:"),
224                "{} uses an unrecognized record key strategy: {key}",
225                doc.id
226            );
227        }
228    }
229
230    /// A method document's `id` is what a route is reached at, and the
231    /// verb decides whether the route is a GET or a POST. Both are read
232    /// off the document by `crates/didbot/tests/conformance/bot_did.rs`,
233    /// so a document whose kind disagrees with its route drives the wrong
234    /// request there; this is the same fact checked without a server.
235    #[test]
236    fn every_method_document_is_one_of_the_method_nsids() {
237        let mut ids: Vec<String> = docs()
238            .iter()
239            .filter(|doc| matches!(doc.main_type(), Some("query" | "procedure")))
240            .map(|doc| doc.id.clone())
241            .collect();
242        ids.sort();
243        let mut known: Vec<String> = nsid::METHODS.iter().map(|s| s.to_string()).collect();
244        known.sort();
245        assert_eq!(ids, known);
246    }
247
248    /// Every schema a definition carries that can hold properties: a
249    /// record's body, a method's parameters, its request and its response,
250    /// and a plain definition, which is a schema itself.
251    fn schemas_of(def: &serde_json::Value) -> Vec<&serde_json::Value> {
252        let body = |name: &str| def.get(name).and_then(|body| body.get("schema"));
253        [
254            def.get("record"),
255            def.get("parameters"),
256            body("input"),
257            body("output"),
258        ]
259        .into_iter()
260        .flatten()
261        .chain(def.get("properties").map(|_| def))
262        .collect()
263    }
264
265    /// Every property of `schema`, and of every object nested in it, carries
266    /// a description.
267    fn describes_every_property(id: &str, def: &str, schema: &serde_json::Value) {
268        let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else {
269            return;
270        };
271        for (field, property) in properties {
272            assert!(
273                property
274                    .get("description")
275                    .and_then(|d| d.as_str())
276                    .is_some(),
277                "{id}#{def}.{field} has no description"
278            );
279            if let Some(items) = property.get("items") {
280                describes_every_property(id, def, items);
281            }
282            describes_every_property(id, def, property);
283        }
284    }
285
286    #[test]
287    fn every_definition_and_every_field_is_described() {
288        // Descriptions are what a consent screen and an external reader see.
289        // An undescribed field is a field nobody outside this repo can use.
290        // The top-level `description` is optional in the specification and is
291        // not required here; the primary def carries the document's meaning.
292        for doc in docs() {
293            for (name, def) in &doc.defs {
294                assert!(
295                    def.get("description").and_then(|d| d.as_str()).is_some(),
296                    "{}#{name} has no description",
297                    doc.id
298                );
299                for schema in schemas_of(def) {
300                    describes_every_property(&doc.id, name, schema);
301                }
302            }
303        }
304    }
305
306    #[test]
307    fn the_singleton_records_use_literal_self() {
308        // A registration is one-per-account by design. A tid key there would
309        // silently permit two of them.
310        for doc in docs() {
311            if doc.id == nsid::REGISTRATION {
312                assert_eq!(doc.record_key(), Some("literal:self"), "{}", doc.id);
313            }
314        }
315    }
316}