gsm_core/
validate.rs

1use crate::{CardBlock, MessageCard, MessageEnvelope, OutKind, OutMessage};
2use anyhow::{Result, bail};
3use time::OffsetDateTime;
4
5/// Validates an inbound [`MessageEnvelope`] for required fields and timestamp correctness.
6///
7/// ```
8/// use gsm_core::{validate_envelope, MessageEnvelope, Platform};
9/// use std::collections::BTreeMap;
10///
11/// let env = MessageEnvelope {
12///     tenant: "acme".into(),
13///     platform: Platform::Teams,
14///     chat_id: "chat-1".into(),
15///     user_id: "user-7".into(),
16///     thread_id: None,
17///     msg_id: "msg-99".into(),
18///     text: Some("Hello".into()),
19///     timestamp: "2024-01-01T00:00:00Z".into(),
20///     context: BTreeMap::new(),
21/// };
22///
23/// validate_envelope(&env).unwrap();
24/// ```
25pub fn validate_envelope(env: &MessageEnvelope) -> Result<()> {
26    if env.tenant.trim().is_empty() {
27        bail!("tenant empty");
28    }
29    if env.chat_id.trim().is_empty() {
30        bail!("chat_id empty");
31    }
32    if env.user_id.trim().is_empty() {
33        bail!("user_id empty");
34    }
35    if env.msg_id.trim().is_empty() {
36        bail!("msg_id empty");
37    }
38    // timestamp is ISO-8601-ish; accept RFC3339
39    OffsetDateTime::parse(
40        &env.timestamp,
41        &time::format_description::well_known::Rfc3339,
42    )
43    .map_err(|e| anyhow::anyhow!("invalid timestamp: {e}"))?;
44    Ok(())
45}
46
47/// Validates an outbound [`OutMessage`] before it is sent to translators.
48///
49/// ```
50/// use gsm_core::{make_tenant_ctx, validate_out, OutKind, OutMessage, Platform};
51///
52/// let out = OutMessage {
53///     ctx: make_tenant_ctx("acme".into(), None, None),
54///     tenant: "acme".into(),
55///     platform: Platform::Telegram,
56///     chat_id: "chat-1".into(),
57///     thread_id: None,
58///     kind: OutKind::Text,
59///     text: Some("Hello".into()),
60///     message_card: None,
61///     #[cfg(feature = "adaptive-cards")]
62///     adaptive_card: None,
63///     meta: Default::default(),
64/// };
65///
66/// validate_out(&out).unwrap();
67/// ```
68pub fn validate_out(out: &OutMessage) -> Result<()> {
69    if out.tenant.trim().is_empty() {
70        bail!("tenant empty");
71    }
72    if out.chat_id.trim().is_empty() {
73        bail!("chat_id empty");
74    }
75    match out.kind {
76        OutKind::Text => {
77            if out.text.as_deref().unwrap_or("").trim().is_empty() {
78                bail!("text empty");
79            }
80        }
81        OutKind::Card => {
82            let card = out
83                .message_card
84                .as_ref()
85                .ok_or_else(|| anyhow::anyhow!("card missing"))?;
86            validate_card(card)?;
87        }
88    }
89    Ok(())
90}
91
92/// Validates the structure and content of a [`MessageCard`].
93///
94/// ```
95/// use gsm_core::{validate_card, CardAction, CardBlock, MessageCard};
96/// use serde_json::json;
97///
98/// let card = MessageCard {
99///     title: Some("Weather".into()),
100///     body: vec![
101///         CardBlock::Text { text: "Forecast".into(), markdown: false },
102///         CardBlock::Fact { label: "High".into(), value: "22C".into() },
103///     ],
104///     actions: vec![
105///         CardAction::OpenUrl {
106///             title: "Details".into(),
107///             url: "https://example.com".into(),
108///             jwt: false,
109///         },
110///         CardAction::Postback { title: "Ack".into(), data: json!({"ok": true}) },
111///     ],
112/// };
113///
114/// validate_card(&card).unwrap();
115/// ```
116pub fn validate_card(card: &MessageCard) -> Result<()> {
117    if card.body.is_empty() && card.title.as_deref().unwrap_or("").is_empty() {
118        bail!("card must have title or body");
119    }
120    for block in &card.body {
121        match block {
122            CardBlock::Text { text, .. } if text.trim().is_empty() => bail!("empty text block"),
123            CardBlock::Fact { label, value }
124                if label.trim().is_empty() || value.trim().is_empty() =>
125            {
126                bail!("empty fact")
127            }
128            CardBlock::Image { url } if url.trim().is_empty() => bail!("empty image url"),
129            _ => {}
130        }
131    }
132    Ok(())
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::{CardAction, Platform, make_tenant_ctx};
139    use serde_json::json;
140    use std::collections::BTreeMap;
141
142    fn sample_envelope() -> MessageEnvelope {
143        MessageEnvelope {
144            tenant: "acme".into(),
145            platform: Platform::Teams,
146            chat_id: "chat-1".into(),
147            user_id: "user-7".into(),
148            thread_id: None,
149            msg_id: "msg-1".into(),
150            text: Some("Hello".into()),
151            timestamp: "2024-01-01T00:00:00Z".into(),
152            context: BTreeMap::new(),
153        }
154    }
155
156    fn sample_out(kind: OutKind) -> OutMessage {
157        OutMessage {
158            ctx: make_tenant_ctx("acme".into(), None, None),
159            tenant: "acme".into(),
160            platform: Platform::Telegram,
161            chat_id: "chat-1".into(),
162            thread_id: None,
163            kind,
164            text: Some("Hello".into()),
165            message_card: None,
166            #[cfg(feature = "adaptive-cards")]
167            adaptive_card: None,
168            meta: Default::default(),
169        }
170    }
171
172    #[test]
173    fn envelope_rejects_empty_tenant() {
174        let mut env = sample_envelope();
175        env.tenant = "   ".into();
176        assert!(validate_envelope(&env).is_err());
177    }
178
179    #[test]
180    fn envelope_rejects_bad_timestamp() {
181        let mut env = sample_envelope();
182        env.timestamp = "not-a-date".into();
183        assert!(validate_envelope(&env).is_err());
184    }
185
186    #[test]
187    fn out_text_requires_content() {
188        let mut out = sample_out(OutKind::Text);
189        out.text = Some("   ".into());
190        assert!(validate_out(&out).is_err());
191    }
192
193    #[test]
194    fn out_card_requires_message_card() {
195        let out = sample_out(OutKind::Card);
196        assert!(validate_out(&out).is_err());
197    }
198
199    #[test]
200    fn card_requires_body_or_title() {
201        let card = MessageCard {
202            title: None,
203            body: vec![],
204            actions: vec![],
205        };
206        assert!(validate_card(&card).is_err());
207    }
208
209    #[test]
210    fn card_rejects_empty_fact_fields() {
211        let card = MessageCard {
212            title: Some("Facts".into()),
213            body: vec![CardBlock::Fact {
214                label: " ".into(),
215                value: "".into(),
216            }],
217            actions: vec![],
218        };
219        assert!(validate_card(&card).is_err());
220    }
221
222    #[test]
223    fn card_accepts_valid_structure() {
224        let card = MessageCard {
225            title: Some("Weather".into()),
226            body: vec![
227                CardBlock::Text {
228                    text: "Sunny".into(),
229                    markdown: false,
230                },
231                CardBlock::Fact {
232                    label: "High".into(),
233                    value: "22C".into(),
234                },
235            ],
236            actions: vec![CardAction::Postback {
237                title: "Ack".into(),
238                data: json!({"ok": true}),
239            }],
240        };
241        assert!(validate_card(&card).is_ok());
242    }
243}