evidentsource_core/domain/
state_change.rs

1//! State change types.
2//!
3//! State changes are commands that produce events. They encapsulate
4//! business logic for validating and processing requests.
5
6use super::identifiers::StateChangeName;
7
8/// State change version number.
9pub type StateChangeVersion = u64;
10
11/// A summary of a state change definition.
12///
13/// This is returned when listing state changes registered with the database.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct StateChangeDefinitionSummary {
16    /// The name of the state change.
17    pub name: StateChangeName,
18    /// The version of the state change.
19    pub version: StateChangeVersion,
20}
21
22impl StateChangeDefinitionSummary {
23    /// Create a new state change definition summary.
24    pub fn new(name: StateChangeName, version: StateChangeVersion) -> Self {
25        Self { name, version }
26    }
27}
28
29/// A command received by a state change.
30///
31/// This mirrors the WIT Command type and can be used by domain crates
32/// to implement TryFrom conversions without depending on the SDK.
33///
34/// The SDK is format-agnostic: users implement `TryFrom<Command>` with their
35/// preferred deserializer (serde_json, prost, rmp-serde, etc.).
36///
37/// # Example
38///
39/// ```rust,ignore
40/// use evidentsource_core::domain::{Command, CommandParseError};
41///
42/// impl TryFrom<Command> for MyDomainCommand {
43///     type Error = CommandParseError;
44///
45///     fn try_from(cmd: Command) -> Result<Self, Self::Error> {
46///         let bytes = cmd.body.as_deref().ok_or(CommandParseError::NoBody)?;
47///         // Use your preferred deserializer
48///         serde_json::from_slice(bytes)
49///             .map_err(|e| CommandParseError::DeserializationFailed(e.to_string()))
50///     }
51/// }
52/// ```
53#[derive(Debug, Clone)]
54pub struct Command {
55    /// Optional request body as raw bytes.
56    pub body: Option<Vec<u8>>,
57    /// Content type of the body (e.g., "application/json").
58    pub content_type: String,
59    /// Optional content schema URI.
60    pub content_schema: Option<String>,
61}
62
63impl Command {
64    /// Create a new command with the given body and content type.
65    pub fn new(body: Option<Vec<u8>>, content_type: impl Into<String>) -> Self {
66        Command {
67            body,
68            content_type: content_type.into(),
69            content_schema: None,
70        }
71    }
72
73    /// Create a command with a JSON body.
74    pub fn json(body: Vec<u8>) -> Self {
75        Command {
76            body: Some(body),
77            content_type: "application/json".to_string(),
78            content_schema: None,
79        }
80    }
81
82    /// Set the content schema.
83    pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
84        self.content_schema = Some(schema.into());
85        self
86    }
87
88    /// Get a reference to the body bytes if present.
89    pub fn body(&self) -> Option<&[u8]> {
90        self.body.as_deref()
91    }
92
93    /// Get the content type.
94    pub fn content_type(&self) -> &str {
95        &self.content_type
96    }
97
98    /// Get the content schema if present.
99    pub fn content_schema(&self) -> Option<&str> {
100        self.content_schema.as_deref()
101    }
102
103    /// Check if the content type is JSON.
104    pub fn is_json(&self) -> bool {
105        self.content_type == "application/json" || self.content_type.ends_with("+json")
106    }
107}
108
109/// Error when parsing a command body.
110#[derive(Debug, Clone, thiserror::Error)]
111pub enum CommandParseError {
112    /// Command body is required but was not provided.
113    #[error("command body is required")]
114    NoBody,
115    /// Failed to deserialize the command body.
116    #[error("deserialization failed: {0}")]
117    DeserializationFailed(String),
118}
119
120/// Error when creating a command request.
121#[derive(Debug, Clone, thiserror::Error)]
122pub enum CommandRequestError {
123    /// Failed to serialize the request body.
124    #[error("serialization failed: {0}")]
125    SerializationFailed(String),
126}
127
128/// A command request to execute a state change.
129#[derive(Debug, Clone)]
130pub struct CommandRequest {
131    /// HTTP-style headers for the request.
132    pub headers: Vec<(String, String)>,
133    /// Optional request body.
134    pub body: Option<Vec<u8>>,
135    /// Content type of the body (e.g., "application/json").
136    pub content_type: Option<String>,
137    /// Optional content schema URL for validation.
138    pub content_schema: Option<String>,
139}
140
141impl CommandRequest {
142    /// Create a new empty command request.
143    pub fn new() -> Self {
144        CommandRequest {
145            headers: Vec::new(),
146            body: None,
147            content_type: None,
148            content_schema: None,
149        }
150    }
151
152    /// Create a command request with a body.
153    pub fn with_body(body: Vec<u8>) -> Self {
154        CommandRequest {
155            headers: Vec::new(),
156            body: Some(body),
157            content_type: None,
158            content_schema: None,
159        }
160    }
161
162    /// Create a command request with a JSON-serialized body.
163    ///
164    /// This serializes the value to JSON and sets the appropriate Content-Type header.
165    ///
166    /// # Example
167    ///
168    /// ```
169    /// use evidentsource_core::domain::CommandRequest;
170    /// use serde::Serialize;
171    ///
172    /// #[derive(Serialize)]
173    /// struct CreateAccount {
174    ///     account_id: String,
175    ///     initial_balance: u64,
176    /// }
177    ///
178    /// let cmd = CreateAccount {
179    ///     account_id: "acc-123".to_string(),
180    ///     initial_balance: 1000,
181    /// };
182    ///
183    /// let request = CommandRequest::json(&cmd).unwrap();
184    /// assert_eq!(request.get_header("Content-Type"), Some("application/json"));
185    /// ```
186    pub fn json<T: serde::Serialize>(value: &T) -> Result<Self, CommandRequestError> {
187        let body = serde_json::to_vec(value)
188            .map_err(|e| CommandRequestError::SerializationFailed(e.to_string()))?;
189        Ok(CommandRequest {
190            headers: vec![("Content-Type".to_string(), "application/json".to_string())],
191            body: Some(body),
192            content_type: Some("application/json".to_string()),
193            content_schema: None,
194        })
195    }
196
197    /// Add a header to the request.
198    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
199        self.headers.push((name.into(), value.into()));
200        self
201    }
202
203    /// Set the request body.
204    pub fn body(mut self, body: Vec<u8>) -> Self {
205        self.body = Some(body);
206        self
207    }
208
209    /// Set the content type.
210    ///
211    /// # Example
212    ///
213    /// ```
214    /// use evidentsource_core::domain::CommandRequest;
215    ///
216    /// let request = CommandRequest::new()
217    ///     .body(b"<xml>data</xml>".to_vec())
218    ///     .content_type("application/xml");
219    /// ```
220    pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
221        self.content_type = Some(content_type.into());
222        self
223    }
224
225    /// Set the content schema URL.
226    ///
227    /// # Example
228    ///
229    /// ```
230    /// use evidentsource_core::domain::CommandRequest;
231    ///
232    /// let request = CommandRequest::new()
233    ///     .body(b"{}".to_vec())
234    ///     .content_type("application/json")
235    ///     .content_schema("https://example.com/schemas/command.json");
236    /// ```
237    pub fn content_schema(mut self, schema_url: impl Into<String>) -> Self {
238        self.content_schema = Some(schema_url.into());
239        self
240    }
241
242    /// Get a header value by name.
243    pub fn get_header(&self, name: &str) -> Option<&str> {
244        self.headers
245            .iter()
246            .find(|(k, _)| k.eq_ignore_ascii_case(name))
247            .map(|(_, v)| v.as_str())
248    }
249}
250
251impl Default for CommandRequest {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use serde::Serialize;
261
262    #[test]
263    fn test_command_request_builder() {
264        let request = CommandRequest::new()
265            .header("Content-Type", "application/json")
266            .body(b"{}".to_vec());
267
268        assert_eq!(request.get_header("content-type"), Some("application/json"));
269        assert_eq!(request.body, Some(b"{}".to_vec()));
270    }
271
272    #[test]
273    fn test_command_request_json() {
274        #[derive(Serialize)]
275        struct TestCmd {
276            name: String,
277            value: i32,
278        }
279
280        let cmd = TestCmd {
281            name: "test".to_string(),
282            value: 42,
283        };
284
285        let request = CommandRequest::json(&cmd).unwrap();
286
287        assert_eq!(request.get_header("Content-Type"), Some("application/json"));
288        assert!(request.body.is_some());
289
290        // Verify the JSON is valid
291        let body = request.body.unwrap();
292        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
293        assert_eq!(parsed["name"], "test");
294        assert_eq!(parsed["value"], 42);
295    }
296
297    #[test]
298    fn test_state_change_definition_summary() {
299        let name = StateChangeName::new("my-state-change").unwrap();
300        let summary = StateChangeDefinitionSummary::new(name.clone(), 1);
301
302        assert_eq!(summary.name, name);
303        assert_eq!(summary.version, 1);
304    }
305}