Skip to main content

appcore_api/
command_contract.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: command_contract.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/05/31 13:38:42 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 13:45:20 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Shared command request/response API contract for transports.
12
13use appcore_core::{AppId, CommandEnvelope, CommandName, NodeId, RuntimeResult};
14use serde::{Deserialize, Serialize};
15
16/// Version 1 command request transported to the Runtime host.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct CommandRequest {
19    /// Declared application or Runtime command capability.
20    pub command_name: String,
21    /// Caller-assigned request identity.
22    pub command_id: String,
23    /// Replay-safe identity required by mutating commands.
24    pub idempotency_key: Option<String>,
25    /// Opaque UTF-8 application payload.
26    pub payload: String,
27}
28
29/// Event identity returned by an accepted command.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct CommandResponseEvent {
32    /// Registered event name.
33    pub event_name: String,
34    /// Unique emitted event identity.
35    pub event_id: String,
36}
37
38/// Version 1 controlled command response.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct CommandResponse {
41    /// Whether the command was accepted.
42    pub accepted: bool,
43    /// Controlled rejection detail, when present.
44    pub message: Option<String>,
45    /// Events emitted by an accepted command.
46    pub events: Vec<CommandResponseEvent>,
47}
48
49/// Validation failures defined by the command V1 contract.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum CommandRequestValidationError {
52    /// The command name is empty.
53    EmptyCommandName,
54    /// The command identifier is empty.
55    EmptyCommandId,
56    /// The payload exceeds the configured request bound.
57    PayloadTooLarge,
58    /// A mutating command omitted its idempotency key.
59    MissingIdempotencyKey,
60    /// The supplied idempotency key is malformed.
61    InvalidIdempotencyKey,
62    /// The command name is malformed.
63    InvalidCommandName,
64    /// The command identifier is malformed.
65    InvalidCommandId,
66}
67
68impl CommandRequest {
69    /// Validates identifiers, idempotency and the payload bound.
70    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    /// Borrows the UTF-8 payload as bytes.
98    pub fn payload_bytes(&self) -> &[u8] {
99        self.payload.as_bytes()
100    }
101
102    /// Validates and converts this request to the core command envelope.
103    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    /// Validates and consumes this request without copying its payload bytes.
117    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    /// Creates an accepted response containing emitted event identities.
167    pub fn accepted(events: Vec<CommandResponseEvent>) -> Self {
168        Self {
169            accepted: true,
170            message: None,
171            events,
172        }
173    }
174
175    /// Creates a controlled rejected response.
176    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;