1use appcore_core::{AppId, CommandEnvelope, CommandName, NodeId, RuntimeResult};
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct CommandRequest {
19 pub command_name: String,
21 pub command_id: String,
23 pub idempotency_key: Option<String>,
25 pub payload: String,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct CommandResponseEvent {
32 pub event_name: String,
34 pub event_id: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct CommandResponse {
41 pub accepted: bool,
43 pub message: Option<String>,
45 pub events: Vec<CommandResponseEvent>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum CommandRequestValidationError {
52 EmptyCommandName,
54 EmptyCommandId,
56 PayloadTooLarge,
58 MissingIdempotencyKey,
60 InvalidIdempotencyKey,
62 InvalidCommandName,
64 InvalidCommandId,
66}
67
68impl CommandRequest {
69 pub fn validate(&self, max_payload_bytes: usize) -> Result<(), CommandRequestValidationError> {
71 if self.command_name.trim().is_empty() {
72 return Err(CommandRequestValidationError::EmptyCommandName);
73 }
74 if self.command_id.trim().is_empty() {
75 return Err(CommandRequestValidationError::EmptyCommandId);
76 }
77 if self.command_name.len() > 128 || !is_valid_token(&self.command_name) {
78 return Err(CommandRequestValidationError::InvalidCommandName);
79 }
80 if self.command_id.len() > 128 || !is_valid_token(&self.command_id) {
81 return Err(CommandRequestValidationError::InvalidCommandId);
82 }
83 if requires_idempotency_key(&self.command_name) && self.idempotency_key.is_none() {
84 return Err(CommandRequestValidationError::MissingIdempotencyKey);
85 }
86 if let Some(key) = self.idempotency_key.as_deref() {
87 if key.trim().is_empty() || key.len() > 128 || !is_valid_token(key) {
88 return Err(CommandRequestValidationError::InvalidIdempotencyKey);
89 }
90 }
91 if self.payload.len() > max_payload_bytes {
92 return Err(CommandRequestValidationError::PayloadTooLarge);
93 }
94 Ok(())
95 }
96
97 pub fn payload_bytes(&self) -> &[u8] {
99 self.payload.as_bytes()
100 }
101
102 pub fn to_envelope(
104 &self,
105 app_id: AppId,
106 node_id: NodeId,
107 issued_at_ms: u64,
108 max_payload_bytes: usize,
109 ) -> RuntimeResult<CommandEnvelope> {
110 if let Err(error) = self.validate(max_payload_bytes) {
111 return Err(validation_error_to_runtime_error(error));
112 }
113 self.to_envelope_unchecked(app_id, node_id, issued_at_ms)
114 }
115
116 pub fn into_envelope(
118 self,
119 app_id: AppId,
120 node_id: NodeId,
121 issued_at_ms: u64,
122 max_payload_bytes: usize,
123 ) -> RuntimeResult<CommandEnvelope> {
124 if let Err(error) = self.validate(max_payload_bytes) {
125 return Err(validation_error_to_runtime_error(error));
126 }
127 self.into_envelope_unchecked(app_id, node_id, issued_at_ms)
128 }
129
130 fn to_envelope_unchecked(
131 &self,
132 app_id: AppId,
133 node_id: NodeId,
134 issued_at_ms: u64,
135 ) -> RuntimeResult<CommandEnvelope> {
136 CommandEnvelope::new(
137 CommandName::new(self.command_name.clone())?,
138 self.command_id.clone(),
139 app_id,
140 node_id,
141 issued_at_ms,
142 self.idempotency_key.clone(),
143 self.payload_bytes().to_vec(),
144 )
145 }
146
147 fn into_envelope_unchecked(
148 self,
149 app_id: AppId,
150 node_id: NodeId,
151 issued_at_ms: u64,
152 ) -> RuntimeResult<CommandEnvelope> {
153 CommandEnvelope::new(
154 CommandName::new(self.command_name)?,
155 self.command_id,
156 app_id,
157 node_id,
158 issued_at_ms,
159 self.idempotency_key,
160 self.payload.into_bytes(),
161 )
162 }
163}
164
165impl CommandResponse {
166 pub fn accepted(events: Vec<CommandResponseEvent>) -> Self {
168 Self {
169 accepted: true,
170 message: None,
171 events,
172 }
173 }
174
175 pub fn rejected(message: impl Into<String>) -> Self {
177 Self {
178 accepted: false,
179 message: Some(message.into()),
180 events: Vec::new(),
181 }
182 }
183}
184
185fn is_valid_token(value: &str) -> bool {
186 value
187 .bytes()
188 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
189}
190
191fn requires_idempotency_key(command_name: &str) -> bool {
192 command_name != "runtime.ping"
193}
194
195fn validation_error_to_runtime_error(
196 error: CommandRequestValidationError,
197) -> appcore_core::RuntimeError {
198 use CommandRequestValidationError as ValidationError;
199 let reason = match error {
200 ValidationError::EmptyCommandName => "empty_command_name",
201 ValidationError::EmptyCommandId => "empty_command_id",
202 ValidationError::PayloadTooLarge => "payload_too_large",
203 ValidationError::MissingIdempotencyKey => "missing_idempotency_key",
204 ValidationError::InvalidIdempotencyKey => "invalid_idempotency_key",
205 ValidationError::InvalidCommandName => "invalid_command_name",
206 ValidationError::InvalidCommandId => "invalid_command_id",
207 };
208 appcore_core::RuntimeError::InvalidRequest {
209 kind: "command",
210 reason,
211 }
212}
213
214#[cfg(test)]
215#[path = "command_contract_tests.rs"]
216mod tests;