Skip to main content

soma_ops/
contract_id.rs

1use std::fmt;
2
3use serde::{Deserialize, Deserializer, Serialize};
4
5use crate::OperationName;
6
7const MAX_SCHEMA_ID_CHARS: usize = 256;
8const MAX_DIAGNOSTIC_CODE_CHARS: usize = 128;
9
10/// Stable identity for a versioned operation parameter or result schema.
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(transparent)]
14pub struct SchemaId(String);
15
16impl SchemaId {
17    /// Creates and validates a schema identity.
18    pub fn new(value: impl Into<String>) -> Result<Self, SchemaIdError> {
19        let value = value.into();
20        validate_schema_id(&value)?;
21        Ok(Self(value))
22    }
23
24    /// Derives the parameter schema identity for one operation version.
25    pub fn parameters(operation: &OperationName, version: u32) -> Result<Self, SchemaIdError> {
26        Self::for_kind(operation, "parameters", version)
27    }
28
29    /// Derives the result schema identity for one operation version.
30    pub fn result(operation: &OperationName, version: u32) -> Result<Self, SchemaIdError> {
31        Self::for_kind(operation, "result", version)
32    }
33
34    fn for_kind(
35        operation: &OperationName,
36        kind: &'static str,
37        version: u32,
38    ) -> Result<Self, SchemaIdError> {
39        if version == 0 {
40            return Err(SchemaIdError::ZeroVersion);
41        }
42        Self::new(format!(
43            "schema.operations.{}.{kind}.v{version}",
44            operation.as_str()
45        ))
46    }
47
48    /// Returns the stable schema identity.
49    #[must_use]
50    pub fn as_str(&self) -> &str {
51        &self.0
52    }
53}
54
55impl fmt::Display for SchemaId {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        formatter.write_str(&self.0)
58    }
59}
60
61impl<'de> Deserialize<'de> for SchemaId {
62    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
63    where
64        D: Deserializer<'de>,
65    {
66        let value = String::deserialize(deserializer)?;
67        Self::new(value).map_err(serde::de::Error::custom)
68    }
69}
70
71/// Invalid operation schema identity.
72#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
73#[non_exhaustive]
74pub enum SchemaIdError {
75    /// Schema versions start at one.
76    #[error("operation schema version must be greater than zero")]
77    ZeroVersion,
78    /// The identity did not follow the canonical schema naming contract.
79    #[error("invalid operation schema id: {0}")]
80    Invalid(String),
81}
82
83fn validate_schema_id(value: &str) -> Result<(), SchemaIdError> {
84    if value.chars().count() > MAX_SCHEMA_ID_CHARS {
85        return Err(SchemaIdError::Invalid(value.to_owned()));
86    }
87    let segments = value.split('.').collect::<Vec<_>>();
88    if segments.len() < 6 || segments[0..2] != ["schema", "operations"] {
89        return Err(SchemaIdError::Invalid(value.to_owned()));
90    }
91    let kind = segments[segments.len() - 2];
92    if !matches!(kind, "parameters" | "result") {
93        return Err(SchemaIdError::Invalid(value.to_owned()));
94    }
95    let version = segments[segments.len() - 1];
96    let Some(version) = version.strip_prefix('v') else {
97        return Err(SchemaIdError::Invalid(value.to_owned()));
98    };
99    if version
100        .parse::<u32>()
101        .ok()
102        .filter(|value| *value > 0)
103        .is_none()
104    {
105        return Err(SchemaIdError::Invalid(value.to_owned()));
106    }
107    let operation = segments[2..segments.len() - 2].join(".");
108    OperationName::new(operation).map_err(|_| SchemaIdError::Invalid(value.to_owned()))?;
109    Ok(())
110}
111
112/// Stable machine-readable diagnostic code such as `target.not_found`.
113#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
115#[serde(transparent)]
116pub struct DiagnosticCode(String);
117
118impl DiagnosticCode {
119    /// Creates and validates a diagnostic code.
120    pub fn new(value: impl Into<String>) -> Result<Self, DiagnosticCodeError> {
121        let value = value.into();
122        if valid_diagnostic_code(&value) {
123            Ok(Self(value))
124        } else {
125            Err(DiagnosticCodeError(value))
126        }
127    }
128
129    /// Returns the stable code.
130    #[must_use]
131    pub fn as_str(&self) -> &str {
132        &self.0
133    }
134}
135
136impl fmt::Display for DiagnosticCode {
137    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138        formatter.write_str(&self.0)
139    }
140}
141
142impl<'de> Deserialize<'de> for DiagnosticCode {
143    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
144    where
145        D: Deserializer<'de>,
146    {
147        let value = String::deserialize(deserializer)?;
148        Self::new(value).map_err(serde::de::Error::custom)
149    }
150}
151
152/// Invalid stable diagnostic code.
153#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
154#[error("invalid diagnostic code: {0}")]
155pub struct DiagnosticCodeError(String);
156
157fn valid_diagnostic_code(value: &str) -> bool {
158    let count = value.chars().count();
159    if !(3..=MAX_DIAGNOSTIC_CODE_CHARS).contains(&count) || !value.contains('.') {
160        return false;
161    }
162    value.split('.').all(valid_code_segment)
163}
164
165fn valid_code_segment(segment: &str) -> bool {
166    let mut characters = segment.chars();
167    matches!(characters.next(), Some('a'..='z'))
168        && characters.all(|character| {
169            character.is_ascii_lowercase()
170                || character.is_ascii_digit()
171                || matches!(character, '-' | '_')
172        })
173        && !segment.ends_with(['-', '_'])
174}
175
176#[cfg(test)]
177#[path = "contract_id_tests.rs"]
178mod tests;