Skip to main content

starweaver_core/
protocol.rs

1//! Shared protocol identity and versioned durable-record codecs.
2
3use std::{error::Error, fmt};
4
5use serde::{Deserialize, Serialize, de::DeserializeOwned};
6use serde_json::Value;
7
8/// Transport-neutral identity negotiated by Starweaver-owned protocols.
9#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "camelCase")]
11pub struct ProtocolIdentity {
12    /// Stable protocol family name.
13    pub name: String,
14    /// Breaking compatibility generation.
15    pub major: u32,
16    /// Documentation and fixture revision.
17    pub revision: String,
18    /// Implemented, negotiable feature names.
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub features: Vec<String>,
21}
22
23impl ProtocolIdentity {
24    /// Build a protocol identity.
25    #[must_use]
26    pub fn new(name: impl Into<String>, major: u32, revision: impl Into<String>) -> Self {
27        Self {
28            name: name.into(),
29            major,
30            revision: revision.into(),
31            features: Vec::new(),
32        }
33    }
34
35    /// Attach implemented features.
36    #[must_use]
37    pub fn with_features(mut self, features: impl IntoIterator<Item = impl Into<String>>) -> Self {
38        self.features = features.into_iter().map(Into::into).collect();
39        self
40    }
41
42    /// Validate a peer identity against an expected protocol family and major.
43    ///
44    /// # Errors
45    ///
46    /// Returns a compatibility error for a different name or major version.
47    pub fn validate(&self, expected_name: &str, expected_major: u32) -> Result<(), ProtocolError> {
48        if self.name != expected_name {
49            return Err(ProtocolError::UnexpectedProtocol {
50                expected: expected_name.to_string(),
51                actual: self.name.clone(),
52            });
53        }
54        if self.major != expected_major {
55            return Err(ProtocolError::UnsupportedMajor {
56                protocol: self.name.clone(),
57                expected: expected_major,
58                actual: self.major,
59            });
60        }
61        Ok(())
62    }
63}
64
65/// Version envelope written around stable durable JSON records.
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
67pub struct VersionedEnvelope<T> {
68    /// Stable schema identifier.
69    pub schema: String,
70    /// Positive schema version.
71    pub version: u32,
72    /// Typed record payload.
73    pub payload: T,
74}
75
76impl<T> VersionedEnvelope<T> {
77    /// Build an envelope.
78    #[must_use]
79    pub fn new(schema: impl Into<String>, version: u32, payload: T) -> Self {
80        Self {
81            schema: schema.into(),
82            version,
83            payload,
84        }
85    }
86}
87
88/// Schema declaration implemented by durable record types.
89pub trait VersionedRecord: Sized {
90    /// Stable schema identifier.
91    const SCHEMA: &'static str;
92    /// Current schema version.
93    const VERSION: u32 = 1;
94    /// Whether this record explicitly accepts a previous bare-JSON v0 shape.
95    const ALLOW_BARE_V0: bool = false;
96
97    /// Decode one enveloped payload version.
98    ///
99    /// Records that advance beyond v1 override this method to dispatch older
100    /// versions through dedicated legacy DTOs before decoding the current type.
101    ///
102    /// # Errors
103    ///
104    /// Returns an unsupported-version error or a payload decoding error.
105    fn decode_version(version: u32, payload: Value) -> Result<Self, VersionedRecordError>
106    where
107        Self: DeserializeOwned,
108    {
109        if version == 0 || version != Self::VERSION {
110            return Err(VersionedRecordError::UnsupportedVersion {
111                schema: Self::SCHEMA,
112                supported: Self::VERSION,
113                actual: version,
114            });
115        }
116        serde_json::from_value(payload).map_err(VersionedRecordError::Json)
117    }
118
119    /// Decode the explicitly supported bare-JSON v0 shape.
120    ///
121    /// # Errors
122    ///
123    /// Returns an unsupported-v0 error or a payload decoding error.
124    fn decode_bare_v0(payload: Value) -> Result<Self, VersionedRecordError>
125    where
126        Self: DeserializeOwned,
127    {
128        if !Self::ALLOW_BARE_V0 {
129            return Err(VersionedRecordError::BareV0Unsupported {
130                schema: Self::SCHEMA,
131            });
132        }
133        serde_json::from_value(payload).map_err(VersionedRecordError::Json)
134    }
135}
136
137/// Encode a durable record as its current versioned JSON envelope.
138///
139/// # Errors
140///
141/// Returns a JSON serialization error when the record cannot be encoded.
142pub fn to_versioned_json<T>(value: &T) -> Result<String, serde_json::Error>
143where
144    T: Serialize + VersionedRecord,
145{
146    serde_json::to_string(&VersionedEnvelope::new(T::SCHEMA, T::VERSION, value))
147}
148
149/// Encode a durable record as its current versioned JSON value.
150///
151/// # Errors
152///
153/// Returns a JSON serialization error when the record cannot be encoded.
154pub fn to_versioned_value<T>(value: &T) -> Result<Value, serde_json::Error>
155where
156    T: Serialize + VersionedRecord,
157{
158    serde_json::to_value(VersionedEnvelope::new(T::SCHEMA, T::VERSION, value))
159}
160
161/// Decode a current envelope or an explicitly supported legacy bare JSON record.
162///
163/// Bare JSON is treated as legacy version zero. A JSON object carrying `schema`
164/// or `version` is always treated as an envelope and fails closed when malformed.
165///
166/// # Errors
167///
168/// Returns a compatibility or JSON error for malformed, mismatched, or unknown records.
169pub fn from_versioned_json<T>(input: &str) -> Result<T, VersionedRecordError>
170where
171    T: DeserializeOwned + VersionedRecord,
172{
173    let value = serde_json::from_str(input).map_err(VersionedRecordError::Json)?;
174    from_versioned_value(value)
175}
176
177/// Decode a current envelope or an explicitly supported legacy bare JSON value.
178///
179/// # Errors
180///
181/// Returns a compatibility or JSON error for malformed, mismatched, or unknown records.
182pub fn from_versioned_value<T>(value: Value) -> Result<T, VersionedRecordError>
183where
184    T: DeserializeOwned + VersionedRecord,
185{
186    let looks_enveloped = value
187        .as_object()
188        .is_some_and(|object| object.contains_key("schema") || object.contains_key("version"));
189    if !looks_enveloped {
190        return T::decode_bare_v0(value);
191    }
192
193    let envelope = serde_json::from_value::<VersionedEnvelope<Value>>(value)
194        .map_err(VersionedRecordError::Json)?;
195    if envelope.schema != T::SCHEMA {
196        return Err(VersionedRecordError::WrongSchema {
197            expected: T::SCHEMA,
198            actual: envelope.schema,
199        });
200    }
201    T::decode_version(envelope.version, envelope.payload)
202}
203
204/// Protocol compatibility failure.
205#[derive(Clone, Debug, Eq, PartialEq)]
206pub enum ProtocolError {
207    /// Peer selected another protocol family.
208    UnexpectedProtocol {
209        /// Expected protocol name.
210        expected: String,
211        /// Received protocol name.
212        actual: String,
213    },
214    /// Peer selected an unsupported breaking generation.
215    UnsupportedMajor {
216        /// Protocol family.
217        protocol: String,
218        /// Supported major.
219        expected: u32,
220        /// Received major.
221        actual: u32,
222    },
223}
224
225impl fmt::Display for ProtocolError {
226    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
227        match self {
228            Self::UnexpectedProtocol { expected, actual } => {
229                write!(formatter, "expected protocol {expected}, received {actual}")
230            }
231            Self::UnsupportedMajor {
232                protocol,
233                expected,
234                actual,
235            } => write!(
236                formatter,
237                "unsupported {protocol} major {actual}; supported major is {expected}"
238            ),
239        }
240    }
241}
242
243impl Error for ProtocolError {}
244
245/// Durable-record envelope failure.
246#[derive(Debug)]
247pub enum VersionedRecordError {
248    /// JSON parsing or typed payload decoding failed.
249    Json(serde_json::Error),
250    /// Bare legacy JSON was supplied to a record that did not opt in.
251    BareV0Unsupported {
252        /// Schema id.
253        schema: &'static str,
254    },
255    /// Envelope names another schema.
256    WrongSchema {
257        /// Expected schema id.
258        expected: &'static str,
259        /// Received schema id.
260        actual: String,
261    },
262    /// Envelope version is unknown or invalid.
263    UnsupportedVersion {
264        /// Schema id.
265        schema: &'static str,
266        /// Current supported version.
267        supported: u32,
268        /// Received version.
269        actual: u32,
270    },
271}
272
273impl fmt::Display for VersionedRecordError {
274    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
275        match self {
276            Self::Json(error) => write!(formatter, "invalid durable JSON: {error}"),
277            Self::BareV0Unsupported { schema } => {
278                write!(
279                    formatter,
280                    "bare v0 JSON is not supported for durable schema {schema}"
281                )
282            }
283            Self::WrongSchema { expected, actual } => {
284                write!(
285                    formatter,
286                    "expected durable schema {expected}, received {actual}"
287                )
288            }
289            Self::UnsupportedVersion {
290                schema,
291                supported,
292                actual,
293            } => write!(
294                formatter,
295                "unsupported {schema} version {actual}; supported version is {supported}"
296            ),
297        }
298    }
299}
300
301impl Error for VersionedRecordError {
302    fn source(&self) -> Option<&(dyn Error + 'static)> {
303        match self {
304            Self::Json(error) => Some(error),
305            Self::BareV0Unsupported { .. }
306            | Self::WrongSchema { .. }
307            | Self::UnsupportedVersion { .. } => None,
308        }
309    }
310}