Skip to main content

ignition_core/client/
sessions.rs

1//! Session-family capability models (02-03, HLTH-08) — designer
2//! sessions, Perspective sessions, and Vision clients, field names
3//! matching the live 8.3.6 captures (02-RESEARCH §Sessions + terminate)
4//! and the gateway's openapi schema.
5//!
6//! Every model carries `#[serde(flatten)] extra` passthrough so `--json`
7//! stays complete as gateway responses evolve.
8//!
9//! Two path subtleties are pinned here (Pitfall 8 + spec):
10//! - the Perspective LIST is `/data/perspective/api/v1/sessions/` WITH a
11//!   trailing slash (the module-scoped prefix differs from core
12//!   `/data/api/v1` — base-URL joining must not collapse it; pinned by
13//!   an exact-path wiremock matcher in tests/sessions_contract.rs);
14//! - the Perspective TERMINATE is a DELETE against the same path WITHOUT
15//!   the trailing slash, carrying `sessionId` (+ optional `message`) as
16//!   QUERY params, never a body.
17//!
18//! All three terminations are audit-logged server-side (official docs) —
19//! nothing to do client-side beyond the call itself.
20
21use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24
25/// GET path of the designer-sessions list.
26pub(crate) const DESIGNERS_PATH: &str = "/data/api/v1/designers";
27
28/// GET path of the Perspective sessions list — the EXACT trailing slash
29/// is part of the contract (spec; Pitfall 8).
30pub(crate) const PERSPECTIVE_SESSIONS_LIST_PATH: &str = "/data/perspective/api/v1/sessions/";
31
32/// DELETE path of the Perspective terminate route — NO trailing slash
33/// (spec), with `sessionId` (+ optional `message`) as query params.
34pub(crate) const PERSPECTIVE_SESSIONS_TERMINATE_PATH: &str = "/data/perspective/api/v1/sessions";
35
36/// GET path of the Vision clients list.
37pub(crate) const VISION_CLIENTS_PATH: &str = "/data/vision/api/v1/clients";
38
39/// DELETE path of the designer prune route (singular `designer`).
40pub(crate) fn designer_prune_path(id: &str) -> String {
41    format!("/data/api/v1/designer/{id}")
42}
43
44/// DELETE path of the Vision client terminate route (singular `client`).
45pub(crate) fn vision_client_terminate_path(id: &str) -> String {
46    format!("/data/vision/api/v1/client/{id}")
47}
48
49/// One item of `GET /data/api/v1/designers` (openapi: `id`, `user`,
50/// `uptime`, `lastcomm`, `timeout`, `memory` (an OBJECT — heap
51/// breakdown), `project`, `address`, `timezone`).
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct DesignerInfo {
54    /// Session id.
55    pub id: String,
56    /// Remote designer address, e.g. `"192.168.1.50:52526"`.
57    #[serde(default)]
58    pub address: String,
59    /// Authenticated designer user.
60    #[serde(default)]
61    pub user: String,
62    /// Open project name.
63    #[serde(default)]
64    pub project: String,
65    /// Heap breakdown as the gateway reports it (openapi: object) —
66    /// raw passthrough, unit semantics are the gateway's.
67    #[serde(default)]
68    pub memory: serde_json::Value,
69    /// Session uptime in epoch **MILLISECONDS** (same scale as
70    /// `overview.uptime`).
71    #[serde(default)]
72    pub uptime: i64,
73    /// Last communication timestamp, epoch **MILLISECONDS**.
74    #[serde(default)]
75    pub lastcomm: i64,
76    /// Session timeout, epoch **MILLISECONDS**.
77    #[serde(default)]
78    pub timeout: i64,
79    /// Designer-side timezone id.
80    #[serde(default)]
81    pub timezone: String,
82    /// Unknown keys round-trip (passthrough-shaped `--json`).
83    #[serde(flatten)]
84    pub extra: BTreeMap<String, serde_json::Value>,
85}
86
87/// One item of `GET /data/perspective/api/v1/sessions/` (openapi:
88/// camelCase keys — serde-renamed; `sessionScope`, `pageIds`,
89/// `recentBytesSent`, `totalBytesSent` ride the passthrough).
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct PerspectiveSession {
92    /// Session id (the `sessionId` the terminate route takes).
93    pub id: String,
94    /// Authenticated Perspective user (`"anonymous"` when public).
95    #[serde(default)]
96    pub username: String,
97    /// Whether the session passed login.
98    #[serde(default)]
99    pub authorized: bool,
100    /// Open project name.
101    #[serde(default)]
102    pub project: String,
103    /// Browser address (`clientAddress` on the wire).
104    #[serde(
105        rename = "clientAddress",
106        alias = "client_address",
107        default,
108        skip_serializing_if = "String::is_empty"
109    )]
110    pub client_address: String,
111    /// Last communication timestamp, epoch **MILLISECONDS**
112    /// (`lastComm` on the wire).
113    #[serde(rename = "lastComm", alias = "last_comm", default)]
114    pub last_comm: i64,
115    /// Number of pages the session holds open (`activePages`).
116    #[serde(rename = "activePages", alias = "active_pages", default)]
117    pub active_pages: i64,
118    /// Browser user agent (`userAgent`).
119    #[serde(
120        rename = "userAgent",
121        alias = "user_agent",
122        default,
123        skip_serializing_if = "String::is_empty"
124    )]
125    pub user_agent: String,
126    /// `sessionScope`, `pageIds`, `recentBytesSent`, `totalBytesSent`,
127    /// … round-trip.
128    #[serde(flatten)]
129    pub extra: BTreeMap<String, serde_json::Value>,
130}
131
132/// One item of `GET /data/vision/api/v1/clients` — the same shape as
133/// [`DesignerInfo`] plus `tagCount` (openapi).
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct VisionClient {
136    /// Client id.
137    pub id: String,
138    /// Remote client address.
139    #[serde(default)]
140    pub address: String,
141    /// Authenticated client user.
142    #[serde(default)]
143    pub user: String,
144    /// Open project name.
145    #[serde(default)]
146    pub project: String,
147    /// Heap breakdown as the gateway reports it (openapi: object).
148    #[serde(default)]
149    pub memory: serde_json::Value,
150    /// Client uptime in epoch **MILLISECONDS**.
151    #[serde(default)]
152    pub uptime: i64,
153    /// Last communication timestamp, epoch **MILLISECONDS**.
154    #[serde(default)]
155    pub lastcomm: i64,
156    /// Client timeout, epoch **MILLISECONDS**.
157    #[serde(default)]
158    pub timeout: i64,
159    /// Client-side timezone id.
160    #[serde(default)]
161    pub timezone: String,
162    /// Subscribed tag count (`tagCount` on the wire).
163    #[serde(rename = "tagCount", alias = "tag_count", default)]
164    pub tag_count: i64,
165    /// Unknown keys round-trip.
166    #[serde(flatten)]
167    pub extra: BTreeMap<String, serde_json::Value>,
168}
169
170#[cfg(test)]
171mod tests {
172    use super::{DesignerInfo, PerspectiveSession, VisionClient};
173
174    /// The openapi item shapes parse: designers carry an OBJECT `memory`
175    /// and ms-scale numerics; unknown keys round-trip.
176    #[test]
177    fn designer_info_parses_the_openapi_shape() {
178        let designer: DesignerInfo = serde_json::from_value(serde_json::json!({
179            "id": "d-1",
180            "user": "admin",
181            "uptime": 600000,
182            "lastcomm": 1787346747022i64,
183            "timeout": 3600000,
184            "memory": {"used": 268435456i64, "max": 1073741824i64},
185            "project": "MyProject",
186            "address": "192.168.1.50:52526",
187            "timezone": "America/New_York"
188        }))
189        .expect("openapi designer shape must parse");
190        assert_eq!(designer.id, "d-1");
191        assert_eq!(designer.user, "admin");
192        assert_eq!(designer.uptime, 600000, "epoch ms");
193        assert_eq!(
194            designer.memory["used"], 268435456i64,
195            "memory is an OBJECT (openapi) — passthrough"
196        );
197
198        // Wire-faithful round-trip: keys unchanged, extras preserved.
199        let round = serde_json::to_value(&designer).expect("serialize");
200        assert_eq!(round["memory"]["max"], 1073741824i64);
201        assert_eq!(round["timezone"], "America/New_York");
202    }
203
204    /// Perspective items parse with the camelCase renames; the not-modeled
205    /// known keys (`sessionScope`, `pageIds`, byte counters) ride extras.
206    #[test]
207    fn perspective_session_parses_the_camel_case_shape() {
208        let session: PerspectiveSession = serde_json::from_value(serde_json::json!({
209            "id": "psess-1",
210            "username": "admin",
211            "authorized": true,
212            "project": "MyProject",
213            "clientAddress": "10.0.0.5",
214            "lastComm": 1787346747022i64,
215            "sessionScope": "G",
216            "activePages": 2,
217            "pageIds": ["viewA", "viewB"],
218            "recentBytesSent": 1024,
219            "totalBytesSent": 4096,
220            "userAgent": "Mozilla/5.0"
221        }))
222        .expect("perspective capture shape must parse");
223        assert_eq!(session.id, "psess-1");
224        assert_eq!(session.client_address, "10.0.0.5", "clientAddress rename");
225        assert_eq!(session.last_comm, 1787346747022, "lastComm rename");
226        assert_eq!(session.active_pages, 2, "activePages rename");
227        assert_eq!(session.user_agent, "Mozilla/5.0", "userAgent rename");
228        assert_eq!(
229            session.extra.get("sessionScope"),
230            Some(&serde_json::json!("G")),
231            "unmodeled known keys round-trip"
232        );
233
234        let round = serde_json::to_value(&session).expect("serialize");
235        assert_eq!(round["clientAddress"], "10.0.0.5");
236        assert_eq!(round["userAgent"], "Mozilla/5.0");
237    }
238
239    /// Vision clients = designer shape + `tagCount`.
240    #[test]
241    fn vision_client_parses_designer_shape_plus_tag_count() {
242        let client: VisionClient = serde_json::from_value(serde_json::json!({
243            "id": "v-1",
244            "user": "operator",
245            "uptime": 120000,
246            "lastcomm": 1787346747022i64,
247            "timeout": 3600000,
248            "memory": {"used": 134217728i64, "max": 536870912i64},
249            "project": "PlantFloor",
250            "address": "10.0.0.9:443",
251            "timezone": "UTC",
252            "tagCount": 1523
253        }))
254        .expect("vision capture shape must parse");
255        assert_eq!(client.id, "v-1");
256        assert_eq!(client.tag_count, 1523, "tagCount rename");
257
258        let round = serde_json::to_value(&client).expect("serialize");
259        assert_eq!(round["tagCount"], 1523, "gateway-native key on the way out");
260    }
261}