appcore_api/
command_contract.rs1use 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 fn to_envelope_unchecked(
117 &self,
118 app_id: AppId,
119 node_id: NodeId,
120 issued_at_ms: u64,
121 ) -> RuntimeResult<CommandEnvelope> {
122 CommandEnvelope::new(
123 CommandName::new(self.command_name.clone())?,
124 self.command_id.clone(),
125 app_id,
126 node_id,
127 issued_at_ms,
128 self.idempotency_key.clone(),
129 self.payload_bytes().to_vec(),
130 )
131 }
132}
133
134impl CommandResponse {
135 pub fn accepted(events: Vec<CommandResponseEvent>) -> Self {
137 Self {
138 accepted: true,
139 message: None,
140 events,
141 }
142 }
143
144 pub fn rejected(message: impl Into<String>) -> Self {
146 Self {
147 accepted: false,
148 message: Some(message.into()),
149 events: Vec::new(),
150 }
151 }
152}
153
154fn is_valid_token(value: &str) -> bool {
155 value
156 .bytes()
157 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
158}
159
160fn requires_idempotency_key(command_name: &str) -> bool {
161 command_name != "runtime.ping"
162}
163
164fn validation_error_to_runtime_error(
165 error: CommandRequestValidationError,
166) -> appcore_core::RuntimeError {
167 use CommandRequestValidationError as ValidationError;
168 let reason = match error {
169 ValidationError::EmptyCommandName => "empty_command_name",
170 ValidationError::EmptyCommandId => "empty_command_id",
171 ValidationError::PayloadTooLarge => "payload_too_large",
172 ValidationError::MissingIdempotencyKey => "missing_idempotency_key",
173 ValidationError::InvalidIdempotencyKey => "invalid_idempotency_key",
174 ValidationError::InvalidCommandName => "invalid_command_name",
175 ValidationError::InvalidCommandId => "invalid_command_id",
176 };
177 appcore_core::RuntimeError::InvalidRequest {
178 kind: "command",
179 reason,
180 }
181}
182
183#[cfg(test)]
184#[path = "command_contract_tests.rs"]
185mod tests;