Skip to main content

acp_runtime/
messages.rs

1// Copyright 2026 ACP Project
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5use chrono::{Duration, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use uuid::Uuid;
9
10use crate::constants::{ACP_VERSION, DEFAULT_CRYPTO_SUITE};
11use crate::errors::{AcpError, AcpResult};
12use crate::json_support::{self, JsonMap};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
16pub enum MessageClass {
17    Send,
18    Ack,
19    Fail,
20    Capabilities,
21    Compensate,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
26pub enum DeliveryState {
27    Pending,
28    Delivered,
29    Acknowledged,
30    Failed,
31    Declined,
32    Expired,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
37pub enum DeliveryMode {
38    Auto,
39    Direct,
40    Relay,
41    Amqp,
42    Mqtt,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct WrappedContentKey {
47    pub recipient: String,
48    #[serde(rename = "ephemeral_public_key")]
49    pub ephemeral_public_key: String,
50    pub nonce: String,
51    pub ciphertext: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct Envelope {
56    #[serde(rename = "acp_version")]
57    pub acp_version: String,
58    #[serde(rename = "message_class")]
59    pub message_class: MessageClass,
60    #[serde(rename = "message_id")]
61    pub message_id: String,
62    #[serde(rename = "operation_id")]
63    pub operation_id: String,
64    pub timestamp: String,
65    #[serde(rename = "expires_at")]
66    pub expires_at: String,
67    pub sender: String,
68    pub recipients: Vec<String>,
69    #[serde(rename = "context_id")]
70    pub context_id: String,
71    #[serde(rename = "crypto_suite")]
72    pub crypto_suite: String,
73    #[serde(
74        rename = "correlation_id",
75        default,
76        skip_serializing_if = "Option::is_none"
77    )]
78    pub correlation_id: Option<String>,
79    #[serde(
80        rename = "in_reply_to",
81        default,
82        skip_serializing_if = "Option::is_none"
83    )]
84    pub in_reply_to: Option<String>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct ProtectedPayload {
89    pub nonce: String,
90    pub ciphertext: String,
91    #[serde(rename = "wrapped_content_keys")]
92    pub wrapped_content_keys: Vec<WrappedContentKey>,
93    #[serde(rename = "payload_hash")]
94    pub payload_hash: String,
95    #[serde(rename = "signature_kid")]
96    pub signature_kid: String,
97    pub signature: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct AcpMessage {
102    pub envelope: Envelope,
103    #[serde(rename = "protected")]
104    pub protected_payload: ProtectedPayload,
105    #[serde(
106        rename = "sender_identity_document",
107        default,
108        skip_serializing_if = "Option::is_none"
109    )]
110    pub sender_identity_document: Option<JsonMap>,
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct DeliveryOutcome {
115    pub recipient: String,
116    pub state: DeliveryState,
117    #[serde(
118        rename = "status_code",
119        default,
120        skip_serializing_if = "Option::is_none"
121    )]
122    pub status_code: Option<u16>,
123    #[serde(
124        rename = "response_class",
125        default,
126        skip_serializing_if = "Option::is_none"
127    )]
128    pub response_class: Option<MessageClass>,
129    #[serde(
130        rename = "reason_code",
131        default,
132        skip_serializing_if = "Option::is_none"
133    )]
134    pub reason_code: Option<String>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub detail: Option<String>,
137    #[serde(
138        rename = "response_message",
139        default,
140        skip_serializing_if = "Option::is_none"
141    )]
142    pub response_message: Option<JsonMap>,
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct SendResult {
147    #[serde(rename = "operation_id")]
148    pub operation_id: String,
149    #[serde(rename = "message_id")]
150    pub message_id: String,
151    #[serde(rename = "message_ids", default, skip_serializing_if = "Vec::is_empty")]
152    pub message_ids: Vec<String>,
153    #[serde(default)]
154    pub outcomes: Vec<DeliveryOutcome>,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct CompensateInstruction {
159    #[serde(rename = "operation_id")]
160    pub operation_id: String,
161    pub reason: String,
162    #[serde(default)]
163    pub actions: Vec<JsonMap>,
164}
165
166impl Envelope {
167    #[allow(clippy::too_many_arguments)]
168    pub fn build(
169        sender: impl Into<String>,
170        recipients: Vec<String>,
171        message_class: MessageClass,
172        context_id: impl Into<String>,
173        expires_in_seconds: i64,
174        operation_id: Option<String>,
175        correlation_id: Option<String>,
176        in_reply_to: Option<String>,
177        crypto_suite: Option<String>,
178    ) -> AcpResult<Self> {
179        let now = Utc::now();
180        let expires_at = now + Duration::seconds(expires_in_seconds.max(1));
181        let env = Self {
182            acp_version: ACP_VERSION.to_string(),
183            message_class,
184            message_id: Uuid::new_v4().to_string(),
185            operation_id: operation_id.unwrap_or_else(|| Uuid::new_v4().to_string()),
186            timestamp: now.to_rfc3339(),
187            expires_at: expires_at.to_rfc3339(),
188            sender: sender.into(),
189            recipients,
190            context_id: context_id.into(),
191            crypto_suite: crypto_suite.unwrap_or_else(|| DEFAULT_CRYPTO_SUITE.to_string()),
192            correlation_id,
193            in_reply_to,
194        };
195        env.validate()?;
196        Ok(env)
197    }
198
199    pub fn validate(&self) -> AcpResult<()> {
200        if self.sender.trim().is_empty() {
201            return Err(AcpError::Validation(
202                "Envelope sender is required".to_string(),
203            ));
204        }
205        if self.recipients.is_empty() {
206            return Err(AcpError::Validation(
207                "Envelope recipients must not be empty".to_string(),
208            ));
209        }
210        let ts = chrono::DateTime::parse_from_rfc3339(&self.timestamp)
211            .map_err(|e| AcpError::Validation(format!("Invalid timestamp: {e}")))?;
212        let exp = chrono::DateTime::parse_from_rfc3339(&self.expires_at)
213            .map_err(|e| AcpError::Validation(format!("Invalid expires_at: {e}")))?;
214        if exp <= ts {
215            return Err(AcpError::Validation(
216                "Envelope expires_at must be after timestamp".to_string(),
217            ));
218        }
219        Ok(())
220    }
221
222    pub fn is_expired(&self) -> bool {
223        chrono::DateTime::parse_from_rfc3339(&self.expires_at)
224            .map(|exp| exp <= Utc::now())
225            .unwrap_or(true)
226    }
227
228    pub fn to_map(&self) -> AcpResult<JsonMap> {
229        json_support::to_map(self)
230    }
231}
232
233impl ProtectedPayload {
234    pub fn to_signable_value(&self) -> Value {
235        let mut keys = self.wrapped_content_keys.clone();
236        keys.sort_by(|a, b| a.recipient.cmp(&b.recipient));
237        json!({
238            "nonce": self.nonce,
239            "ciphertext": self.ciphertext,
240            "wrapped_content_keys": keys,
241            "payload_hash": self.payload_hash,
242            "signature_kid": self.signature_kid,
243        })
244    }
245}
246
247impl AcpMessage {
248    pub fn to_map(&self) -> AcpResult<JsonMap> {
249        json_support::to_map(self)
250    }
251
252    pub fn from_map(value: &JsonMap) -> AcpResult<Self> {
253        serde_json::from_value(Value::Object(value.clone())).map_err(AcpError::from)
254    }
255
256    pub fn to_json(&self) -> AcpResult<String> {
257        let value = serde_json::to_value(self)?;
258        json_support::canonical_json_string(&value)
259    }
260}
261
262impl SendResult {
263    pub fn to_map(&self) -> AcpResult<JsonMap> {
264        json_support::to_map(self)
265    }
266}
267
268pub fn build_ack_payload(
269    received_message_id: impl Into<String>,
270    status: impl Into<String>,
271) -> JsonMap {
272    let mut payload = JsonMap::new();
273    payload.insert("status".to_string(), Value::String(status.into()));
274    payload.insert(
275        "received_message_id".to_string(),
276        Value::String(received_message_id.into()),
277    );
278    payload
279}
280
281pub fn build_fail_payload(
282    reason_code: impl Into<String>,
283    detail: impl Into<String>,
284    retriable: bool,
285) -> JsonMap {
286    let mut payload = JsonMap::new();
287    payload.insert("reason_code".to_string(), Value::String(reason_code.into()));
288    payload.insert("detail".to_string(), Value::String(detail.into()));
289    payload.insert("retriable".to_string(), Value::Bool(retriable));
290    payload
291}