use appcore_core::{AppId, CommandEnvelope, CommandName, NodeId, RuntimeResult};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandRequest {
pub command_name: String,
pub command_id: String,
pub idempotency_key: Option<String>,
pub payload: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandResponseEvent {
pub event_name: String,
pub event_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandResponse {
pub accepted: bool,
pub message: Option<String>,
pub events: Vec<CommandResponseEvent>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandRequestValidationError {
EmptyCommandName,
EmptyCommandId,
PayloadTooLarge,
MissingIdempotencyKey,
InvalidIdempotencyKey,
InvalidCommandName,
InvalidCommandId,
}
impl CommandRequest {
pub fn validate(&self, max_payload_bytes: usize) -> Result<(), CommandRequestValidationError> {
if self.command_name.trim().is_empty() {
return Err(CommandRequestValidationError::EmptyCommandName);
}
if self.command_id.trim().is_empty() {
return Err(CommandRequestValidationError::EmptyCommandId);
}
if self.command_name.len() > 128 || !is_valid_token(&self.command_name) {
return Err(CommandRequestValidationError::InvalidCommandName);
}
if self.command_id.len() > 128 || !is_valid_token(&self.command_id) {
return Err(CommandRequestValidationError::InvalidCommandId);
}
if requires_idempotency_key(&self.command_name) && self.idempotency_key.is_none() {
return Err(CommandRequestValidationError::MissingIdempotencyKey);
}
if let Some(key) = self.idempotency_key.as_deref() {
if key.trim().is_empty() || key.len() > 128 || !is_valid_token(key) {
return Err(CommandRequestValidationError::InvalidIdempotencyKey);
}
}
if self.payload.len() > max_payload_bytes {
return Err(CommandRequestValidationError::PayloadTooLarge);
}
Ok(())
}
pub fn payload_bytes(&self) -> &[u8] {
self.payload.as_bytes()
}
pub fn to_envelope(
&self,
app_id: AppId,
node_id: NodeId,
issued_at_ms: u64,
max_payload_bytes: usize,
) -> RuntimeResult<CommandEnvelope> {
if let Err(error) = self.validate(max_payload_bytes) {
return Err(validation_error_to_runtime_error(error));
}
self.to_envelope_unchecked(app_id, node_id, issued_at_ms)
}
pub fn into_envelope(
self,
app_id: AppId,
node_id: NodeId,
issued_at_ms: u64,
max_payload_bytes: usize,
) -> RuntimeResult<CommandEnvelope> {
if let Err(error) = self.validate(max_payload_bytes) {
return Err(validation_error_to_runtime_error(error));
}
self.into_envelope_unchecked(app_id, node_id, issued_at_ms)
}
fn to_envelope_unchecked(
&self,
app_id: AppId,
node_id: NodeId,
issued_at_ms: u64,
) -> RuntimeResult<CommandEnvelope> {
CommandEnvelope::new(
CommandName::new(self.command_name.clone())?,
self.command_id.clone(),
app_id,
node_id,
issued_at_ms,
self.idempotency_key.clone(),
self.payload_bytes().to_vec(),
)
}
fn into_envelope_unchecked(
self,
app_id: AppId,
node_id: NodeId,
issued_at_ms: u64,
) -> RuntimeResult<CommandEnvelope> {
CommandEnvelope::new(
CommandName::new(self.command_name)?,
self.command_id,
app_id,
node_id,
issued_at_ms,
self.idempotency_key,
self.payload.into_bytes(),
)
}
}
impl CommandResponse {
pub fn accepted(events: Vec<CommandResponseEvent>) -> Self {
Self {
accepted: true,
message: None,
events,
}
}
pub fn rejected(message: impl Into<String>) -> Self {
Self {
accepted: false,
message: Some(message.into()),
events: Vec::new(),
}
}
}
fn is_valid_token(value: &str) -> bool {
value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
}
fn requires_idempotency_key(command_name: &str) -> bool {
command_name != "runtime.ping"
}
fn validation_error_to_runtime_error(
error: CommandRequestValidationError,
) -> appcore_core::RuntimeError {
use CommandRequestValidationError as ValidationError;
let reason = match error {
ValidationError::EmptyCommandName => "empty_command_name",
ValidationError::EmptyCommandId => "empty_command_id",
ValidationError::PayloadTooLarge => "payload_too_large",
ValidationError::MissingIdempotencyKey => "missing_idempotency_key",
ValidationError::InvalidIdempotencyKey => "invalid_idempotency_key",
ValidationError::InvalidCommandName => "invalid_command_name",
ValidationError::InvalidCommandId => "invalid_command_id",
};
appcore_core::RuntimeError::InvalidRequest {
kind: "command",
reason,
}
}
#[cfg(test)]
#[path = "command_contract_tests.rs"]
mod tests;