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    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    /// Creates an accepted response containing emitted event identities.
136    pub fn accepted(events: Vec<CommandResponseEvent>) -> Self {
137        Self {
138            accepted: true,
139            message: None,
140            events,
141        }
142    }
143
144    /// Creates a controlled rejected response.
145    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;