1#![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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
65pub enum SchemaError {
66 #[error("{path}: schema is not an object")]
68 NotAnObject {
69 path: String,
71 },
72 #[error("{path}: schema has no `type`")]
74 NoType {
75 path: String,
77 },
78 #[error("{path}: `{keyword}` is not {expected}")]
80 BadKeyword {
81 path: String,
83 keyword: &'static str,
85 expected: &'static str,
87 },
88 #[error("{path}: record definition has no `record` schema")]
90 RecordWithoutSchema {
91 path: String,
93 },
94 #[error("{path}: `{kind}` is a field type and cannot be a definition")]
100 NotADefinition {
101 path: String,
103 kind: &'static str,
105 },
106 #[error("{path}: a `{kind}` is a primary definition and must be named `main`")]
112 PrimaryNotMain {
113 path: String,
115 kind: &'static str,
117 },
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Default)]
122pub struct StringSchema {
123 pub constant: Option<String>,
125 pub choices: Option<Vec<String>>,
129 pub format: Option<StringFormat>,
131 pub format_name: Option<String>,
134 pub min_length: Option<usize>,
136 pub max_length: Option<usize>,
138 pub min_graphemes: Option<usize>,
140 pub max_graphemes: Option<usize>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct IntegerSchema {
147 pub constant: Option<i64>,
149 pub choices: Option<Vec<i64>>,
151 pub minimum: Option<i64>,
153 pub maximum: Option<i64>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Default)]
160pub struct ObjectSchema {
161 pub required: BTreeSet<String>,
163 pub nullable: BTreeSet<String>,
165 pub properties: BTreeMap<String, Schema>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum Schema {
172 Boolean {
174 constant: Option<bool>,
176 },
177 Integer(IntegerSchema),
179 String(StringSchema),
181 Bytes {
183 min_length: Option<usize>,
185 max_length: Option<usize>,
187 },
188 CidLink,
190 Blob {
192 accept: Vec<String>,
194 max_size: Option<u64>,
196 },
197 Array {
199 items: Option<Box<Schema>>,
201 min_length: Option<usize>,
203 max_length: Option<usize>,
205 },
206 Object(ObjectSchema),
208 Ref(String),
210 Union {
212 refs: Vec<String>,
214 closed: bool,
217 },
218 Unknown,
220 Null,
222 Token,
224 Unrecognised(String),
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum Def {
231 Record {
233 key: String,
235 record: Schema,
237 },
238 Token,
240 Data(Schema),
242 Method(Box<Method>),
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct Method {
261 pub kind: String,
263 pub parameters: Option<Schema>,
269 pub input: Option<Schema>,
271 pub output: Option<Schema>,
273 pub errors: Vec<String>,
280}
281
282#[derive(Debug, Clone, Default)]
284pub struct Catalog {
285 documents: BTreeMap<String, BTreeMap<String, Def>>,
286}
287
288impl Catalog {
289 pub fn new() -> Self {
291 Self::default()
292 }
293
294 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 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 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 pub fn defines_record(&self, collection: &str) -> bool {
343 matches!(self.lookup(collection, "main"), Some(Def::Record { .. }))
344 }
345
346 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 pub fn lookup(&self, document: &str, name: &str) -> Option<&Def> {
361 self.documents.get(document)?.get(name)
362 }
363
364 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
459pub enum MethodOutput {
460 Valid,
462 Invalid(InvalidRecord),
464 NoSuchMethod,
466 Unchecked,
468}
469
470impl MethodOutput {
471 #[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
493fn 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
502const PRIMARY_TYPES: &[&str] = &[
504 "record",
505 "query",
506 "procedure",
507 "subscription",
508 "permission-set",
509];
510
511const FIELD_ONLY_TYPES: &[&str] = &["unknown", "ref"];
513
514fn 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
567fn 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
617fn 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
637fn 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
653fn 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
682fn 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
787fn 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 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 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 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}