Skip to main content

videosdk/resources/sip/
routing_rules.rs

1//! SIP routing rules, which bind provisioned numbers to a dispatch target.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use futures_util::Stream;
7use reqwest::Method;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11use crate::client::{CallOptions, Client};
12use crate::common::MessageResponse;
13use crate::error::Result;
14use crate::pagination::{auto_page, paginate, ListParams, Page, PageFetcher};
15use crate::query::QueryBuilder;
16use crate::resources::escape;
17use crate::resources::sip::{SipDirection, SipIncludeHeaders, SipRoomPrefix, SipRoomType};
18
19const PATH: &str = "/v2/sip/routing-rule";
20
21/// The destination-room configuration of a routing rule, as it appears on the wire.
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct SipRoomConfig {
25    /// How the room is allocated.
26    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
27    pub kind: Option<SipRoomType>,
28    /// How a per-call room id is generated, for dynamic rooms.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub prefix: Option<SipRoomPrefix>,
31    /// The fixed room id, for static rooms.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub id: Option<String>,
34    /// An optional room PIN.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub pin: Option<String>,
37}
38
39/// Where a routing rule sends the caller.
40///
41/// This is friendly sugar over [`SipRoomConfig`]. Pass it as
42/// [`CreateRoutingRuleParams::rule`]; set [`CreateRoutingRuleParams::room`]
43/// instead to configure the wire shape directly.
44#[derive(Debug, Clone)]
45pub enum RoutingRuleTarget {
46    /// Places every matching call into one fixed room.
47    Direct {
48        /// The fixed room id.
49        room_id: String,
50        /// An optional room PIN.
51        pin: Option<String>,
52    },
53    /// Creates a fresh room for each matching call.
54    Individual {
55        /// How the per-call room id is generated. Defaults to
56        /// [`SipRoomPrefix::BLANK`]. A strategy, not a literal prefix.
57        room_prefix: Option<SipRoomPrefix>,
58        /// An optional room PIN.
59        pin: Option<String>,
60    },
61}
62
63impl From<RoutingRuleTarget> for SipRoomConfig {
64    fn from(target: RoutingRuleTarget) -> Self {
65        match target {
66            RoutingRuleTarget::Direct { room_id, pin } => SipRoomConfig {
67                kind: Some(SipRoomType::STATIC),
68                prefix: None,
69                id: Some(room_id),
70                pin,
71            },
72            RoutingRuleTarget::Individual { room_prefix, pin } => SipRoomConfig {
73                kind: Some(SipRoomType::DYNAMIC),
74                prefix: Some(room_prefix.unwrap_or(SipRoomPrefix::BLANK)),
75                id: None,
76                pin,
77            },
78        }
79    }
80}
81
82/// A routing rule, binding provisioned phone numbers to a dispatch target.
83#[derive(Debug, Clone, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct SipRoutingRule {
86    /// The rule id.
87    pub id: String,
88    /// The rule's display name.
89    pub name: Option<String>,
90    /// The rule's direction.
91    #[serde(rename = "type")]
92    pub direction: Option<SipDirection>,
93    /// The phone numbers bound to this rule.
94    #[serde(default, deserialize_with = "crate::common::null_to_default")]
95    pub numbers: Vec<String>,
96    /// The destination room configuration.
97    pub room: Option<SipRoomConfig>,
98    /// The agent dispatched for matching calls.
99    pub agent_id: Option<String>,
100    /// Metadata handed to the dispatched agent.
101    pub agent_metadata: Option<Map<String, Value>>,
102    /// Whether the caller's number is hidden from the room.
103    pub hide_phone_number: Option<bool>,
104    /// Free-form tags.
105    #[serde(default, deserialize_with = "crate::common::null_to_default")]
106    pub tags: Vec<String>,
107    /// The API key that owns this rule.
108    pub api_key: Option<String>,
109    /// Whether matching calls are recorded.
110    pub recording: Option<bool>,
111    /// Whether DTMF is enabled.
112    pub dtmf: Option<bool>,
113    /// Whether noise cancellation is applied.
114    pub noise_cancellation: Option<bool>,
115    /// The allowed caller-number patterns.
116    #[serde(default, deserialize_with = "crate::common::null_to_default")]
117    pub allowed_numbers: Vec<String>,
118    /// The allowed source IP addresses.
119    #[serde(
120        rename = "allowedIpAddresses",
121        default,
122        deserialize_with = "crate::common::null_to_default"
123    )]
124    pub allowed_ip_addresses: Vec<String>,
125    /// Which SIP headers are forwarded.
126    pub include_headers: Option<SipIncludeHeaders>,
127    /// Headers added to outbound calls.
128    #[serde(default, deserialize_with = "crate::common::null_to_default")]
129    pub headers: HashMap<String, String>,
130    /// Inbound headers mapped onto participant attributes.
131    #[serde(default, deserialize_with = "crate::common::null_to_default")]
132    pub headers_to_attributes: HashMap<String, String>,
133    /// When the rule was created.
134    pub created_at: Option<String>,
135    /// When the rule was last updated.
136    pub updated_at: Option<String>,
137    /// Any fields the server returned that this SDK does not model yet.
138    #[serde(flatten)]
139    pub extra: Map<String, Value>,
140}
141
142/// The parameters for [`SipRoutingRulesResource::create`].
143#[derive(Debug, Clone, Default)]
144pub struct CreateRoutingRuleParams {
145    /// The display name. Required.
146    pub name: String,
147    /// The direction. Required.
148    pub direction: Option<SipDirection>,
149    /// The provisioned phone numbers to bind.
150    pub phone_numbers: Vec<String>,
151    /// Where to route matching calls. Friendly sugar over `room`.
152    pub rule: Option<RoutingRuleTarget>,
153    /// An explicit room configuration. Used only when `rule` is omitted.
154    pub room: Option<SipRoomConfig>,
155    /// The agent to dispatch for matching calls.
156    pub agent_id: Option<String>,
157    /// Metadata handed to the dispatched agent. Applied on create only when
158    /// `agent_id` is also set.
159    pub agent_metadata: Option<Map<String, Value>>,
160    /// Which SIP headers to forward.
161    pub include_headers: Option<SipIncludeHeaders>,
162    /// Headers to add. **Outbound rules only** — the server rejects this on an
163    /// inbound rule.
164    pub headers: HashMap<String, String>,
165    /// Inbound headers to map onto participant attributes. **Inbound rules
166    /// only** — the server rejects this on an outbound rule.
167    pub headers_to_attributes: HashMap<String, String>,
168    /// The allowed caller-number patterns.
169    pub allowed_numbers: Vec<String>,
170    /// The allowed source IP addresses.
171    pub allowed_ip_addresses: Vec<String>,
172    /// Free-form tags.
173    pub tags: Vec<String>,
174    /// Whether to record matching calls.
175    pub recording: Option<bool>,
176    /// Whether to enable DTMF.
177    pub dtmf: Option<bool>,
178    /// Whether to apply noise cancellation.
179    pub noise_cancellation: Option<bool>,
180    /// Whether to hide the caller's number from the room.
181    pub hide_phone_number: Option<bool>,
182    /// The API key that should own this rule.
183    pub api_key: Option<String>,
184}
185
186/// The parameters for [`SipRoutingRulesResource::update`]. A rule's direction is
187/// fixed at creation and cannot be changed.
188#[derive(Debug, Clone, Default)]
189pub struct UpdateRoutingRuleParams {
190    /// The display name.
191    pub name: Option<String>,
192    /// The provisioned phone numbers to bind.
193    pub phone_numbers: Vec<String>,
194    /// Where to route matching calls. Friendly sugar over `room`.
195    pub rule: Option<RoutingRuleTarget>,
196    /// An explicit room configuration. Used only when `rule` is omitted.
197    pub room: Option<SipRoomConfig>,
198    /// The agent to dispatch for matching calls.
199    pub agent_id: Option<String>,
200    /// Metadata handed to the dispatched agent.
201    pub agent_metadata: Option<Map<String, Value>>,
202    /// Which SIP headers to forward.
203    pub include_headers: Option<SipIncludeHeaders>,
204    /// Headers to add. Outbound rules only.
205    pub headers: HashMap<String, String>,
206    /// Inbound headers to map onto participant attributes. Inbound rules only.
207    pub headers_to_attributes: HashMap<String, String>,
208    /// The allowed caller-number patterns.
209    pub allowed_numbers: Vec<String>,
210    /// The allowed source IP addresses.
211    pub allowed_ip_addresses: Vec<String>,
212    /// Free-form tags.
213    pub tags: Vec<String>,
214    /// Whether to record matching calls.
215    pub recording: Option<bool>,
216    /// Whether to enable DTMF.
217    pub dtmf: Option<bool>,
218    /// Whether to apply noise cancellation.
219    pub noise_cancellation: Option<bool>,
220    /// Whether to hide the caller's number from the room.
221    pub hide_phone_number: Option<bool>,
222    /// The API key that should own this rule.
223    pub api_key: Option<String>,
224}
225
226/// The shared wire body. `direction` is sent on create and omitted on update.
227#[derive(Debug, Default, Serialize)]
228#[serde(rename_all = "camelCase")]
229struct RoutingRuleWire {
230    #[serde(skip_serializing_if = "Option::is_none")]
231    name: Option<String>,
232    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
233    direction: Option<SipDirection>,
234    #[serde(skip_serializing_if = "Vec::is_empty")]
235    phone_numbers: Vec<String>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    room: Option<SipRoomConfig>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    agent_id: Option<String>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    agent_metadata: Option<Map<String, Value>>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    include_headers: Option<SipIncludeHeaders>,
244    #[serde(skip_serializing_if = "HashMap::is_empty")]
245    headers: HashMap<String, String>,
246    #[serde(skip_serializing_if = "HashMap::is_empty")]
247    headers_to_attributes: HashMap<String, String>,
248    #[serde(skip_serializing_if = "Vec::is_empty")]
249    allowed_numbers: Vec<String>,
250    #[serde(rename = "allowedIpAddresses", skip_serializing_if = "Vec::is_empty")]
251    allowed_ip_addresses: Vec<String>,
252    #[serde(skip_serializing_if = "Vec::is_empty")]
253    tags: Vec<String>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    recording: Option<bool>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    dtmf: Option<bool>,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    noise_cancellation: Option<bool>,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    hide_phone_number: Option<bool>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    api_key: Option<String>,
264}
265
266/// An explicit `room` wins over the `rule` sugar.
267fn resolve_room(
268    room: Option<SipRoomConfig>,
269    rule: Option<RoutingRuleTarget>,
270) -> Option<SipRoomConfig> {
271    room.or_else(|| rule.map(SipRoomConfig::from))
272}
273
274impl From<CreateRoutingRuleParams> for RoutingRuleWire {
275    fn from(params: CreateRoutingRuleParams) -> Self {
276        Self {
277            name: Some(params.name).filter(|name| !name.is_empty()),
278            direction: params.direction,
279            phone_numbers: params.phone_numbers,
280            room: resolve_room(params.room, params.rule),
281            agent_id: params.agent_id,
282            agent_metadata: params.agent_metadata,
283            include_headers: params.include_headers,
284            headers: params.headers,
285            headers_to_attributes: params.headers_to_attributes,
286            allowed_numbers: params.allowed_numbers,
287            allowed_ip_addresses: params.allowed_ip_addresses,
288            tags: params.tags,
289            recording: params.recording,
290            dtmf: params.dtmf,
291            noise_cancellation: params.noise_cancellation,
292            hide_phone_number: params.hide_phone_number,
293            api_key: params.api_key,
294        }
295    }
296}
297
298impl From<UpdateRoutingRuleParams> for RoutingRuleWire {
299    fn from(params: UpdateRoutingRuleParams) -> Self {
300        Self {
301            name: params.name,
302            // A rule's direction is fixed at creation.
303            direction: None,
304            phone_numbers: params.phone_numbers,
305            room: resolve_room(params.room, params.rule),
306            agent_id: params.agent_id,
307            agent_metadata: params.agent_metadata,
308            include_headers: params.include_headers,
309            headers: params.headers,
310            headers_to_attributes: params.headers_to_attributes,
311            allowed_numbers: params.allowed_numbers,
312            allowed_ip_addresses: params.allowed_ip_addresses,
313            tags: params.tags,
314            recording: params.recording,
315            dtmf: params.dtmf,
316            noise_cancellation: params.noise_cancellation,
317            hide_phone_number: params.hide_phone_number,
318            api_key: params.api_key,
319        }
320    }
321}
322
323/// The query parameters for [`SipRoutingRulesResource::list`].
324#[derive(Debug, Clone, Default)]
325pub struct SipRoutingRuleListParams {
326    /// The 1-based page number.
327    pub page: Option<u32>,
328    /// Items per page.
329    pub per_page: Option<u32>,
330    /// An opaque cursor from a previous page.
331    pub cursor: Option<String>,
332    /// Matches on name or numbers.
333    pub search: Option<String>,
334    /// Filters by direction.
335    pub direction: Option<SipDirection>,
336    /// Filters to specific rule ids.
337    pub rule_ids: Vec<String>,
338    /// Filters by destination room type.
339    pub room_type: Vec<SipRoomType>,
340}
341
342impl SipRoutingRuleListParams {
343    fn pagination(&self) -> ListParams {
344        ListParams {
345            page: self.page,
346            per_page: self.per_page,
347            cursor: self.cursor.clone(),
348        }
349    }
350}
351
352/// SIP routing rules. Reached via [`SipResource::routing_rules`](crate::SipResource::routing_rules).
353#[derive(Debug, Clone, Copy)]
354pub struct SipRoutingRulesResource<'a> {
355    client: &'a Client,
356}
357
358impl<'a> SipRoutingRulesResource<'a> {
359    pub(crate) fn new(client: &'a Client) -> Self {
360        Self { client }
361    }
362
363    /// Creates a routing rule.
364    pub async fn create(&self, params: CreateRoutingRuleParams) -> Result<SipRoutingRule> {
365        let body = RoutingRuleWire::from(params);
366        self.client
367            .json(Method::POST, PATH, CallOptions::json(&body)?)
368            .await
369    }
370
371    /// Lists routing rules, one page at a time.
372    pub async fn list(&self, params: SipRoutingRuleListParams) -> Result<Page<SipRoutingRule>> {
373        paginate(self.fetcher(&params), &params.pagination(), "data", None).await
374    }
375
376    /// Lists routing rules, transparently fetching every page.
377    pub fn list_stream(
378        &self,
379        params: SipRoutingRuleListParams,
380    ) -> impl Stream<Item = Result<SipRoutingRule>> + Send {
381        auto_page(self.fetcher(&params), params.pagination(), "data", None)
382    }
383
384    /// Fetches a routing rule by id.
385    pub async fn get(&self, rule_id: &str) -> Result<SipRoutingRule> {
386        let path = format!("{PATH}/{}", escape(rule_id));
387        self.client
388            .json(Method::GET, &path, CallOptions::new())
389            .await
390    }
391
392    /// Partially updates a routing rule. Its direction cannot be changed.
393    pub async fn update(
394        &self,
395        rule_id: &str,
396        params: UpdateRoutingRuleParams,
397    ) -> Result<SipRoutingRule> {
398        let body = RoutingRuleWire::from(params);
399        let path = format!("{PATH}/{}", escape(rule_id));
400        self.client
401            .json(Method::PATCH, &path, CallOptions::json(&body)?)
402            .await
403    }
404
405    /// Deletes a routing rule.
406    pub async fn delete(&self, rule_id: &str) -> Result<MessageResponse> {
407        let path = format!("{PATH}/{}", escape(rule_id));
408        self.client
409            .json(Method::DELETE, &path, CallOptions::new())
410            .await
411    }
412
413    fn fetcher(&self, params: &SipRoutingRuleListParams) -> PageFetcher {
414        let client = self.client.clone();
415        let params = params.clone();
416        Arc::new(move |page, per_page| {
417            let client = client.clone();
418            let params = params.clone();
419            Box::pin(async move {
420                let query = QueryBuilder::new()
421                    .opt("page", page)
422                    .opt("perPage", per_page)
423                    .opt_str("search", params.search.as_deref())
424                    .opt_str("type", params.direction.as_ref().map(SipDirection::as_str))
425                    .csv("ruleIds", &params.rule_ids)
426                    .csv("roomType", &params.room_type)
427                    .into_pairs();
428                client
429                    .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
430                    .await
431            })
432        })
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use serde_json::json;
440
441    fn wire(params: CreateRoutingRuleParams) -> Value {
442        serde_json::to_value(RoutingRuleWire::from(params)).unwrap()
443    }
444
445    #[test]
446    fn a_direct_target_becomes_a_static_room() {
447        let body = wire(CreateRoutingRuleParams {
448            name: "rule".into(),
449            direction: Some(SipDirection::INBOUND),
450            rule: Some(RoutingRuleTarget::Direct {
451                room_id: "abcd-efgh".into(),
452                pin: Some("1234".into()),
453            }),
454            ..Default::default()
455        });
456        assert_eq!(
457            body,
458            json!({
459                "name": "rule",
460                "type": "inbound",
461                "room": {"type": "static", "id": "abcd-efgh", "pin": "1234"},
462            })
463        );
464    }
465
466    #[test]
467    fn an_individual_target_becomes_a_dynamic_room_defaulting_to_blank() {
468        let body = wire(CreateRoutingRuleParams {
469            name: "rule".into(),
470            rule: Some(RoutingRuleTarget::Individual {
471                room_prefix: None,
472                pin: None,
473            }),
474            ..Default::default()
475        });
476        assert_eq!(body["room"], json!({"type": "dynamic", "prefix": "blank"}));
477
478        let body = wire(CreateRoutingRuleParams {
479            name: "rule".into(),
480            rule: Some(RoutingRuleTarget::Individual {
481                room_prefix: Some(SipRoomPrefix::SIP_NUMBER),
482                pin: None,
483            }),
484            ..Default::default()
485        });
486        assert_eq!(
487            body["room"],
488            json!({"type": "dynamic", "prefix": "sipNumber"})
489        );
490    }
491
492    #[test]
493    fn an_explicit_room_wins_over_the_rule_sugar() {
494        let body = wire(CreateRoutingRuleParams {
495            name: "rule".into(),
496            room: Some(SipRoomConfig {
497                kind: Some(SipRoomType::STATIC),
498                id: Some("explicit".into()),
499                ..Default::default()
500            }),
501            rule: Some(RoutingRuleTarget::Direct {
502                room_id: "sugar".into(),
503                pin: None,
504            }),
505            ..Default::default()
506        });
507        assert_eq!(body["room"]["id"], json!("explicit"));
508    }
509
510    #[test]
511    fn an_update_never_sends_the_direction() {
512        let body = serde_json::to_value(RoutingRuleWire::from(UpdateRoutingRuleParams {
513            name: Some("renamed".into()),
514            ..Default::default()
515        }))
516        .unwrap();
517        assert_eq!(body, json!({"name": "renamed"}));
518        assert!(body.get("type").is_none(), "direction is fixed at creation");
519    }
520
521    #[test]
522    fn empty_collections_are_omitted() {
523        let body = wire(CreateRoutingRuleParams {
524            name: "rule".into(),
525            ..Default::default()
526        });
527        assert_eq!(body, json!({"name": "rule"}));
528    }
529
530    #[test]
531    fn allowed_ip_addresses_keeps_its_lowercase_p() {
532        let body = wire(CreateRoutingRuleParams {
533            name: "rule".into(),
534            allowed_ip_addresses: vec!["1.2.3.4".into()],
535            ..Default::default()
536        });
537        assert_eq!(body["allowedIpAddresses"], json!(["1.2.3.4"]));
538    }
539}