lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

schema.rs (19904B)


      1 //! Validated schema identities and deterministic module dispatch.
      2 
      3 use alloc::{string::String, vec::Vec};
      4 use core::{fmt, str::FromStr};
      5 
      6 /// Maximum UTF-8 byte length accepted for a schema identifier.
      7 pub const MAX_SCHEMA_ID_BYTES: usize = 255;
      8 
      9 /// Passive metadata that preserves an externally governed schema identity.
     10 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     11 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
     12 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     13 pub struct Metadata {
     14     /// Historical generated type name bound by the schema contract.
     15     pub type_name: &'static str,
     16     /// Canonical schema identifier.
     17     pub schema_id: &'static str,
     18     /// Declared schema generation.
     19     pub schema_version: u16,
     20 }
     21 
     22 /// A canonical, version-suffixed schema identifier.
     23 ///
     24 /// Schema identifiers contain one or more dot-separated namespace segments
     25 /// followed by a positive canonical version segment such as `v1`. Namespace
     26 /// segments begin with a lowercase ASCII letter and contain only lowercase
     27 /// ASCII letters, digits, and underscores.
     28 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     29 pub struct SchemaId {
     30     value: String,
     31     version: u16,
     32 }
     33 
     34 impl SchemaId {
     35     /// Parses and validates a schema identifier.
     36     pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
     37         let value = value.into();
     38         if value.is_empty() {
     39             return Err(Error::EmptySchemaId);
     40         }
     41         if value.len() > MAX_SCHEMA_ID_BYTES {
     42             return Err(Error::SchemaIdTooLong {
     43                 actual: value.len(),
     44                 max: MAX_SCHEMA_ID_BYTES,
     45             });
     46         }
     47 
     48         let (namespace, version_segment) = value
     49             .rsplit_once('.')
     50             .ok_or(Error::MissingSchemaNamespace)?;
     51         if namespace.is_empty() {
     52             return Err(Error::MissingSchemaNamespace);
     53         }
     54         for (index, segment) in namespace.split('.').enumerate() {
     55             if !valid_namespace_segment(segment) {
     56                 return Err(Error::InvalidSchemaNamespaceSegment { index });
     57             }
     58         }
     59 
     60         let version = parse_version_segment(version_segment)?;
     61         Ok(Self { value, version })
     62     }
     63 
     64     /// Returns the canonical identifier text.
     65     pub fn as_str(&self) -> &str {
     66         self.value.as_str()
     67     }
     68 
     69     /// Returns the positive schema generation encoded by the final segment.
     70     pub const fn version(&self) -> u16 {
     71         self.version
     72     }
     73 }
     74 
     75 impl AsRef<str> for SchemaId {
     76     fn as_ref(&self) -> &str {
     77         self.as_str()
     78     }
     79 }
     80 
     81 impl fmt::Display for SchemaId {
     82     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     83         formatter.write_str(self.as_str())
     84     }
     85 }
     86 
     87 impl FromStr for SchemaId {
     88     type Err = Error;
     89 
     90     fn from_str(value: &str) -> Result<Self, Self::Err> {
     91         Self::parse(value)
     92     }
     93 }
     94 
     95 impl TryFrom<&str> for SchemaId {
     96     type Error = Error;
     97 
     98     fn try_from(value: &str) -> Result<Self, Self::Error> {
     99         Self::parse(value)
    100     }
    101 }
    102 
    103 impl TryFrom<String> for SchemaId {
    104     type Error = Error;
    105 
    106     fn try_from(value: String) -> Result<Self, Self::Error> {
    107         Self::parse(value)
    108     }
    109 }
    110 
    111 /// A supported versioned module in the protocol package.
    112 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
    113 #[non_exhaustive]
    114 pub enum ModuleVersion {
    115     /// `capability::v1`.
    116     CapabilityV1,
    117     /// `error::v1`.
    118     ErrorV1,
    119     /// `event::v1`.
    120     EventV1,
    121     /// `runtime::v1`.
    122     RuntimeV1,
    123     /// `radrootsd::transport_publish::v5`.
    124     RadrootsdTransportPublishV5,
    125 }
    126 
    127 impl ModuleVersion {
    128     /// Every module generation supported by this package version.
    129     pub const ALL: [Self; 5] = [
    130         Self::CapabilityV1,
    131         Self::ErrorV1,
    132         Self::EventV1,
    133         Self::RuntimeV1,
    134         Self::RadrootsdTransportPublishV5,
    135     ];
    136 
    137     /// Returns the stable Rust module path relative to `radroots_protocol`.
    138     pub const fn path(self) -> &'static str {
    139         match self {
    140             Self::CapabilityV1 => "capability::v1",
    141             Self::ErrorV1 => "error::v1",
    142             Self::EventV1 => "event::v1",
    143             Self::RuntimeV1 => "runtime::v1",
    144             Self::RadrootsdTransportPublishV5 => "radrootsd::transport_publish::v5",
    145         }
    146     }
    147 
    148     /// Returns the explicit contract generation for the module.
    149     pub const fn generation(self) -> u16 {
    150         match self {
    151             Self::CapabilityV1 | Self::ErrorV1 | Self::EventV1 | Self::RuntimeV1 => 1,
    152             Self::RadrootsdTransportPublishV5 => 5,
    153         }
    154     }
    155 }
    156 
    157 /// One validated schema-to-module registration.
    158 #[derive(Clone, Debug, Eq, PartialEq)]
    159 pub struct Descriptor {
    160     id: SchemaId,
    161     module: ModuleVersion,
    162 }
    163 
    164 impl Descriptor {
    165     /// Creates a registration after validating its schema identifier.
    166     pub fn try_new(id: impl Into<String>, module: ModuleVersion) -> Result<Self, Error> {
    167         Ok(Self {
    168             id: SchemaId::parse(id)?,
    169             module,
    170         })
    171     }
    172 
    173     /// Returns the schema identifier.
    174     pub const fn id(&self) -> &SchemaId {
    175         &self.id
    176     }
    177 
    178     /// Returns the module generation that owns the schema.
    179     pub const fn module(&self) -> ModuleVersion {
    180         self.module
    181     }
    182 }
    183 
    184 /// A canonical registry of unique schema identifiers.
    185 ///
    186 /// Construction sorts descriptors by schema identifier so iteration and
    187 /// lookup remain deterministic regardless of input order.
    188 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    189 pub struct Registry {
    190     descriptors: Vec<Descriptor>,
    191 }
    192 
    193 impl Registry {
    194     /// Builds a canonical registry and rejects duplicate schema identifiers.
    195     pub fn try_new(descriptors: impl IntoIterator<Item = Descriptor>) -> Result<Self, Error> {
    196         let mut descriptors: Vec<_> = descriptors.into_iter().collect();
    197         descriptors.sort_by(|left, right| left.id.cmp(&right.id));
    198 
    199         for adjacent in descriptors.windows(2) {
    200             if adjacent[0].id == adjacent[1].id {
    201                 return Err(Error::DuplicateSchemaId {
    202                     schema_id: adjacent[0].id.as_str().into(),
    203                 });
    204             }
    205         }
    206 
    207         Ok(Self { descriptors })
    208     }
    209 
    210     /// Builds a registry from governed schema metadata and module ownership.
    211     pub fn try_from_metadata(
    212         entries: impl IntoIterator<Item = (Metadata, ModuleVersion)>,
    213     ) -> Result<Self, Error> {
    214         let descriptors = entries
    215             .into_iter()
    216             .map(|(metadata, module)| {
    217                 let descriptor = Descriptor::try_new(metadata.schema_id, module)?;
    218                 let encoded = descriptor.id().version();
    219                 if metadata.schema_version != encoded {
    220                     return Err(Error::SchemaVersionMismatch {
    221                         schema_id: metadata.schema_id.into(),
    222                         declared: metadata.schema_version,
    223                         encoded,
    224                     });
    225                 }
    226                 Ok(descriptor)
    227             })
    228             .collect::<Result<Vec<_>, _>>()?;
    229         Self::try_new(descriptors)
    230     }
    231 
    232     /// Returns the canonical descriptor sequence.
    233     pub fn descriptors(&self) -> &[Descriptor] {
    234         self.descriptors.as_slice()
    235     }
    236 
    237     /// Returns the number of registered schemas.
    238     pub fn len(&self) -> usize {
    239         self.descriptors.len()
    240     }
    241 
    242     /// Reports whether the registry contains no schemas.
    243     pub fn is_empty(&self) -> bool {
    244         self.descriptors.is_empty()
    245     }
    246 
    247     /// Returns the descriptor for an exact schema identifier.
    248     pub fn descriptor(&self, id: &SchemaId) -> Option<&Descriptor> {
    249         self.descriptors
    250             .binary_search_by(|descriptor| descriptor.id.cmp(id))
    251             .ok()
    252             .map(|index| &self.descriptors[index])
    253     }
    254 
    255     /// Dispatches an exact schema identifier to its owning module generation.
    256     pub fn module_for(&self, id: &SchemaId) -> Option<ModuleVersion> {
    257         self.descriptor(id).map(Descriptor::module)
    258     }
    259 }
    260 
    261 /// Builds the complete protocol V1 schema registry currently owned here.
    262 pub fn protocol_v1_registry() -> Result<Registry, Error> {
    263     let capability = crate::capability::v1::SCHEMAS
    264         .iter()
    265         .copied()
    266         .map(|metadata| (metadata, ModuleVersion::CapabilityV1));
    267     let event = crate::event::v1::SCHEMAS
    268         .iter()
    269         .copied()
    270         .map(|metadata| (metadata, ModuleVersion::EventV1));
    271     let error = crate::error::v1::SCHEMAS
    272         .iter()
    273         .copied()
    274         .map(|metadata| (metadata, ModuleVersion::ErrorV1));
    275     let mut descriptors = Registry::try_from_metadata(capability.chain(event).chain(error))?
    276         .descriptors()
    277         .to_vec();
    278     descriptors.extend(
    279         crate::runtime::v1::schema_registry()?
    280             .descriptors()
    281             .iter()
    282             .cloned(),
    283     );
    284     descriptors.extend(
    285         crate::radrootsd::transport_publish::v5::schema_registry()?
    286             .descriptors()
    287             .iter()
    288             .cloned(),
    289     );
    290     Registry::try_new(descriptors)
    291 }
    292 
    293 /// Schema identity or registry validation failure.
    294 #[derive(Clone, Debug, Eq, PartialEq)]
    295 #[non_exhaustive]
    296 pub enum Error {
    297     /// The schema identifier is empty.
    298     EmptySchemaId,
    299     /// The schema identifier exceeds the byte-length limit.
    300     SchemaIdTooLong {
    301         /// Actual UTF-8 byte length.
    302         actual: usize,
    303         /// Maximum accepted UTF-8 byte length.
    304         max: usize,
    305     },
    306     /// The schema identifier does not include a namespace before its version.
    307     MissingSchemaNamespace,
    308     /// A namespace segment is empty or noncanonical.
    309     InvalidSchemaNamespaceSegment {
    310         /// Zero-based namespace segment index.
    311         index: usize,
    312     },
    313     /// The final segment is not a canonical positive `vN` generation.
    314     InvalidSchemaVersion,
    315     /// Metadata declares a generation different from the schema ID suffix.
    316     SchemaVersionMismatch {
    317         /// Canonical schema identifier.
    318         schema_id: String,
    319         /// Generation stored in metadata.
    320         declared: u16,
    321         /// Generation encoded in the schema ID.
    322         encoded: u16,
    323     },
    324     /// The registry contains an identifier more than once.
    325     DuplicateSchemaId {
    326         /// Duplicated canonical schema identifier.
    327         schema_id: String,
    328     },
    329 }
    330 
    331 impl fmt::Display for Error {
    332     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    333         match self {
    334             Self::EmptySchemaId => formatter.write_str("schema id must not be empty"),
    335             Self::SchemaIdTooLong { actual, max } => {
    336                 write!(formatter, "schema id length {actual} exceeds {max} bytes")
    337             }
    338             Self::MissingSchemaNamespace => {
    339                 formatter.write_str("schema id must contain a namespace and version")
    340             }
    341             Self::InvalidSchemaNamespaceSegment { index } => {
    342                 write!(formatter, "schema id namespace segment {index} is invalid")
    343             }
    344             Self::InvalidSchemaVersion => {
    345                 formatter.write_str("schema id version must be canonical positive vN")
    346             }
    347             Self::SchemaVersionMismatch {
    348                 schema_id,
    349                 declared,
    350                 encoded,
    351             } => write!(
    352                 formatter,
    353                 "schema id {schema_id} encodes v{encoded} but metadata declares v{declared}"
    354             ),
    355             Self::DuplicateSchemaId { schema_id } => {
    356                 write!(formatter, "duplicate schema id {schema_id}")
    357             }
    358         }
    359     }
    360 }
    361 
    362 #[cfg(feature = "std")]
    363 impl std::error::Error for Error {}
    364 
    365 fn valid_namespace_segment(segment: &str) -> bool {
    366     let mut bytes = segment.bytes();
    367     matches!(bytes.next(), Some(b'a'..=b'z'))
    368         && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
    369 }
    370 
    371 fn parse_version_segment(segment: &str) -> Result<u16, Error> {
    372     let Some(digits) = segment.strip_prefix('v') else {
    373         return Err(Error::InvalidSchemaVersion);
    374     };
    375     if digits.is_empty()
    376         || digits.starts_with('0')
    377         || !digits.bytes().all(|byte| byte.is_ascii_digit())
    378     {
    379         return Err(Error::InvalidSchemaVersion);
    380     }
    381     digits
    382         .parse::<u16>()
    383         .ok()
    384         .filter(|version| *version > 0)
    385         .ok_or(Error::InvalidSchemaVersion)
    386 }
    387 
    388 #[cfg(test)]
    389 mod tests {
    390     use alloc::{string::ToString, vec};
    391     use std::collections::BTreeSet;
    392 
    393     use super::*;
    394 
    395     #[test]
    396     fn schema_id_parsing_accepts_existing_contract_shape() {
    397         let id = SchemaId::parse("radroots.protocol.transport_kind.v1").expect("schema id");
    398         assert_eq!(id.as_str(), "radroots.protocol.transport_kind.v1");
    399         assert_eq!(id.version(), 1);
    400         assert_eq!(id.to_string(), id.as_str());
    401         assert_eq!(id, id.as_str().parse().expect("FromStr schema id"));
    402         assert_eq!(
    403             SchemaId::try_from(id.as_str()).expect("borrowed conversion"),
    404             id
    405         );
    406         assert_eq!(
    407             SchemaId::try_from(id.as_str().to_string()).expect("owned conversion"),
    408             id
    409         );
    410         assert_eq!(id.as_ref(), id.as_str());
    411     }
    412 
    413     #[test]
    414     fn schema_id_parsing_rejects_every_noncanonical_shape() {
    415         let too_long = alloc::format!("{}.v1", "a".repeat(MAX_SCHEMA_ID_BYTES));
    416         for (value, expected) in [
    417             ("", Error::EmptySchemaId),
    418             ("v1", Error::MissingSchemaNamespace),
    419             (".v1", Error::MissingSchemaNamespace),
    420             (
    421                 "radroots..event.v1",
    422                 Error::InvalidSchemaNamespaceSegment { index: 1 },
    423             ),
    424             (
    425                 "Radroots.protocol.event.v1",
    426                 Error::InvalidSchemaNamespaceSegment { index: 0 },
    427             ),
    428             (
    429                 "radroots.protocol.event-name.v1",
    430                 Error::InvalidSchemaNamespaceSegment { index: 2 },
    431             ),
    432             ("radroots.protocol.event", Error::InvalidSchemaVersion),
    433             ("radroots.protocol.event.v0", Error::InvalidSchemaVersion),
    434             ("radroots.protocol.event.v01", Error::InvalidSchemaVersion),
    435             (
    436                 "radroots.protocol.event.v65536",
    437                 Error::InvalidSchemaVersion,
    438             ),
    439         ] {
    440             assert_eq!(SchemaId::parse(value), Err(expected), "{value}");
    441         }
    442         assert_eq!(
    443             SchemaId::parse(too_long),
    444             Err(Error::SchemaIdTooLong {
    445                 actual: MAX_SCHEMA_ID_BYTES + 3,
    446                 max: MAX_SCHEMA_ID_BYTES,
    447             })
    448         );
    449     }
    450 
    451     #[test]
    452     fn module_inventory_has_unique_paths_and_explicit_generations() {
    453         let paths = ModuleVersion::ALL
    454             .into_iter()
    455             .map(ModuleVersion::path)
    456             .collect::<BTreeSet<_>>();
    457         assert_eq!(paths.len(), ModuleVersion::ALL.len());
    458         assert_eq!(ModuleVersion::CapabilityV1.generation(), 1);
    459         assert_eq!(ModuleVersion::ErrorV1.generation(), 1);
    460         assert_eq!(ModuleVersion::EventV1.generation(), 1);
    461         assert_eq!(ModuleVersion::RuntimeV1.generation(), 1);
    462         assert_eq!(ModuleVersion::RadrootsdTransportPublishV5.generation(), 5);
    463     }
    464 
    465     #[test]
    466     fn registry_is_canonical_unique_and_dispatches_exact_ids() {
    467         let event = Descriptor::try_new(
    468             "radroots.protocol.event_descriptor.v1",
    469             ModuleVersion::EventV1,
    470         )
    471         .expect("event descriptor");
    472         let capability = Descriptor::try_new(
    473             "radroots.protocol.transport_kind.v1",
    474             ModuleVersion::CapabilityV1,
    475         )
    476         .expect("capability descriptor");
    477         let registry = Registry::try_new(vec![event, capability.clone()]).expect("registry");
    478 
    479         assert_eq!(registry.len(), 2);
    480         assert!(!registry.is_empty());
    481         assert_eq!(
    482             registry
    483                 .descriptors()
    484                 .iter()
    485                 .map(|descriptor| descriptor.id().as_str())
    486                 .collect::<Vec<_>>(),
    487             vec![
    488                 "radroots.protocol.event_descriptor.v1",
    489                 "radroots.protocol.transport_kind.v1",
    490             ]
    491         );
    492         assert_eq!(
    493             registry.module_for(capability.id()),
    494             Some(ModuleVersion::CapabilityV1)
    495         );
    496         let unknown = SchemaId::parse("radroots.protocol.unknown.v1").expect("unknown id");
    497         assert_eq!(registry.module_for(&unknown), None);
    498         assert_eq!(registry.descriptor(&unknown), None);
    499         assert!(Registry::default().is_empty());
    500     }
    501 
    502     #[test]
    503     fn registry_rejects_duplicate_schema_ids() {
    504         let first = Descriptor::try_new(
    505             "radroots.protocol.event_descriptor.v1",
    506             ModuleVersion::EventV1,
    507         )
    508         .expect("first descriptor");
    509         let second = Descriptor::try_new(
    510             "radroots.protocol.event_descriptor.v1",
    511             ModuleVersion::CapabilityV1,
    512         )
    513         .expect("second descriptor");
    514         assert_eq!(
    515             Registry::try_new(vec![first, second]),
    516             Err(Error::DuplicateSchemaId {
    517                 schema_id: "radroots.protocol.event_descriptor.v1".into(),
    518             })
    519         );
    520     }
    521 
    522     #[test]
    523     fn metadata_registry_rejects_version_mismatch() {
    524         let metadata = Metadata {
    525             type_name: "EventDescriptor",
    526             schema_id: "radroots.protocol.event_descriptor.v1",
    527             schema_version: 2,
    528         };
    529         assert_eq!(
    530             Registry::try_from_metadata([(metadata, ModuleVersion::EventV1)]),
    531             Err(Error::SchemaVersionMismatch {
    532                 schema_id: metadata.schema_id.into(),
    533                 declared: 2,
    534                 encoded: 1,
    535             })
    536         );
    537     }
    538 
    539     #[test]
    540     fn protocol_v1_registry_dispatches_all_migrated_schemas() {
    541         let registry = protocol_v1_registry().expect("protocol V1 registry");
    542         assert_eq!(registry.len(), 7 + crate::runtime::v1::CATALOG.len() * 2);
    543         for descriptor in registry.descriptors() {
    544             let expected = if descriptor.id().as_str()
    545                 == crate::radrootsd::transport_publish::v5::API_VERSION
    546             {
    547                 ModuleVersion::RadrootsdTransportPublishV5
    548             } else if descriptor.id().as_str() == crate::error::v1::SCHEMA_ID {
    549                 ModuleVersion::ErrorV1
    550             } else if descriptor.id().as_str().starts_with("radroots.runtime.") {
    551                 ModuleVersion::RuntimeV1
    552             } else if descriptor.id().as_str().contains("event_descriptor")
    553                 || descriptor.id().as_str().contains("trade_state")
    554             {
    555                 ModuleVersion::EventV1
    556             } else {
    557                 ModuleVersion::CapabilityV1
    558             };
    559             assert_eq!(registry.module_for(descriptor.id()), Some(expected));
    560         }
    561     }
    562 
    563     #[test]
    564     fn schema_errors_have_stable_messages() {
    565         let errors = [
    566             Error::EmptySchemaId,
    567             Error::SchemaIdTooLong {
    568                 actual: 256,
    569                 max: 255,
    570             },
    571             Error::MissingSchemaNamespace,
    572             Error::InvalidSchemaNamespaceSegment { index: 2 },
    573             Error::InvalidSchemaVersion,
    574             Error::SchemaVersionMismatch {
    575                 schema_id: "radroots.test.v1".into(),
    576                 declared: 2,
    577                 encoded: 1,
    578             },
    579             Error::DuplicateSchemaId {
    580                 schema_id: "radroots.test.v1".into(),
    581             },
    582         ];
    583         for error in errors {
    584             assert!(!error.to_string().is_empty());
    585         }
    586 
    587         for value in [
    588             "radroots.protocol.event.v",
    589             "radroots.protocol.event.v-1",
    590             "radroots.protocol.event.vx",
    591             "radroots.protocol.event.v999999",
    592         ] {
    593             assert_eq!(SchemaId::parse(value), Err(Error::InvalidSchemaVersion));
    594         }
    595     }
    596 }