Skip to main content

unifly_api/command/requests/
ports.rs

1//! Switch port profile apply request types.
2//!
3//! `ApplyPortsRequest` is the write-only DTO for `--from-file` payloads
4//! and `devices ports export` output. It mirrors the controller's per-port
5//! override shape but uses CLI-friendly field names and string values.
6//! The handler resolves network names to Session `_id`s and converts
7//! strings to the model enums (`PortMode`, `PoeMode`, `PortSpeedSetting`)
8//! at PUT time.
9
10use serde::{Deserialize, Serialize};
11
12/// One switch's port configuration described as a single resource.
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct ApplyPortsRequest {
16    /// Per-port overrides to splice into the device's `port_overrides`.
17    /// Ports not listed here keep their existing override unchanged
18    /// (splice semantics — see the from-file plan for details).
19    pub ports: Vec<ApplyPortEntry>,
20}
21
22/// Per-port override for [`ApplyPortsRequest`].
23///
24/// Splice semantics: every field except `index` is optional. Missing fields
25/// leave the existing override value untouched. `tagged_network_ids:
26/// Some([])` clears the tagged list (JSON Merge Patch); `None` leaves it
27/// alone.
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct ApplyPortEntry {
31    /// 1-based port index (required). Matches `port_idx` on the wire.
32    pub index: u32,
33
34    /// User-facing port label.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub name: Option<String>,
37
38    /// Operational mode. Accepts `"access"`, `"trunk"`, or `"mirror"`.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub mode: Option<String>,
41
42    /// Native (untagged) network — accepts a Session `_id` UUID or a
43    /// network name. The handler resolves names against the cached
44    /// network list. Aliased as `native_vlan` for ergonomics.
45    #[serde(
46        default,
47        skip_serializing_if = "Option::is_none",
48        alias = "native_vlan"
49    )]
50    pub native_network_id: Option<String>,
51
52    /// Tagged networks for trunk ports. `Some(vec![])` clears the list;
53    /// `None` leaves the existing list untouched. Each entry is a
54    /// Session `_id` UUID or network name. Aliased as `tagged_vlans`.
55    #[serde(
56        default,
57        skip_serializing_if = "Option::is_none",
58        alias = "tagged_vlans"
59    )]
60    pub tagged_network_ids: Option<Vec<String>>,
61
62    /// Whether this trunk port carries all VLANs (the controller's
63    /// "auto" tagged-VLAN mode). Mutually exclusive with
64    /// `tagged_network_ids` — the handler validates.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub tagged_all: Option<bool>,
67
68    /// PoE mode. Accepts `"on"`, `"off"`, `"auto"`, `"pasv24"`,
69    /// `"passthrough"`. (`"on"` maps to `PoeMode::Auto` — same as the
70    /// `--poe` CLI flag.)
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub poe: Option<String>,
73
74    /// Configured link speed. Accepts `"auto"`, `"10"`, `"100"`,
75    /// `"1000"`, `"2500"`, `"5000"`, `"10000"`.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub speed: Option<String>,
78
79    /// If `true`, drop this port's entry from `port_overrides` entirely
80    /// — returning the port to controller defaults. All other fields on
81    /// this entry are ignored when `reset` is true.
82    #[serde(default, skip_serializing_if = "is_false")]
83    pub reset: bool,
84}
85
86#[allow(clippy::trivially_copy_pass_by_ref)]
87fn is_false(b: &bool) -> bool {
88    !*b
89}
90
91#[cfg(test)]
92mod tests {
93    use super::{ApplyPortEntry, ApplyPortsRequest};
94
95    #[test]
96    fn deserializes_minimal_entry() {
97        let req: ApplyPortsRequest = serde_json::from_value(serde_json::json!({
98            "ports": [
99                { "index": 9, "name": "mac-mini" }
100            ]
101        }))
102        .expect("deserialize");
103        assert_eq!(req.ports.len(), 1);
104        assert_eq!(req.ports[0].index, 9);
105        assert_eq!(req.ports[0].name.as_deref(), Some("mac-mini"));
106        assert!(req.ports[0].mode.is_none());
107        assert!(!req.ports[0].reset);
108    }
109
110    #[test]
111    fn deserializes_full_entry_via_aliases() {
112        let req: ApplyPortsRequest = serde_json::from_value(serde_json::json!({
113            "ports": [
114                {
115                    "index": 1,
116                    "name": "uplink",
117                    "mode": "trunk",
118                    "native_vlan": "infra",
119                    "tagged_vlans": ["personal", "iot"],
120                    "tagged_all": false,
121                    "poe": "auto",
122                    "speed": "auto"
123                }
124            ]
125        }))
126        .expect("deserialize");
127        let p = &req.ports[0];
128        assert_eq!(p.native_network_id.as_deref(), Some("infra"));
129        assert_eq!(
130            p.tagged_network_ids.as_deref(),
131            Some(&["personal".into(), "iot".into()][..])
132        );
133        assert_eq!(p.tagged_all, Some(false));
134    }
135
136    #[test]
137    fn empty_tagged_array_round_trips_as_clear_intent() {
138        // `tagged_network_ids: Some(vec![])` is the explicit "clear" case
139        // (JSON Merge Patch semantics). Distinct from `None`.
140        let req: ApplyPortsRequest = serde_json::from_value(serde_json::json!({
141            "ports": [{ "index": 5, "tagged_vlans": [] }]
142        }))
143        .expect("deserialize");
144        assert_eq!(req.ports[0].tagged_network_ids.as_deref(), Some(&[][..]));
145    }
146
147    #[test]
148    fn reset_entry_serializes_compactly() {
149        let req = ApplyPortsRequest {
150            ports: vec![ApplyPortEntry {
151                index: 5,
152                name: None,
153                mode: None,
154                native_network_id: None,
155                tagged_network_ids: None,
156                tagged_all: None,
157                poe: None,
158                speed: None,
159                reset: true,
160            }],
161        };
162        let value = serde_json::to_value(&req).expect("serialize");
163        assert_eq!(
164            value,
165            serde_json::json!({ "ports": [{ "index": 5, "reset": true }] })
166        );
167    }
168
169    #[test]
170    fn unknown_field_is_rejected() {
171        let result: Result<ApplyPortsRequest, _> = serde_json::from_value(serde_json::json!({
172            "ports": [{ "index": 1, "vlan": "infra" }]  // typo, not native_vlan
173        }));
174        let err = result.expect_err("expected unknown-field rejection");
175        assert!(err.to_string().contains("unknown field"));
176    }
177}