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(default, skip_serializing_if = "Option::is_none")]
74    pub tenant: Option<String>,
75    #[serde(
76        rename = "correlation_id",
77        default,
78        skip_serializing_if = "Option::is_none"
79    )]
80    pub correlation_id: Option<String>,
81    #[serde(
82        rename = "in_reply_to",
83        default,
84        skip_serializing_if = "Option::is_none"
85    )]
86    pub in_reply_to: Option<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct ProtectedPayload {
91    pub nonce: String,
92    pub ciphertext: String,
93    #[serde(rename = "wrapped_content_keys")]
94    pub wrapped_content_keys: Vec<WrappedContentKey>,
95    #[serde(rename = "payload_hash")]
96    pub payload_hash: String,
97    #[serde(rename = "signature_kid")]
98    pub signature_kid: String,
99    pub signature: String,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct AcpMessage {
104    pub envelope: Envelope,
105    #[serde(rename = "protected")]
106    pub protected_payload: ProtectedPayload,
107    #[serde(
108        rename = "sender_identity_document",
109        default,
110        skip_serializing_if = "Option::is_none"
111    )]
112    pub sender_identity_document: Option<JsonMap>,
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct DeliveryOutcome {
117    pub recipient: String,
118    pub state: DeliveryState,
119    #[serde(
120        rename = "status_code",
121        default,
122        skip_serializing_if = "Option::is_none"
123    )]
124    pub status_code: Option<u16>,
125    #[serde(
126        rename = "response_class",
127        default,
128        skip_serializing_if = "Option::is_none"
129    )]
130    pub response_class: Option<MessageClass>,
131    #[serde(
132        rename = "reason_code",
133        default,
134        skip_serializing_if = "Option::is_none"
135    )]
136    pub reason_code: Option<String>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub detail: Option<String>,
139    #[serde(
140        rename = "response_message",
141        default,
142        skip_serializing_if = "Option::is_none"
143    )]
144    pub response_message: Option<JsonMap>,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct SendResult {
149    #[serde(rename = "operation_id")]
150    pub operation_id: String,
151    #[serde(rename = "message_id")]
152    pub message_id: String,
153    #[serde(rename = "message_ids", default, skip_serializing_if = "Vec::is_empty")]
154    pub message_ids: Vec<String>,
155    #[serde(default)]
156    pub outcomes: Vec<DeliveryOutcome>,
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct CompensateInstruction {
161    #[serde(rename = "operation_id")]
162    pub operation_id: String,
163    pub reason: String,
164    #[serde(default)]
165    pub actions: Vec<JsonMap>,
166}
167
168impl Envelope {
169    #[allow(clippy::too_many_arguments)]
170    pub fn build(
171        sender: impl Into<String>,
172        recipients: Vec<String>,
173        message_class: MessageClass,
174        context_id: impl Into<String>,
175        expires_in_seconds: i64,
176        operation_id: Option<String>,
177        tenant: Option<String>,
178        correlation_id: Option<String>,
179        in_reply_to: Option<String>,
180        crypto_suite: Option<String>,
181    ) -> AcpResult<Self> {
182        let now = Utc::now();
183        let expires_at = now + Duration::seconds(expires_in_seconds.max(1));
184        let env = Self {
185            acp_version: ACP_VERSION.to_string(),
186            message_class,
187            message_id: Uuid::new_v4().to_string(),
188            operation_id: operation_id.unwrap_or_else(|| Uuid::new_v4().to_string()),
189            timestamp: now.to_rfc3339(),
190            expires_at: expires_at.to_rfc3339(),
191            sender: sender.into(),
192            recipients,
193            context_id: context_id.into(),
194            crypto_suite: crypto_suite.unwrap_or_else(|| DEFAULT_CRYPTO_SUITE.to_string()),
195            tenant,
196            correlation_id,
197            in_reply_to,
198        };
199        env.validate()?;
200        Ok(env)
201    }
202
203    pub fn validate(&self) -> AcpResult<()> {
204        if self.sender.trim().is_empty() {
205            return Err(AcpError::Validation(
206                "Envelope sender is required".to_string(),
207            ));
208        }
209        if self.recipients.is_empty() {
210            return Err(AcpError::Validation(
211                "Envelope recipients must not be empty".to_string(),
212            ));
213        }
214        let ts = chrono::DateTime::parse_from_rfc3339(&self.timestamp)
215            .map_err(|e| AcpError::Validation(format!("Invalid timestamp: {e}")))?;
216        let exp = chrono::DateTime::parse_from_rfc3339(&self.expires_at)
217            .map_err(|e| AcpError::Validation(format!("Invalid expires_at: {e}")))?;
218        if exp <= ts {
219            return Err(AcpError::Validation(
220                "Envelope expires_at must be after timestamp".to_string(),
221            ));
222        }
223        Ok(())
224    }
225
226    pub fn is_expired(&self) -> bool {
227        chrono::DateTime::parse_from_rfc3339(&self.expires_at)
228            .map(|exp| exp <= Utc::now())
229            .unwrap_or(true)
230    }
231
232    pub fn to_map(&self) -> AcpResult<JsonMap> {
233        json_support::to_map(self)
234    }
235}
236
237impl ProtectedPayload {
238    pub fn to_signable_value(&self) -> Value {
239        let mut keys = self.wrapped_content_keys.clone();
240        keys.sort_by(|a, b| a.recipient.cmp(&b.recipient));
241        json!({
242            "nonce": self.nonce,
243            "ciphertext": self.ciphertext,
244            "wrapped_content_keys": keys,
245            "payload_hash": self.payload_hash,
246            "signature_kid": self.signature_kid,
247        })
248    }
249}
250
251impl AcpMessage {
252    pub fn to_map(&self) -> AcpResult<JsonMap> {
253        json_support::to_map(self)
254    }
255
256    pub fn from_map(value: &JsonMap) -> AcpResult<Self> {
257        serde_json::from_value(Value::Object(value.clone())).map_err(AcpError::from)
258    }
259
260    pub fn to_json(&self) -> AcpResult<String> {
261        let value = serde_json::to_value(self)?;
262        json_support::canonical_json_string(&value)
263    }
264}
265
266impl SendResult {
267    pub fn to_map(&self) -> AcpResult<JsonMap> {
268        json_support::to_map(self)
269    }
270}
271
272pub fn build_ack_payload(
273    received_message_id: impl Into<String>,
274    status: impl Into<String>,
275) -> JsonMap {
276    let mut payload = JsonMap::new();
277    payload.insert("status".to_string(), Value::String(status.into()));
278    payload.insert(
279        "received_message_id".to_string(),
280        Value::String(received_message_id.into()),
281    );
282    payload
283}
284
285pub fn build_fail_payload(
286    reason_code: impl Into<String>,
287    detail: impl Into<String>,
288    retriable: bool,
289) -> JsonMap {
290    let mut payload = JsonMap::new();
291    payload.insert("reason_code".to_string(), Value::String(reason_code.into()));
292    payload.insert("detail".to_string(), Value::String(detail.into()));
293    payload.insert("retriable".to_string(), Value::Bool(retriable));
294    payload
295}