p2panda_rs/schema/
schema_id.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3use std::fmt;
4use std::fmt::Display;
5use std::str::FromStr;
6
7use serde::de::Visitor;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use yasmf_hash::MAX_YAMF_HASH_SIZE;
10
11use crate::document::DocumentViewId;
12use crate::operation::OperationId;
13use crate::schema::error::SchemaIdError;
14use crate::schema::SchemaName;
15use crate::Human;
16
17/// Spelling of _schema definition_ schema
18pub(super) const SCHEMA_DEFINITION_NAME: &str = "schema_definition";
19
20/// Spelling of _schema field definition_ schema
21pub(super) const SCHEMA_FIELD_DEFINITION_NAME: &str = "schema_field_definition";
22
23/// Spelling of _blob_ schema
24pub(super) const BLOB_NAME: &str = "blob";
25
26/// Spelling of _blob piece_ schema
27pub(super) const BLOB_PIECE_NAME: &str = "blob_piece";
28
29/// Represent a schema's version.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum SchemaVersion {
32    /// An application schema's version contains its document view id.
33    Application(DocumentViewId),
34
35    /// A system schema's version contains an integer version number.
36    System(u8),
37}
38
39/// Identifies the schema of an [`Operation`][`crate::operation::Operation`] or
40/// [`Document`][`crate::document::Document`].
41///
42/// Every schema id has a name and version.
43#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
44pub enum SchemaId {
45    /// An application schema.
46    Application(SchemaName, DocumentViewId),
47
48    /// A schema definition.
49    SchemaDefinition(u8),
50
51    /// A schema definition field.
52    SchemaFieldDefinition(u8),
53
54    /// A blob.
55    Blob(u8),
56
57    /// A blob piece.
58    BlobPiece(u8),
59}
60
61impl SchemaId {
62    /// Instantiate a new `SchemaId`.
63    ///
64    /// ```
65    /// # use p2panda_rs::schema::SchemaId;
66    /// let system_schema = SchemaId::new("schema_definition_v1");
67    /// assert!(system_schema.is_ok());
68    ///
69    /// let application_schema = SchemaId::new(
70    ///     "venue_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b"
71    /// );
72    /// assert!(application_schema.is_ok());
73    /// ```
74    pub fn new(id: &str) -> Result<Self, SchemaIdError> {
75        // Retrieve the rightmost section separated by an underscore and check whether it follows
76        // the version format of system schemas (e.g. `..._v1`).
77        let rightmost_section = id
78            .rsplit_once('_')
79            .ok_or_else(|| {
80                SchemaIdError::MalformedSchemaId(
81                    id.to_string(),
82                    "doesn't contain an underscore".to_string(),
83                )
84            })?
85            .1;
86
87        let is_system_schema =
88            rightmost_section.starts_with('v') && rightmost_section.len() < MAX_YAMF_HASH_SIZE * 2;
89
90        match is_system_schema {
91            true => Self::parse_system_schema_str(id),
92            false => Self::parse_application_schema_str(id),
93        }
94    }
95
96    /// Returns a `SchemaId` given an application schema's name and view id.
97    pub fn new_application(name: &SchemaName, view_id: &DocumentViewId) -> Self {
98        Self::Application(name.to_owned(), view_id.clone())
99    }
100
101    /// Access the schema name.
102    pub fn name(&self) -> SchemaName {
103        match self {
104            SchemaId::Application(name, _) => name.to_owned(),
105            // We unwrap here as we know system schema names are valid names.
106            SchemaId::Blob(_) => SchemaName::new(BLOB_NAME).unwrap(),
107            SchemaId::BlobPiece(_) => SchemaName::new(BLOB_PIECE_NAME).unwrap(),
108            SchemaId::SchemaDefinition(_) => SchemaName::new(SCHEMA_DEFINITION_NAME).unwrap(),
109            SchemaId::SchemaFieldDefinition(_) => {
110                SchemaName::new(SCHEMA_FIELD_DEFINITION_NAME).unwrap()
111            }
112        }
113    }
114
115    /// Access the schema version.
116    pub fn version(&self) -> SchemaVersion {
117        match self {
118            SchemaId::Application(_, view_id) => SchemaVersion::Application(view_id.clone()),
119            SchemaId::Blob(version) => SchemaVersion::System(*version),
120            SchemaId::BlobPiece(version) => SchemaVersion::System(*version),
121            SchemaId::SchemaDefinition(version) => SchemaVersion::System(*version),
122            SchemaId::SchemaFieldDefinition(version) => SchemaVersion::System(*version),
123        }
124    }
125}
126
127impl SchemaId {
128    /// Read a system schema id from a string.
129    fn parse_system_schema_str(id_str: &str) -> Result<Self, SchemaIdError> {
130        let (name, version_str) = id_str.rsplit_once('_').unwrap();
131
132        let version = version_str[1..].parse::<u8>().map_err(|_| {
133            SchemaIdError::MalformedSchemaId(
134                id_str.to_string(),
135                "couldn't parse system schema version".to_string(),
136            )
137        })?;
138
139        match name {
140            SCHEMA_DEFINITION_NAME => Ok(Self::SchemaDefinition(version)),
141            SCHEMA_FIELD_DEFINITION_NAME => Ok(Self::SchemaFieldDefinition(version)),
142            BLOB_NAME => Ok(Self::Blob(version)),
143            BLOB_PIECE_NAME => Ok(Self::BlobPiece(version)),
144            _ => Err(SchemaIdError::UnknownSystemSchema(name.to_string())),
145        }
146    }
147
148    /// Read an application schema id from a string.
149    ///
150    /// Parses the schema id by iteratively splitting sections from the right at `_` until the
151    /// remainder is shorter than an operation id. Each section is parsed as an operation id
152    /// and the last (leftmost) section is parsed as the schema's name.
153    fn parse_application_schema_str(id_str: &str) -> Result<Self, SchemaIdError> {
154        let mut operation_ids = vec![];
155        let mut remainder = id_str;
156
157        while let Some((left, right)) = remainder.rsplit_once('_') {
158            let operation_id: OperationId = right.parse()?;
159            operation_ids.push(operation_id);
160
161            // If the remainder is no longer than an entry hash we assume that it's the schema
162            // name. By breaking here we allow the schema name to contain underscores as well.
163            remainder = left;
164            if remainder.len() < MAX_YAMF_HASH_SIZE * 2 {
165                break;
166            }
167        }
168
169        // Since we've built the array from the back, we have to reverse it again to get the
170        // original order
171        operation_ids.reverse();
172
173        // Validate if the name is given and correct
174        if remainder.is_empty() {
175            return Err(SchemaIdError::MissingApplicationSchemaName(
176                id_str.to_string(),
177            ));
178        }
179
180        let name = match remainder.parse() {
181            Ok(name) => Ok(name),
182            Err(_) => Err(SchemaIdError::MalformedSchemaId(
183                id_str.to_string(),
184                "name contains too many or invalid characters".to_string(),
185            )),
186        }?;
187
188        Ok(SchemaId::Application(
189            name,
190            DocumentViewId::from_untrusted(operation_ids)?,
191        ))
192    }
193}
194
195impl Display for SchemaId {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        match self {
198            SchemaId::Application(name, view_id) => {
199                write!(f, "{}", name)?;
200
201                view_id
202                    .iter()
203                    .try_for_each(|op_id| write!(f, "_{}", op_id.as_str()))?;
204
205                Ok(())
206            }
207            SchemaId::Blob(version) => {
208                write!(f, "{}_v{}", BLOB_NAME, version)
209            }
210            SchemaId::BlobPiece(version) => {
211                write!(f, "{}_v{}", BLOB_PIECE_NAME, version)
212            }
213            SchemaId::SchemaDefinition(version) => {
214                write!(f, "{}_v{}", SCHEMA_DEFINITION_NAME, version)
215            }
216            SchemaId::SchemaFieldDefinition(version) => {
217                write!(f, "{}_v{}", SCHEMA_FIELD_DEFINITION_NAME, version)
218            }
219        }
220    }
221}
222
223impl Human for SchemaId {
224    fn display(&self) -> String {
225        match self {
226            SchemaId::Application(name, view_id) => format!("{} {}", name, view_id.display()),
227            system_schema => format!("{}", system_schema),
228        }
229    }
230}
231
232impl FromStr for SchemaId {
233    type Err = SchemaIdError;
234
235    fn from_str(s: &str) -> Result<Self, Self::Err> {
236        Self::new(s)
237    }
238}
239
240impl Serialize for SchemaId {
241    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
242    where
243        S: Serializer,
244    {
245        serializer.serialize_str(&self.to_string())
246    }
247}
248
249impl<'de> Deserialize<'de> for SchemaId {
250    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
251    where
252        D: Deserializer<'de>,
253    {
254        struct SchemaIdVisitor;
255
256        impl<'de> Visitor<'de> for SchemaIdVisitor {
257            type Value = SchemaId;
258
259            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
260                formatter.write_str("schema id as string")
261            }
262
263            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
264            where
265                E: serde::de::Error,
266            {
267                SchemaId::new(value).map_err(|err| serde::de::Error::custom(err.to_string()))
268            }
269        }
270
271        deserializer.deserialize_any(SchemaIdVisitor)
272    }
273}
274
275#[cfg(test)]
276mod test {
277    use rstest::rstest;
278
279    use crate::schema::SchemaName;
280    use crate::test_utils::constants::SCHEMA_ID;
281    use crate::test_utils::fixtures::schema_id;
282    use crate::Human;
283
284    use super::SchemaId;
285
286    #[rstest]
287    #[case(
288        SchemaId::new(SCHEMA_ID).unwrap(),
289        "venue_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b"
290    )]
291    #[case(SchemaId::SchemaDefinition(1), "schema_definition_v1")]
292    #[case(SchemaId::SchemaFieldDefinition(1), "schema_field_definition_v1")]
293    #[case(SchemaId::Blob(1), "blob_v1")]
294    #[case(SchemaId::BlobPiece(1), "blob_piece_v1")]
295    fn serialize(#[case] schema_id: SchemaId, #[case] expected_schema_id_string: &str) {
296        let mut cbor_bytes = Vec::new();
297        let mut expected_cbor_bytes = Vec::new();
298
299        ciborium::ser::into_writer(&schema_id, &mut cbor_bytes).unwrap();
300        ciborium::ser::into_writer(expected_schema_id_string, &mut expected_cbor_bytes).unwrap();
301
302        assert_eq!(cbor_bytes, expected_cbor_bytes);
303    }
304
305    #[rstest]
306    #[case(
307        SchemaId::new_application(&SchemaName::new("venue").unwrap(), &"0020ce6f2c08e56836d6c3eb4080d6cc948dba138cba328c28059f45ebe459901771".parse().unwrap()
308        ),
309        "venue_0020ce6f2c08e56836d6c3eb4080d6cc948dba138cba328c28059f45ebe459901771"
310    )]
311    #[case(SchemaId::SchemaDefinition(1), "schema_definition_v1")]
312    #[case(SchemaId::SchemaFieldDefinition(1), "schema_field_definition_v1")]
313    #[case(SchemaId::Blob(1), "blob_v1")]
314    #[case(SchemaId::BlobPiece(1), "blob_piece_v1")]
315    fn deserialize(#[case] schema_id: SchemaId, #[case] expected_schema_id_string: &str) {
316        let parsed_app_schema: SchemaId = expected_schema_id_string.parse().unwrap();
317        assert_eq!(schema_id, parsed_app_schema);
318    }
319
320    // Not a hash at all
321    #[rstest]
322    #[case(
323        "This is not a hash",
324        "malformed schema id `This is not a hash`: doesn't contain an underscore"
325    )]
326    // Only an operation id, could be interpreted as document view id but still missing the name
327    #[case(
328        "0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b",
329        "malformed schema id `0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b`: doesn't contain an underscore"
330    )]
331    // Only the name is missing now
332    #[case(
333        "_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b",
334        "application schema id is missing a name: _0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c\
335        7b9ab46293111c48fc78b"
336    )]
337    // Name contains invalid characters
338    #[case(
339        "abc2%_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b",
340        "malformed schema id `abc2%_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b`: name contains too many or invalid characters"
341    )]
342    // This name is too long, parser will fail trying to read its last section as an operation id
343    #[case(
344        "this_name_is_way_too_long_it_cant_be_good_to_have_such_a_long_name_to_be_honest_0020c65\
345        567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b",
346        "encountered invalid hash while parsing application schema id: invalid hex encoding in \
347        hash string"
348    )]
349    // This hash is malformed
350    #[case(
351        "venue_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc7",
352        "encountered invalid hash while parsing application schema id: invalid hash length 33 \
353        bytes, expected 34 bytes"
354    )]
355    // this looks like a system schema, but it is not
356    #[case(
357        "unknown_system_schema_name_v1",
358        "unsupported system schema: unknown_system_schema_name"
359    )]
360    // malformed system schema version number
361    #[case(
362        "schema_definition_v1.5",
363        "malformed schema id `schema_definition_v1.5`: couldn't parse system schema version"
364    )]
365    fn invalid_deserialization(#[case] schema_id_str: &str, #[case] expected_err: &str) {
366        assert_eq!(
367            format!("{}", schema_id_str.parse::<SchemaId>().unwrap_err()),
368            expected_err
369        );
370    }
371
372    #[test]
373    fn new_schema_type() {
374        let appl_schema = SchemaId::new(
375            "venue_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b",
376        )
377        .unwrap();
378        assert_eq!(
379            appl_schema,
380            SchemaId::new(
381                "venue_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b"
382            )
383            .unwrap()
384        );
385
386        assert_eq!(
387            format!("{}", appl_schema),
388            "venue_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b"
389        );
390
391        let schema = SchemaId::new("schema_definition_v50").unwrap();
392        assert_eq!(schema, SchemaId::SchemaDefinition(50));
393        assert_eq!(format!("{}", schema), "schema_definition_v50");
394
395        let schema_field = SchemaId::new("schema_field_definition_v1").unwrap();
396        assert_eq!(schema_field, SchemaId::SchemaFieldDefinition(1));
397        assert_eq!(format!("{}", schema_field), "schema_field_definition_v1");
398    }
399
400    #[test]
401    fn from_str() {
402        let schema: SchemaId = "schema_definition_v1".parse().unwrap();
403        assert_eq!(schema, SchemaId::SchemaDefinition(1));
404    }
405
406    #[rstest]
407    fn string_representation(schema_id: SchemaId) {
408        assert_eq!(
409            schema_id.to_string(),
410            "venue_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b"
411        );
412        assert_eq!(
413            format!("{}", schema_id),
414            "venue_0020c65567ae37efea293e34a9c7d13f8f2bf23dbdc3b5c7b9ab46293111c48fc78b"
415        );
416        assert_eq!(format!("{}", SchemaId::Blob(1)), "blob_v1");
417        assert_eq!(format!("{}", SchemaId::BlobPiece(1)), "blob_piece_v1");
418        assert_eq!(
419            format!("{}", SchemaId::SchemaDefinition(1)),
420            "schema_definition_v1"
421        );
422        assert_eq!(
423            format!("{}", SchemaId::SchemaFieldDefinition(1)),
424            "schema_field_definition_v1"
425        );
426    }
427
428    #[rstest]
429    fn short_representation(schema_id: SchemaId) {
430        assert_eq!(schema_id.display(), "venue 8fc78b");
431        assert_eq!(
432            SchemaId::SchemaDefinition(1).display(),
433            "schema_definition_v1"
434        );
435    }
436}