Skip to main content

freeswitch_types/
headers.rs

1//! Typed event header names for FreeSWITCH ESL events.
2
3/// Error returned when parsing an unrecognized event header name.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct ParseEventHeaderError(pub String);
6
7impl std::fmt::Display for ParseEventHeaderError {
8    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9        write!(f, "unknown event header: {}", self.0)
10    }
11}
12
13impl std::error::Error for ParseEventHeaderError {}
14
15sip_header::define_header_enum! {
16    error_type: ParseEventHeaderError,
17    /// Top-level header names that appear in FreeSWITCH ESL events.
18    ///
19    /// These are the headers on the parsed event itself (not protocol framing
20    /// headers like `Content-Type`). Use with [`EslEvent::header()`](crate::EslEvent::header) for
21    /// type-safe lookups.
22    pub enum EventHeader {
23        EventName => "Event-Name",
24        EventSubclass => "Event-Subclass",
25        UniqueId => "Unique-ID",
26        CallerUniqueId => "Caller-Unique-ID",
27        OtherLegUniqueId => "Other-Leg-Unique-ID",
28        ChannelCallUuid => "Channel-Call-UUID",
29        JobUuid => "Job-UUID",
30        ChannelName => "Channel-Name",
31        ChannelState => "Channel-State",
32        ChannelStateNumber => "Channel-State-Number",
33        ChannelCallState => "Channel-Call-State",
34        AnswerState => "Answer-State",
35        CallDirection => "Call-Direction",
36        HangupCause => "Hangup-Cause",
37        CallerCallerIdName => "Caller-Caller-ID-Name",
38        CallerCallerIdNumber => "Caller-Caller-ID-Number",
39        CallerOrigCallerIdName => "Caller-Orig-Caller-ID-Name",
40        CallerOrigCallerIdNumber => "Caller-Orig-Caller-ID-Number",
41        CallerCalleeIdName => "Caller-Callee-ID-Name",
42        CallerCalleeIdNumber => "Caller-Callee-ID-Number",
43        CallerDestinationNumber => "Caller-Destination-Number",
44        CallerContext => "Caller-Context",
45        CallerDirection => "Caller-Direction",
46        CallerNetworkAddr => "Caller-Network-Addr",
47        CoreUuid => "Core-UUID",
48        DtmfDigit => "DTMF-Digit",
49        Priority => "priority",
50        LogLevel => "Log-Level",
51        /// SIP NOTIFY body content (JSON payload from `NOTIFY_IN` events).
52        PlData => "pl_data",
53        /// SIP event package name from `NOTIFY_IN` events (e.g. `emergency-AbandonedCall`).
54        SipEvent => "event",
55        /// SIP content type from `NOTIFY_IN` events.
56        SipContentType => "sip_content_type",
57        /// Gateway that received the SIP NOTIFY.
58        GatewayName => "gateway_name",
59
60        // --- Codec (from switch_channel_event_set_data / switch_core_codec.c) ---
61        // Audio read
62        ChannelReadCodecName => "Channel-Read-Codec-Name",
63        ChannelReadCodecRate => "Channel-Read-Codec-Rate",
64        ChannelReadCodecBitRate => "Channel-Read-Codec-Bit-Rate",
65        /// Only present when actual_samples_per_second != samples_per_second.
66        ChannelReportedReadCodecRate => "Channel-Reported-Read-Codec-Rate",
67        // Audio write
68        ChannelWriteCodecName => "Channel-Write-Codec-Name",
69        ChannelWriteCodecRate => "Channel-Write-Codec-Rate",
70        ChannelWriteCodecBitRate => "Channel-Write-Codec-Bit-Rate",
71        /// Only present when actual_samples_per_second != samples_per_second.
72        ChannelReportedWriteCodecRate => "Channel-Reported-Write-Codec-Rate",
73        // Video read/write
74        ChannelVideoReadCodecName => "Channel-Video-Read-Codec-Name",
75        ChannelVideoReadCodecRate => "Channel-Video-Read-Codec-Rate",
76        ChannelVideoWriteCodecName => "Channel-Video-Write-Codec-Name",
77        ChannelVideoWriteCodecRate => "Channel-Video-Write-Codec-Rate",
78        /// Active session count from `HEARTBEAT` events.
79        SessionCount => "Session-Count",
80        FreeswitchHostname => "FreeSWITCH-Hostname",
81        FreeswitchSwitchname => "FreeSWITCH-Switchname",
82        FreeswitchIpv4 => "FreeSWITCH-IPv4",
83        FreeswitchIpv6 => "FreeSWITCH-IPv6",
84        FreeswitchVersion => "FreeSWITCH-Version",
85        FreeswitchDomain => "FreeSWITCH-Domain",
86        FreeswitchUser => "FreeSWITCH-User",
87
88        // --- Application (from switch_core_session.c) ---
89        Application => "Application",
90        ApplicationData => "Application-Data",
91        ApplicationResponse => "Application-Response",
92        ApplicationUuid => "Application-UUID",
93    }
94}
95
96/// Normalize a header key to its canonical form for case-insensitive storage.
97///
98/// FreeSWITCH's C ESL uses case-insensitive header lookups (`strcasecmp`), but
99/// stores header names verbatim. Multiple C code paths emit the same logical
100/// header with different casing (e.g. `switch_channel.c` sends `Unique-ID`
101/// while `switch_event.c` sends `unique-id`). This function normalizes keys
102/// so that both resolve to the same `HashMap` entry.
103///
104/// **Strategy:**
105/// 1. Known [`EventHeader`] variants are matched first (case-insensitive) and
106///    returned in their canonical wire form (e.g. `unique-id` → `Unique-ID`).
107/// 2. Unknown keys containing underscores are returned **unchanged** -- these
108///    are channel variables (`variable_*`) or `sip_h_*` passthrough headers
109///    where the suffix preserves the original SIP header casing.
110/// 3. Unknown dash-separated keys are Title-Cased to match FreeSWITCH's
111///    dominant convention for event and framing headers.
112pub fn normalize_header_key(raw: &str) -> String {
113    if let Ok(eh) = raw.parse::<EventHeader>() {
114        return eh
115            .as_str()
116            .to_string();
117    }
118    if raw.contains('_') {
119        raw.to_string()
120    } else {
121        title_case_dashes(raw)
122    }
123}
124
125fn title_case_dashes(s: &str) -> String {
126    let mut result = String::with_capacity(s.len());
127    let mut capitalize_next = true;
128    for c in s.chars() {
129        if c == '-' {
130            result.push('-');
131            capitalize_next = true;
132        } else if capitalize_next {
133            result.push(c.to_ascii_uppercase());
134            capitalize_next = false;
135        } else {
136            result.push(c.to_ascii_lowercase());
137        }
138    }
139    result
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn display_round_trip() {
148        assert_eq!(EventHeader::UniqueId.to_string(), "Unique-ID");
149        assert_eq!(
150            EventHeader::ChannelCallState.to_string(),
151            "Channel-Call-State"
152        );
153        assert_eq!(
154            EventHeader::CallerCallerIdName.to_string(),
155            "Caller-Caller-ID-Name"
156        );
157        assert_eq!(EventHeader::Priority.to_string(), "priority");
158    }
159
160    #[test]
161    fn as_ref_str() {
162        let h: &str = EventHeader::UniqueId.as_ref();
163        assert_eq!(h, "Unique-ID");
164    }
165
166    #[test]
167    fn from_str_case_insensitive() {
168        assert_eq!(
169            "unique-id".parse::<EventHeader>(),
170            Ok(EventHeader::UniqueId)
171        );
172        assert_eq!(
173            "UNIQUE-ID".parse::<EventHeader>(),
174            Ok(EventHeader::UniqueId)
175        );
176        assert_eq!(
177            "Unique-ID".parse::<EventHeader>(),
178            Ok(EventHeader::UniqueId)
179        );
180        assert_eq!(
181            "channel-call-state".parse::<EventHeader>(),
182            Ok(EventHeader::ChannelCallState)
183        );
184    }
185
186    #[test]
187    fn from_str_unknown() {
188        let err = "X-Custom-Not-In-Enum".parse::<EventHeader>();
189        assert!(err.is_err());
190        assert_eq!(
191            err.unwrap_err()
192                .to_string(),
193            "unknown event header: X-Custom-Not-In-Enum"
194        );
195    }
196
197    // --- normalize_header_key tests ---
198    // FreeSWITCH C ESL uses strcasecmp for header lookups but stores names
199    // verbatim. Multiple C code paths emit the same logical header with
200    // different casing (switch_channel.c Title-Case vs switch_event.c lowercase
201    // vs switch_core_codec.c mixed). normalize_header_key canonicalizes keys
202    // so they collapse to a single HashMap entry.
203
204    #[test]
205    fn normalize_known_enum_variants_return_canonical_form() {
206        // EventHeader::from_str is case-insensitive; canonical as_str() is returned
207        assert_eq!(normalize_header_key("unique-id"), "Unique-ID");
208        assert_eq!(normalize_header_key("UNIQUE-ID"), "Unique-ID");
209        assert_eq!(normalize_header_key("Unique-ID"), "Unique-ID");
210        assert_eq!(normalize_header_key("dtmf-digit"), "DTMF-Digit");
211        assert_eq!(normalize_header_key("DTMF-DIGIT"), "DTMF-Digit");
212        assert_eq!(
213            normalize_header_key("channel-call-uuid"),
214            "Channel-Call-UUID"
215        );
216        assert_eq!(normalize_header_key("event-name"), "Event-Name");
217    }
218
219    #[test]
220    fn normalize_known_underscore_variants_return_canonical_form() {
221        // Headers whose canonical form contains underscores
222        assert_eq!(normalize_header_key("priority"), "priority");
223        assert_eq!(normalize_header_key("PRIORITY"), "priority");
224        assert_eq!(normalize_header_key("pl_data"), "pl_data");
225        assert_eq!(normalize_header_key("PL_DATA"), "pl_data");
226        assert_eq!(normalize_header_key("sip_content_type"), "sip_content_type");
227        assert_eq!(normalize_header_key("gateway_name"), "gateway_name");
228        assert_eq!(normalize_header_key("event"), "event");
229        assert_eq!(normalize_header_key("EVENT"), "event");
230    }
231
232    #[test]
233    fn normalize_codec_headers_from_switch_core_codec() {
234        // switch_core_codec.c sends lowercase, switch_channel_event_set_data sends Title-Case
235        // Both must normalize to the canonical EventHeader form
236        assert_eq!(
237            normalize_header_key("channel-read-codec-bit-rate"),
238            "Channel-Read-Codec-Bit-Rate"
239        );
240        assert_eq!(
241            normalize_header_key("Channel-Read-Codec-Bit-Rate"),
242            "Channel-Read-Codec-Bit-Rate"
243        );
244        // switch_core_codec.c mixed case for write: "Channel-Write-codec-bit-rate"
245        assert_eq!(
246            normalize_header_key("Channel-Write-codec-bit-rate"),
247            "Channel-Write-Codec-Bit-Rate"
248        );
249        assert_eq!(
250            normalize_header_key("channel-video-read-codec-name"),
251            "Channel-Video-Read-Codec-Name"
252        );
253    }
254
255    #[test]
256    fn normalize_unknown_underscore_keys_passthrough() {
257        // Channel variables and sip_h_* passthrough preserve original casing
258        assert_eq!(
259            normalize_header_key("variable_sip_call_id"),
260            "variable_sip_call_id"
261        );
262        assert_eq!(
263            normalize_header_key("variable_sip_h_X-My-CUSTOM-Header"),
264            "variable_sip_h_X-My-CUSTOM-Header"
265        );
266        assert_eq!(
267            normalize_header_key("variable_sip_h_Diversion"),
268            "variable_sip_h_Diversion"
269        );
270    }
271
272    #[test]
273    fn normalize_unknown_dash_keys_title_case() {
274        // Framing and unknown event headers get Title-Cased
275        assert_eq!(normalize_header_key("content-type"), "Content-Type");
276        assert_eq!(normalize_header_key("Content-Type"), "Content-Type");
277        assert_eq!(normalize_header_key("CONTENT-TYPE"), "Content-Type");
278        assert_eq!(normalize_header_key("x-custom-header"), "X-Custom-Header");
279        assert_eq!(
280            normalize_header_key("Content-Disposition"),
281            "Content-Disposition"
282        );
283        assert_eq!(normalize_header_key("reply-text"), "Reply-Text");
284    }
285
286    #[test]
287    fn normalize_idempotent_for_all_enum_variants() {
288        // Normalizing an already-canonical wire string must return it unchanged
289        let variants = [
290            EventHeader::EventName,
291            EventHeader::UniqueId,
292            EventHeader::ChannelCallUuid,
293            EventHeader::DtmfDigit,
294            EventHeader::Priority,
295            EventHeader::PlData,
296            EventHeader::SipEvent,
297            EventHeader::GatewayName,
298            EventHeader::SipContentType,
299            EventHeader::ChannelReadCodecBitRate,
300            EventHeader::ChannelVideoWriteCodecRate,
301            EventHeader::LogLevel,
302        ];
303        for v in variants {
304            let canonical = v.as_str();
305            assert_eq!(
306                normalize_header_key(canonical),
307                canonical,
308                "normalization not idempotent for {canonical}"
309            );
310        }
311    }
312
313    #[test]
314    fn from_str_round_trip_all_variants() {
315        let variants = [
316            EventHeader::EventName,
317            EventHeader::EventSubclass,
318            EventHeader::UniqueId,
319            EventHeader::CallerUniqueId,
320            EventHeader::OtherLegUniqueId,
321            EventHeader::ChannelCallUuid,
322            EventHeader::JobUuid,
323            EventHeader::ChannelName,
324            EventHeader::ChannelState,
325            EventHeader::ChannelStateNumber,
326            EventHeader::ChannelCallState,
327            EventHeader::AnswerState,
328            EventHeader::CallDirection,
329            EventHeader::HangupCause,
330            EventHeader::CallerCallerIdName,
331            EventHeader::CallerCallerIdNumber,
332            EventHeader::CallerOrigCallerIdName,
333            EventHeader::CallerOrigCallerIdNumber,
334            EventHeader::CallerCalleeIdName,
335            EventHeader::CallerCalleeIdNumber,
336            EventHeader::CallerDestinationNumber,
337            EventHeader::CallerContext,
338            EventHeader::CallerDirection,
339            EventHeader::CallerNetworkAddr,
340            EventHeader::CoreUuid,
341            EventHeader::DtmfDigit,
342            EventHeader::Priority,
343            EventHeader::LogLevel,
344            EventHeader::PlData,
345            EventHeader::SipEvent,
346            EventHeader::SipContentType,
347            EventHeader::GatewayName,
348            EventHeader::ChannelReadCodecName,
349            EventHeader::ChannelReadCodecRate,
350            EventHeader::ChannelReadCodecBitRate,
351            EventHeader::ChannelReportedReadCodecRate,
352            EventHeader::ChannelWriteCodecName,
353            EventHeader::ChannelWriteCodecRate,
354            EventHeader::ChannelWriteCodecBitRate,
355            EventHeader::ChannelReportedWriteCodecRate,
356            EventHeader::ChannelVideoReadCodecName,
357            EventHeader::ChannelVideoReadCodecRate,
358            EventHeader::ChannelVideoWriteCodecName,
359            EventHeader::ChannelVideoWriteCodecRate,
360            EventHeader::Application,
361            EventHeader::ApplicationData,
362            EventHeader::ApplicationResponse,
363            EventHeader::ApplicationUuid,
364        ];
365        for v in variants {
366            let wire = v.to_string();
367            let parsed: EventHeader = wire
368                .parse()
369                .unwrap();
370            assert_eq!(parsed, v, "round-trip failed for {wire}");
371        }
372    }
373}