Skip to main content

didbot_schema/
lib.rs

1//! Lexicon schemas, as a typed tree a record can be checked against, and the
2//! identifiers and string formats the check reads.
3//!
4//! `didbot-lexicon` embeds the documents and checks that they are well
5//! formed. Validating a *record* also needs the identifier validators — DIDs,
6//! handles, TIDs, record keys, `at://` URIs — so the schema model lives here,
7//! beside them. It builds for `wasm32-unknown-unknown`, so a browser checks a
8//! record with the same code the store runs; [`validate_with`] is the whole
9//! of what a write's shape must satisfy.
10//!
11//! # What this covers
12//!
13//! The Lexicon specification's type system: object, array, string, integer,
14//! boolean, bytes, cid-link, blob, unknown, ref, union, and every constraint
15//! those carry. [`Method`] and [`MethodOutput`] carry a query's or
16//! procedure's own parameters, input and output on top of that, so
17//! [`Catalog::validate_input`] and [`Catalog::validate_output`] check an
18//! XRPC body the same way [`validate_with`] checks a record.
19//!
20//! # Unrecognised things are carried, not refused
21//!
22//! Two rules, both from the specification, both about forward compatibility,
23//! and both easy to get backwards. An object property no schema declares is
24//! **preserved** rather than rejected — a client written against a newer
25//! version of a lexicon must be able to write through an older server without
26//! silently losing fields. And a `type` this build has never heard of parses
27//! to [`Schema::Unrecognised`] and accepts anything, for the same reason
28//! [`crate::format::StringFormat::parse`] shrugs at an unknown format: the
29//! Lexicon language grows, and a server that treated growth as corruption
30//! would break on a schema change it did not author.
31//!
32//! `knownValues` is likewise advisory and never enforced. It is the
33//! specification's way of writing down an *open* set — the exact opposite of
34//! `enum`, which is closed and is enforced.
35
36#![forbid(unsafe_code)]
37
38pub mod aturi;
39pub mod format;
40pub mod record_key;
41pub mod tid;
42
43mod check;
44mod shape;
45
46pub use aturi::{AtUri, AtUriError};
47pub use check::{InvalidRecord, MAX_SCHEMA_DEPTH};
48pub use record_key::{validate_record_key, RecordKeyError, MAX_RECORD_KEY_LENGTH};
49pub use shape::{
50    schema_is_held, validate_collection, validate_shape, validate_with, ShapeError, Stance,
51};
52
53use std::collections::{BTreeMap, BTreeSet};
54
55use serde_json::Value;
56
57use crate::format::StringFormat;
58
59/// Why a lexicon document could not be turned into a schema tree.
60///
61/// Every one of these is a defect in a lexicon document rather than in a
62/// record. For the documents this project embeds it is a build-time bug, and
63/// the unit tests in this module are what catch it.
64#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
65pub enum SchemaError {
66    /// A definition or property was not a JSON object.
67    #[error("{path}: schema is not an object")]
68    NotAnObject {
69        /// Where in the document it sits.
70        path: String,
71    },
72    /// A schema had no `type`.
73    #[error("{path}: schema has no `type`")]
74    NoType {
75        /// Where in the document it sits.
76        path: String,
77    },
78    /// A keyword held the wrong kind of JSON value.
79    #[error("{path}: `{keyword}` is not {expected}")]
80    BadKeyword {
81        /// Where in the document it sits.
82        path: String,
83        /// The keyword that was wrong.
84        keyword: &'static str,
85        /// What it should have been.
86        expected: &'static str,
87    },
88    /// A record definition had no `record` object.
89    #[error("{path}: record definition has no `record` schema")]
90    RecordWithoutSchema {
91        /// Where in the document it sits.
92        path: String,
93    },
94    /// A `type` that is only a field type appeared in definition position.
95    ///
96    /// `unknown` and `ref` describe a *slot inside* a schema: "whatever the
97    /// writer put here" and "look over there". Neither says anything a
98    /// document could define, so upstream refuses both under `defs`.
99    #[error("{path}: `{kind}` is a field type and cannot be a definition")]
100    NotADefinition {
101        /// Where in the document it sits.
102        path: String,
103        /// The `type` as written.
104        kind: &'static str,
105    },
106    /// A primary definition was named something other than `main`.
107    ///
108    /// Records and methods are addressed by the NSID of the document holding
109    /// them, so a second one in the same document has no name to be reached
110    /// by; the specification puts the primary under `main` and only `main`.
111    #[error("{path}: a `{kind}` is a primary definition and must be named `main`")]
112    PrimaryNotMain {
113        /// Where in the document it sits.
114        path: String,
115        /// The `type` as written.
116        kind: &'static str,
117    },
118}
119
120/// A string schema and everything that constrains it.
121#[derive(Debug, Clone, PartialEq, Eq, Default)]
122pub struct StringSchema {
123    /// The only value permitted, if the schema names one.
124    pub constant: Option<String>,
125    /// The closed set of permitted values, if the schema names one.
126    ///
127    /// Distinct from `knownValues`, which is open and is not enforced.
128    pub choices: Option<Vec<String>>,
129    /// The declared format, if this build recognises it.
130    pub format: Option<StringFormat>,
131    /// The format name as written, kept even when unrecognised so that an
132    /// error can name it.
133    pub format_name: Option<String>,
134    /// Minimum length in UTF-8 bytes.
135    pub min_length: Option<usize>,
136    /// Maximum length in UTF-8 bytes.
137    pub max_length: Option<usize>,
138    /// Minimum length in grapheme clusters.
139    pub min_graphemes: Option<usize>,
140    /// Maximum length in grapheme clusters.
141    pub max_graphemes: Option<usize>,
142}
143
144/// An integer schema and everything that constrains it.
145#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct IntegerSchema {
147    /// The only value permitted, if the schema names one.
148    pub constant: Option<i64>,
149    /// The closed set of permitted values, if the schema names one.
150    pub choices: Option<Vec<i64>>,
151    /// Smallest permitted value, inclusive.
152    pub minimum: Option<i64>,
153    /// Largest permitted value, inclusive.
154    pub maximum: Option<i64>,
155}
156
157/// An object schema: its properties and which of them are required or
158/// nullable.
159#[derive(Debug, Clone, PartialEq, Eq, Default)]
160pub struct ObjectSchema {
161    /// Properties that must be present.
162    pub required: BTreeSet<String>,
163    /// Properties that may be present and null.
164    pub nullable: BTreeSet<String>,
165    /// The declared properties. Anything not named here is preserved.
166    pub properties: BTreeMap<String, Schema>,
167}
168
169/// One schema node.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum Schema {
172    /// `boolean`, optionally pinned to one value.
173    Boolean {
174        /// The only value permitted, if the schema names one.
175        constant: Option<bool>,
176    },
177    /// `integer`.
178    Integer(IntegerSchema),
179    /// `string`.
180    String(StringSchema),
181    /// `bytes`, whose length bounds are on the decoded byte count.
182    Bytes {
183        /// Smallest permitted decoded length, in bytes.
184        min_length: Option<usize>,
185        /// Largest permitted decoded length, in bytes.
186        max_length: Option<usize>,
187    },
188    /// `cid-link`: `{"$link": "<cid>"}`.
189    CidLink,
190    /// `blob`.
191    Blob {
192        /// MIME patterns the blob's type must match, if the schema names any.
193        accept: Vec<String>,
194        /// Largest permitted `size`, in bytes.
195        max_size: Option<u64>,
196    },
197    /// `array`.
198    Array {
199        /// The element schema, if the document gave one.
200        items: Option<Box<Schema>>,
201        /// Fewest permitted elements.
202        min_length: Option<usize>,
203        /// Most permitted elements.
204        max_length: Option<usize>,
205    },
206    /// `object`.
207    Object(ObjectSchema),
208    /// `ref`: a pointer at another definition, resolved at check time.
209    Ref(String),
210    /// `union`: one of several referenced definitions, chosen by `$type`.
211    Union {
212        /// The definitions this union admits.
213        refs: Vec<String>,
214        /// Whether a `$type` outside `refs` is refused. Open unions accept
215        /// one, which is how a consumer survives a variant added later.
216        closed: bool,
217    },
218    /// `unknown`: any object, but an object.
219    Unknown,
220    /// `null`.
221    Null,
222    /// `token`: as a field type, a placeholder that carries no data.
223    Token,
224    /// A `type` this build does not know, which accepts anything.
225    Unrecognised(String),
226}
227
228/// One definition in a document.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum Def {
231    /// A `record`: a key strategy and the object schema of its body.
232    Record {
233        /// The record key strategy, as written.
234        key: String,
235        /// The record body's schema.
236        record: Schema,
237    },
238    /// A `token`: a bare identifier with no data.
239    Token,
240    /// Any other definition usable as data — an object, a string, an array.
241    Data(Schema),
242    /// A `query`, `procedure`, `subscription` or `permission-set`.
243    ///
244    /// Kept rather than dropped for two reasons. A ref pointing at one is
245    /// reported as pointing at something that is not data, instead of as a
246    /// dangling ref. And the schemas a method declares are what the
247    /// conformance harness checks this server's own HTTP against: an XRPC
248    /// response is a JSON value with a schema, and the schema is in the
249    /// document rather than in anything written here.
250    Method(Box<Method>),
251}
252
253/// A `query`, `procedure`, `subscription` or `permission-set` definition.
254///
255/// Everything a method declares that is a *schema*, and nothing else. The
256/// encoding strings are not kept: a method whose output is `application/cbor`
257/// or `*/*` carries no schema and is [`Method::output`] `None`, which is the
258/// only thing a caller can act on.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct Method {
261    /// The definition's `type`, as written.
262    pub kind: String,
263    /// The `parameters` object, when the method declares one.
264    ///
265    /// Query parameters arrive as strings and are coerced by whatever parsed
266    /// the request, so this is here to be read rather than to check a request
267    /// body against.
268    pub parameters: Option<Schema>,
269    /// The schema of a JSON request body, when the method takes one.
270    pub input: Option<Schema>,
271    /// The schema of a JSON response body, when the method returns one.
272    pub output: Option<Schema>,
273    /// The error names the method declares, in the order the document lists
274    /// them.
275    ///
276    /// A client generates a typed class per name, so an error this server
277    /// invents is one the client reports as an unknown failure rather than as
278    /// the condition it is.
279    pub errors: Vec<String>,
280}
281
282/// A set of lexicon documents that can resolve refs between themselves.
283#[derive(Debug, Clone, Default)]
284pub struct Catalog {
285    documents: BTreeMap<String, BTreeMap<String, Def>>,
286}
287
288impl Catalog {
289    /// An empty catalog.
290    pub fn new() -> Self {
291        Self::default()
292    }
293
294    /// Adds one parsed lexicon document.
295    pub fn insert(&mut self, doc: &didbot_lexicon::lexicon::LexiconDoc) -> Result<(), SchemaError> {
296        let mut defs = BTreeMap::new();
297        for (name, body) in &doc.defs {
298            defs.insert(
299                name.clone(),
300                parse_def(&format!("{}#{name}", doc.id), name, body)?,
301            );
302        }
303        self.documents.insert(doc.id.clone(), defs);
304        Ok(())
305    }
306
307    /// Builds a catalog from parsed documents.
308    pub fn from_documents<'a>(
309        docs: impl IntoIterator<Item = &'a didbot_lexicon::lexicon::LexiconDoc>,
310    ) -> Result<Self, SchemaError> {
311        let mut catalog = Self::new();
312        for doc in docs {
313            catalog.insert(doc)?;
314        }
315        Ok(catalog)
316    }
317
318    /// The catalog of the documents this deployment embeds.
319    ///
320    /// Built once and shared. Failure is a defect in a document this binary
321    /// was compiled from, so it is reported as an empty catalog rather than
322    /// panicking at an arbitrary first write; the unit tests in this module
323    /// and in `didbot-lexicon` are what actually catch it.
324    pub fn embedded() -> &'static Self {
325        static EMBEDDED: std::sync::OnceLock<Catalog> = std::sync::OnceLock::new();
326        EMBEDDED.get_or_init(|| {
327            let docs = match didbot_lexicon::lexicon::load_all() {
328                Ok(docs) => docs,
329                Err(error) => {
330                    tracing::error!(%error, "embedded lexicons did not parse");
331                    return Catalog::new();
332                }
333            };
334            Catalog::from_documents(&docs).unwrap_or_else(|error| {
335                tracing::error!(%error, "embedded lexicons are not valid schemas");
336                Catalog::new()
337            })
338        })
339    }
340
341    /// Whether this catalog holds a record definition for a collection.
342    pub fn defines_record(&self, collection: &str) -> bool {
343        matches!(self.lookup(collection, "main"), Some(Def::Record { .. }))
344    }
345
346    /// The record key strategy a collection's `main` definition declares.
347    ///
348    /// `None` when this catalog has no record definition for the collection.
349    /// The string is returned exactly as the document wrote it, because
350    /// deciding which strategies are supported belongs to the store that has
351    /// to honour them; see `didbot-pds`'s `records::resolve_key`.
352    pub fn record_key(&self, collection: &str) -> Option<&str> {
353        match self.lookup(collection, "main") {
354            Some(Def::Record { key, .. }) => Some(key.as_str()),
355            _ => None,
356        }
357    }
358
359    /// One definition, by document id and definition name.
360    pub fn lookup(&self, document: &str, name: &str) -> Option<&Def> {
361        self.documents.get(document)?.get(name)
362    }
363
364    /// Resolves a `ref` string written inside `from`.
365    ///
366    /// Three forms, all of them in the specification: `#name` is local to the
367    /// document doing the referring, `nsid#name` names a definition in
368    /// another, and a bare `nsid` names that document's `main`.
369    pub fn resolve(&self, from: &str, reference: &str) -> Option<(String, &Def)> {
370        let (document, name) = split_ref(from, reference);
371        let def = self.lookup(&document, &name)?;
372        Some((format!("{document}#{name}"), def))
373    }
374
375    /// Every collection this catalog defines a record for.
376    pub fn record_collections(&self) -> Vec<&str> {
377        self.documents
378            .iter()
379            .filter(|(_, defs)| matches!(defs.get("main"), Some(Def::Record { .. })))
380            .map(|(id, _)| id.as_str())
381            .collect()
382    }
383
384    /// One method definition, by NSID.
385    ///
386    /// `None` when this catalog holds no such document, or when the
387    /// document's `main` is a record rather than a method.
388    pub fn method(&self, nsid: &str) -> Option<&Method> {
389        match self.lookup(nsid, "main") {
390            Some(Def::Method(method)) => Some(method),
391            _ => None,
392        }
393    }
394
395    /// Checks a JSON response body against the output schema `nsid` declares.
396    ///
397    /// The whole reason this exists: a response is a value with a schema, the
398    /// schema is written down in a document somebody else maintains, and a
399    /// server that checks its own output against a struct it wrote from
400    /// reading that document is checking its reading rather than the
401    /// document.
402    ///
403    /// [`MethodOutput::Unchecked`] is not a pass. It says the document
404    /// declares no JSON schema for this method's response — a CAR file, a
405    /// blob — and a caller that wants coverage has to say what it checks
406    /// instead.
407    pub fn validate_output(&self, nsid: &str, body: &Value) -> MethodOutput {
408        let Some(method) = self.method(nsid) else {
409            return MethodOutput::NoSuchMethod;
410        };
411        let Some(schema) = &method.output else {
412            return MethodOutput::Unchecked;
413        };
414        match check::check(self, nsid, schema, body) {
415            Ok(()) => MethodOutput::Valid,
416            Err(error) => MethodOutput::Invalid(error),
417        }
418    }
419
420    /// Checks a JSON request body against the input schema `nsid` declares.
421    ///
422    /// The other direction, and it is worth checking too: a request this
423    /// server's own tests compose is one a client would have to be able to
424    /// compose, so a fixture that quietly stopped satisfying the document
425    /// would make every response checked against it prove nothing.
426    pub fn validate_input(&self, nsid: &str, body: &Value) -> MethodOutput {
427        let Some(method) = self.method(nsid) else {
428            return MethodOutput::NoSuchMethod;
429        };
430        let Some(schema) = &method.input else {
431            return MethodOutput::Unchecked;
432        };
433        match check::check(self, nsid, schema, body) {
434            Ok(()) => MethodOutput::Valid,
435            Err(error) => MethodOutput::Invalid(error),
436        }
437    }
438
439    /// Checks a record against the collection's `main` record definition.
440    ///
441    /// The collection must be one this catalog defines; see
442    /// [`validate_with`], which is what decides that a record arriving over
443    /// the wire has a schema at all.
444    pub fn validate_record(&self, collection: &str, record: &Value) -> Result<(), InvalidRecord> {
445        let Some(Def::Record { record: schema, .. }) = self.lookup(collection, "main") else {
446            return Err(InvalidRecord::no_such_record(collection));
447        };
448        check::check(self, collection, schema, record)
449    }
450}
451
452/// What checking a body against a method's declared schema found.
453///
454/// Four outcomes rather than a `Result`, because two of the failures are
455/// about the *catalog* rather than about the body: a method nobody vendored
456/// and a method that declares no schema are both silence, and silence that
457/// reads as success is how a conformance suite stops conforming.
458#[derive(Debug, Clone, PartialEq, Eq)]
459pub enum MethodOutput {
460    /// The body satisfies the schema.
461    Valid,
462    /// The body does not.
463    Invalid(InvalidRecord),
464    /// The catalog holds no method by that name.
465    NoSuchMethod,
466    /// The method declares no JSON schema for this body.
467    Unchecked,
468}
469
470impl MethodOutput {
471    /// Panics unless the body was checked and satisfied its schema.
472    ///
473    /// For a test that means to assert conformance: it fails on a body that
474    /// is wrong *and* on one that was never checked, which is the outcome a
475    /// bare boolean would hide.
476    #[track_caller]
477    pub fn expect_valid(self, what: &str) {
478        match self {
479            Self::Valid => {}
480            Self::Invalid(error) => panic!("{what} does not satisfy its lexicon: {error}"),
481            Self::NoSuchMethod => {
482                panic!("{what}: no such method in this catalog — is the document vendored?")
483            }
484            Self::Unchecked => {
485                panic!(
486                    "{what}: the document declares no schema for this body, so nothing was checked"
487                )
488            }
489        }
490    }
491}
492
493/// Splits a `ref` into the document it names and the definition inside it.
494fn split_ref(from: &str, reference: &str) -> (String, String) {
495    match reference.split_once('#') {
496        Some(("", name)) => (from.to_owned(), name.to_owned()),
497        Some((document, name)) => (document.to_owned(), name.to_owned()),
498        None => (reference.to_owned(), "main".to_owned()),
499    }
500}
501
502/// Types that may only appear under `defs` as `main`.
503const PRIMARY_TYPES: &[&str] = &[
504    "record",
505    "query",
506    "procedure",
507    "subscription",
508    "permission-set",
509];
510
511/// Types that are field types only, and are not definitions.
512const FIELD_ONLY_TYPES: &[&str] = &["unknown", "ref"];
513
514/// Parses one definition.
515///
516/// `name` is the key it sits under in `defs`, which is what decides whether a
517/// primary definition is in a legal position; `path` is the same thing
518/// rendered for error messages.
519fn parse_def(path: &str, name: &str, body: &Value) -> Result<Def, SchemaError> {
520    let object = body.as_object().ok_or_else(|| SchemaError::NotAnObject {
521        path: path.to_owned(),
522    })?;
523    let kind = object
524        .get("type")
525        .and_then(Value::as_str)
526        .ok_or_else(|| SchemaError::NoType {
527            path: path.to_owned(),
528        })?;
529    if let Some(kind) = FIELD_ONLY_TYPES.iter().find(|field| **field == kind) {
530        return Err(SchemaError::NotADefinition {
531            path: path.to_owned(),
532            kind,
533        });
534    }
535    if name != "main" {
536        if let Some(kind) = PRIMARY_TYPES.iter().find(|primary| **primary == kind) {
537            return Err(SchemaError::PrimaryNotMain {
538                path: path.to_owned(),
539                kind,
540            });
541        }
542    }
543    match kind {
544        "record" => {
545            let record = object
546                .get("record")
547                .ok_or_else(|| SchemaError::RecordWithoutSchema {
548                    path: path.to_owned(),
549                })?;
550            Ok(Def::Record {
551                key: object
552                    .get("key")
553                    .and_then(Value::as_str)
554                    .unwrap_or("tid")
555                    .to_owned(),
556                record: parse_schema(&format!("{path}.record"), record)?,
557            })
558        }
559        "token" => Ok(Def::Token),
560        "query" | "procedure" | "subscription" | "permission-set" => {
561            Ok(Def::Method(Box::new(parse_method(path, kind, object)?)))
562        }
563        _ => Ok(Def::Data(parse_schema(path, body)?)),
564    }
565}
566
567/// Parses a method definition's schemas.
568///
569/// `parameters` is a schema in its own right; `input` and `output` are
570/// wrappers carrying an `encoding` and an optional `schema`. A body with an
571/// encoding and no schema — `com.atproto.sync.getRepo` returns a CAR file —
572/// is `None` rather than an error, because there is nothing to check and that
573/// is the document saying so.
574fn parse_method(
575    path: &str,
576    kind: &str,
577    object: &serde_json::Map<String, Value>,
578) -> Result<Method, SchemaError> {
579    let body = |keyword: &'static str| -> Result<Option<Schema>, SchemaError> {
580        let Some(wrapper) = object.get(keyword) else {
581            return Ok(None);
582        };
583        let wrapper = wrapper.as_object().ok_or_else(|| SchemaError::BadKeyword {
584            path: path.to_owned(),
585            keyword,
586            expected: "an object",
587        })?;
588        match wrapper.get("schema") {
589            None | Some(Value::Null) => Ok(None),
590            Some(schema) => Ok(Some(parse_schema(&format!("{path}.{keyword}"), schema)?)),
591        }
592    };
593    let parameters = match object.get("parameters") {
594        None | Some(Value::Null) => None,
595        Some(parameters) => Some(parse_schema(&format!("{path}.parameters"), parameters)?),
596    };
597    let errors = object
598        .get("errors")
599        .and_then(Value::as_array)
600        .map(|declared| {
601            declared
602                .iter()
603                .filter_map(|error| error.get("name").and_then(Value::as_str))
604                .map(str::to_owned)
605                .collect()
606        })
607        .unwrap_or_default();
608    Ok(Method {
609        kind: kind.to_owned(),
610        parameters,
611        input: body("input")?,
612        output: body("output")?,
613        errors,
614    })
615}
616
617/// Reads a keyword as a non-negative size.
618fn size(
619    path: &str,
620    object: &serde_json::Map<String, Value>,
621    keyword: &'static str,
622) -> Result<Option<usize>, SchemaError> {
623    match object.get(keyword) {
624        None | Some(Value::Null) => Ok(None),
625        Some(value) => value
626            .as_u64()
627            .and_then(|n| usize::try_from(n).ok())
628            .map(Some)
629            .ok_or(SchemaError::BadKeyword {
630                path: path.to_owned(),
631                keyword,
632                expected: "a non-negative integer",
633            }),
634    }
635}
636
637/// Reads a keyword as a signed integer.
638fn integer(
639    path: &str,
640    object: &serde_json::Map<String, Value>,
641    keyword: &'static str,
642) -> Result<Option<i64>, SchemaError> {
643    match object.get(keyword) {
644        None | Some(Value::Null) => Ok(None),
645        Some(value) => value.as_i64().map(Some).ok_or(SchemaError::BadKeyword {
646            path: path.to_owned(),
647            keyword,
648            expected: "an integer",
649        }),
650    }
651}
652
653/// Reads a keyword as a list of strings.
654fn strings(
655    path: &str,
656    object: &serde_json::Map<String, Value>,
657    keyword: &'static str,
658) -> Result<Option<Vec<String>>, SchemaError> {
659    let Some(value) = object.get(keyword) else {
660        return Ok(None);
661    };
662    let items = value.as_array().ok_or(SchemaError::BadKeyword {
663        path: path.to_owned(),
664        keyword,
665        expected: "an array",
666    })?;
667    items
668        .iter()
669        .map(|item| {
670            item.as_str()
671                .map(str::to_owned)
672                .ok_or(SchemaError::BadKeyword {
673                    path: path.to_owned(),
674                    keyword,
675                    expected: "an array of strings",
676                })
677        })
678        .collect::<Result<Vec<_>, _>>()
679        .map(Some)
680}
681
682/// Parses one schema node.
683fn parse_schema(path: &str, body: &Value) -> Result<Schema, SchemaError> {
684    let object = body.as_object().ok_or_else(|| SchemaError::NotAnObject {
685        path: path.to_owned(),
686    })?;
687    let kind = object
688        .get("type")
689        .and_then(Value::as_str)
690        .ok_or_else(|| SchemaError::NoType {
691            path: path.to_owned(),
692        })?;
693
694    Ok(match kind {
695        "boolean" => Schema::Boolean {
696            constant: object.get("const").and_then(Value::as_bool),
697        },
698        "integer" => Schema::Integer(IntegerSchema {
699            constant: integer(path, object, "const")?,
700            choices: match object.get("enum") {
701                None => None,
702                Some(value) => Some(
703                    value
704                        .as_array()
705                        .ok_or(SchemaError::BadKeyword {
706                            path: path.to_owned(),
707                            keyword: "enum",
708                            expected: "an array",
709                        })?
710                        .iter()
711                        .map(|item| {
712                            item.as_i64().ok_or(SchemaError::BadKeyword {
713                                path: path.to_owned(),
714                                keyword: "enum",
715                                expected: "an array of integers",
716                            })
717                        })
718                        .collect::<Result<Vec<_>, _>>()?,
719                ),
720            },
721            minimum: integer(path, object, "minimum")?,
722            maximum: integer(path, object, "maximum")?,
723        }),
724        "string" => {
725            let format_name = object
726                .get("format")
727                .and_then(Value::as_str)
728                .map(str::to_owned);
729            Schema::String(StringSchema {
730                constant: object
731                    .get("const")
732                    .and_then(Value::as_str)
733                    .map(str::to_owned),
734                choices: strings(path, object, "enum")?,
735                format: format_name.as_deref().and_then(StringFormat::parse),
736                format_name,
737                min_length: size(path, object, "minLength")?,
738                max_length: size(path, object, "maxLength")?,
739                min_graphemes: size(path, object, "minGraphemes")?,
740                max_graphemes: size(path, object, "maxGraphemes")?,
741            })
742        }
743        "bytes" => Schema::Bytes {
744            min_length: size(path, object, "minLength")?,
745            max_length: size(path, object, "maxLength")?,
746        },
747        "cid-link" => Schema::CidLink,
748        "blob" => Schema::Blob {
749            accept: strings(path, object, "accept")?.unwrap_or_default(),
750            max_size: size(path, object, "maxSize")?.map(|n| n as u64),
751        },
752        "array" => Schema::Array {
753            items: match object.get("items") {
754                None => None,
755                Some(items) => Some(Box::new(parse_schema(&format!("{path}.items"), items)?)),
756            },
757            min_length: size(path, object, "minLength")?,
758            max_length: size(path, object, "maxLength")?,
759        },
760        "object" => Schema::Object(parse_object(path, object)?),
761        "params" => Schema::Object(parse_object(path, object)?),
762        "ref" => Schema::Ref(
763            object
764                .get("ref")
765                .and_then(Value::as_str)
766                .ok_or(SchemaError::BadKeyword {
767                    path: path.to_owned(),
768                    keyword: "ref",
769                    expected: "a string",
770                })?
771                .to_owned(),
772        ),
773        "union" => Schema::Union {
774            refs: strings(path, object, "refs")?.unwrap_or_default(),
775            closed: object
776                .get("closed")
777                .and_then(Value::as_bool)
778                .unwrap_or(false),
779        },
780        "unknown" => Schema::Unknown,
781        "null" => Schema::Null,
782        "token" => Schema::Token,
783        other => Schema::Unrecognised(other.to_owned()),
784    })
785}
786
787/// Parses the body of an object schema.
788fn parse_object(
789    path: &str,
790    object: &serde_json::Map<String, Value>,
791) -> Result<ObjectSchema, SchemaError> {
792    let mut properties = BTreeMap::new();
793    if let Some(declared) = object.get("properties") {
794        let declared = declared.as_object().ok_or(SchemaError::BadKeyword {
795            path: path.to_owned(),
796            keyword: "properties",
797            expected: "an object",
798        })?;
799        for (name, body) in declared {
800            properties.insert(name.clone(), parse_schema(&format!("{path}.{name}"), body)?);
801        }
802    }
803    Ok(ObjectSchema {
804        required: strings(path, object, "required")?
805            .unwrap_or_default()
806            .into_iter()
807            .collect(),
808        nullable: strings(path, object, "nullable")?
809            .unwrap_or_default()
810            .into_iter()
811            .collect(),
812        properties,
813    })
814}
815
816#[cfg(test)]
817mod tests {
818    /// A collection this deployment holds no schema for, used here as an
819    /// ordinary record that is not one of its own.
820    const THING: &str = "com.example.thing";
821
822    use super::*;
823
824    #[test]
825    fn every_embedded_lexicon_becomes_a_schema() {
826        let docs = didbot_lexicon::lexicon::load_all().expect("embedded lexicons parse");
827        let catalog = Catalog::from_documents(&docs).expect("embedded lexicons are valid schemas");
828        let mut collections = catalog.record_collections();
829        collections.sort_unstable();
830        let mut known = didbot_lexicon::nsid::ALL.to_vec();
831        known.sort_unstable();
832        assert_eq!(collections, known);
833    }
834
835    #[test]
836    fn the_shared_catalog_is_not_empty() {
837        // `embedded` degrades to an empty catalog rather than panicking, so
838        // the assertion that it built is worth making once.
839        assert!(Catalog::embedded().defines_record(didbot_lexicon::nsid::REGISTRATION));
840    }
841
842    #[test]
843    fn a_collections_key_strategy_comes_off_its_record_definition() {
844        let catalog = Catalog::embedded();
845        assert_eq!(
846            catalog.record_key(didbot_lexicon::nsid::REGISTRATION),
847            Some("literal:self")
848        );
849        // A collection this deployment holds no document for has no strategy
850        // here, whoever else defines one: the record is the ordinary case.
851        assert_eq!(catalog.record_key(THING), None);
852        assert_eq!(catalog.record_key("com.example.nothing"), None);
853    }
854
855    #[test]
856    fn refs_resolve_in_all_three_written_forms() {
857        let doc: didbot_lexicon::lexicon::LexiconDoc = serde_json::from_value(serde_json::json!({
858            "lexicon": 1,
859            "id": "com.example.thing",
860            "defs": {
861                "main": {"type": "record", "key": "tid", "record": {"type": "object"}},
862                "side": {"type": "object"}
863            }
864        }))
865        .expect("a hand-written document parses");
866        let catalog = Catalog::from_documents(std::iter::once(&doc)).expect("valid");
867
868        assert!(catalog.resolve("com.example.thing", "#side").is_some());
869        assert!(catalog
870            .resolve("com.example.other", "com.example.thing#side")
871            .is_some());
872        assert!(catalog
873            .resolve("com.example.other", "com.example.thing")
874            .is_some());
875        assert!(catalog.resolve("com.example.thing", "#missing").is_none());
876    }
877
878    #[test]
879    fn an_unrecognised_type_parses_rather_than_failing() {
880        let schema = parse_schema("x", &serde_json::json!({"type": "hologram"}))
881            .expect("an unknown type is not a parse failure");
882        assert_eq!(schema, Schema::Unrecognised("hologram".to_owned()));
883    }
884}