Skip to main content

uptrakit_web_api_types/
services.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5use crate::validation::{Validate, ValidationError};
6
7// Canonical types from shared-types with feature-gated OpenAPI derives.
8pub use uptrakit_shared_types::{ParseServiceStatusError, ServiceStatus};
9
10/// Unified response for any service (agent or MQTT).
11#[derive(Debug, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13pub struct ServiceResponse {
14    pub id: Uuid,
15    pub capabilities: Vec<String>,
16    pub service_label: String,
17    pub hostname: String,
18    pub friendly_name: String,
19    pub is_embedded: bool,
20    pub ip_address: Option<String>,
21    pub status: ServiceStatus,
22    pub client_version: Option<String>,
23    #[serde(with = "time::serde::rfc3339::option")]
24    #[cfg_attr(
25        feature = "openapi",
26        schema(value_type = Option<String>, format = DateTime)
27    )]
28    pub last_seen_at: Option<OffsetDateTime>,
29    #[serde(with = "time::serde::rfc3339")]
30    #[cfg_attr(
31        feature = "openapi",
32        schema(value_type = String, format = DateTime)
33    )]
34    pub created_at: OffsetDateTime,
35    #[serde(with = "time::serde::rfc3339")]
36    #[cfg_attr(
37        feature = "openapi",
38        schema(value_type = String, format = DateTime)
39    )]
40    pub updated_at: OffsetDateTime,
41    /// Custom ping interval override in seconds. `None` means the
42    /// service-profile default is used.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub ping_interval_seconds: Option<u32>,
45    /// Per-service certificate lifetime override in hours. `None` means the
46    /// global default is used.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub cert_lifetime_hours: Option<u32>,
49    /// External service IDs currently causing this embedded service to yield.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub yielded_to: Option<Vec<Uuid>>,
52}
53
54/// Query parameters for listing services.
55#[derive(Serialize, Deserialize)]
56#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
57pub struct ListServicesQuery {
58    /// Filter by capability.
59    pub capability: Option<String>,
60    /// Filter by status: `pending`, `approved`, `rejected`, `deactivated`.
61    pub status: Option<ServiceStatus>,
62    /// Page number (1-indexed). Defaults to 1.
63    pub page: Option<u64>,
64    /// Items per page. Defaults to 20, max 1000.
65    pub per_page: Option<u64>,
66}
67
68impl ListServicesQuery {
69    pub fn pagination(&self) -> crate::pagination::PaginationParams {
70        crate::pagination::PaginationParams {
71            page: self.page,
72            per_page: self.per_page,
73        }
74    }
75}
76
77/// Request to update a service's configurable settings.
78#[derive(Serialize, Deserialize)]
79#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
80pub struct UpdateServiceRequest {
81    /// Custom ping interval in seconds.
82    /// Omit to keep current value. Set to `0` to clear the override and
83    /// revert to the service-type default. Set to a positive value to
84    /// override the default.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub ping_interval_seconds: Option<u32>,
87    /// Per-service certificate lifetime in hours.
88    /// Omit to keep current value. Set to `0` to clear the override and revert
89    /// to the global default. Set to a positive value (1–17520) to override.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub cert_lifetime_hours: Option<u32>,
92}
93
94impl Validate for UpdateServiceRequest {
95    fn validate(&self) -> Result<(), ValidationError> {
96        // 0 is a sentinel meaning "clear the override"; any positive value
97        // must be at least 5 seconds to avoid excessive polling.
98        if let Some(interval) = self.ping_interval_seconds
99            && interval != 0
100            && interval < 5
101        {
102            return Err(ValidationError {
103                field: "ping_interval_seconds",
104                message: "ping_interval_seconds must be 0 (to clear) or at least 5".to_string(),
105            });
106        }
107        if let Some(hours) = self.cert_lifetime_hours
108            && hours != 0
109            && !(1..=17_520u32).contains(&hours)
110        {
111            return Err(ValidationError {
112                field: "cert_lifetime_hours",
113                message: "cert_lifetime_hours must be 0 (to clear) or between 1 and 17520"
114                    .to_string(),
115            });
116        }
117        Ok(())
118    }
119}
120
121/// Request to enable or disable the update freeze on a connected service.
122#[derive(Serialize, Deserialize)]
123#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
124pub struct SetUpdateFreezeRequest {
125    /// Whether to enable (`true`) or disable (`false`) the update freeze.
126    pub enabled: bool,
127    /// Optional human-readable reason for the freeze.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub reason: Option<String>,
130}
131
132impl Validate for SetUpdateFreezeRequest {
133    fn validate(&self) -> Result<(), ValidationError> {
134        if let Some(ref reason) = self.reason
135            && reason.len() > 1024
136        {
137            return Err(ValidationError {
138                field: "reason",
139                message: "reason must be at most 1024 characters".to_string(),
140            });
141        }
142        Ok(())
143    }
144}
145
146// Re-export generic types that are shared across service operations.
147pub use super::agents::{MergeAgentRequest, MessageResponse};
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use time::macros::datetime;
153
154    fn sample_uuid() -> Uuid {
155        Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
156            .expect("hard-coded UUID should be valid")
157    }
158
159    // ── ServiceResponse ──────────────────────────────────────────────
160
161    #[test]
162    fn service_response_round_trip_all_fields() {
163        let resp = ServiceResponse {
164            id: sample_uuid(),
165            capabilities: vec![
166                "software_discovery".into(),
167                "update_hooks".into(),
168                "graceful_shutdown".into(),
169            ],
170            service_label: "Agent".into(),
171            hostname: "host-1.local".to_string(),
172            friendly_name: "My Agent".to_string(),
173            is_embedded: false,
174            ip_address: Some("10.0.0.1".to_string()),
175            status: ServiceStatus::Approved,
176            client_version: Some("1.2.3".to_string()),
177            last_seen_at: Some(datetime!(2025-06-01 12:00:00 UTC)),
178            created_at: datetime!(2025-01-01 0:00:00 UTC),
179            updated_at: datetime!(2025-06-01 12:00:00 UTC),
180            ping_interval_seconds: Some(60),
181            cert_lifetime_hours: None,
182            yielded_to: None,
183        };
184        let json = serde_json::to_string(&resp).expect("serialization should succeed");
185        let deserialized: ServiceResponse =
186            serde_json::from_str(&json).expect("deserialization should succeed");
187        assert_eq!(deserialized.id, sample_uuid());
188        assert_eq!(
189            deserialized.capabilities,
190            vec!["software_discovery", "update_hooks", "graceful_shutdown"]
191        );
192        assert_eq!(deserialized.service_label, "Agent");
193        assert_eq!(deserialized.hostname, "host-1.local");
194        assert_eq!(deserialized.friendly_name, "My Agent");
195        assert!(!deserialized.is_embedded);
196        assert_eq!(deserialized.ip_address.as_deref(), Some("10.0.0.1"));
197        assert_eq!(deserialized.status, ServiceStatus::Approved);
198        assert_eq!(deserialized.client_version.as_deref(), Some("1.2.3"));
199        assert!(deserialized.last_seen_at.is_some());
200        assert_eq!(deserialized.ping_interval_seconds, Some(60));
201    }
202
203    #[test]
204    fn service_response_round_trip_none_fields() {
205        let resp = ServiceResponse {
206            id: sample_uuid(),
207            capabilities: vec!["update_tracking".into(), "graceful_shutdown".into()],
208            service_label: "Update Tracker".into(),
209            hostname: "mqtt-broker".to_string(),
210            friendly_name: "MQTT Service".to_string(),
211            is_embedded: false,
212            ip_address: None,
213            status: ServiceStatus::Pending,
214            client_version: None,
215            last_seen_at: None,
216            created_at: datetime!(2025-01-01 0:00:00 UTC),
217            updated_at: datetime!(2025-01-01 0:00:00 UTC),
218            ping_interval_seconds: None,
219            cert_lifetime_hours: None,
220            yielded_to: None,
221        };
222        let json = serde_json::to_string(&resp).expect("serialization should succeed");
223        let deserialized: ServiceResponse =
224            serde_json::from_str(&json).expect("deserialization should succeed");
225        assert!(deserialized.ip_address.is_none());
226        assert!(deserialized.client_version.is_none());
227        assert!(deserialized.last_seen_at.is_none());
228        assert_eq!(deserialized.status, ServiceStatus::Pending);
229        assert!(deserialized.ping_interval_seconds.is_none());
230    }
231
232    #[test]
233    fn service_response_ssh_agent_type() {
234        let resp = ServiceResponse {
235            id: sample_uuid(),
236            capabilities: vec![
237                "ssh_remote".into(),
238                "software_discovery".into(),
239                "update_hooks".into(),
240                "graceful_shutdown".into(),
241            ],
242            service_label: "SSH Agent".into(),
243            hostname: "ssh-host".to_string(),
244            friendly_name: "SSH Agent".to_string(),
245            is_embedded: false,
246            ip_address: None,
247            status: ServiceStatus::Deactivated,
248            client_version: None,
249            last_seen_at: None,
250            created_at: datetime!(2025-01-01 0:00:00 UTC),
251            updated_at: datetime!(2025-01-01 0:00:00 UTC),
252            ping_interval_seconds: None,
253            cert_lifetime_hours: None,
254            yielded_to: None,
255        };
256        let json_value =
257            serde_json::to_value(&resp).expect("serialization to Value should succeed");
258        assert!(json_value.get("capabilities").is_some());
259        assert_eq!(
260            json_value.get("service_label").and_then(|v| v.as_str()),
261            Some("SSH Agent")
262        );
263        assert_eq!(
264            json_value.get("status").and_then(|v| v.as_str()),
265            Some("deactivated")
266        );
267    }
268
269    // ── ListServicesQuery ────────────────────────────────────────────
270
271    #[test]
272    fn list_services_query_round_trip_all_fields() {
273        let query = ListServicesQuery {
274            capability: Some("software_discovery".into()),
275            status: Some(ServiceStatus::Approved),
276            page: Some(2),
277            per_page: Some(50),
278        };
279        let json = serde_json::to_string(&query).expect("serialization should succeed");
280        let deserialized: ListServicesQuery =
281            serde_json::from_str(&json).expect("deserialization should succeed");
282        assert_eq!(
283            deserialized.capability.as_deref(),
284            Some("software_discovery")
285        );
286        assert_eq!(deserialized.status, Some(ServiceStatus::Approved));
287        assert_eq!(deserialized.page, Some(2));
288        assert_eq!(deserialized.per_page, Some(50));
289    }
290
291    #[test]
292    fn list_services_query_round_trip_none_fields() {
293        let query = ListServicesQuery {
294            capability: None,
295            status: None,
296            page: None,
297            per_page: None,
298        };
299        let json = serde_json::to_string(&query).expect("serialization should succeed");
300        let deserialized: ListServicesQuery =
301            serde_json::from_str(&json).expect("deserialization should succeed");
302        assert!(deserialized.capability.is_none());
303        assert!(deserialized.status.is_none());
304        assert!(deserialized.page.is_none());
305        assert!(deserialized.per_page.is_none());
306    }
307
308    // ── ListServicesQuery::pagination() ──────────────────────────────
309
310    #[test]
311    fn pagination_returns_page_and_per_page() {
312        let query = ListServicesQuery {
313            capability: None,
314            status: None,
315            page: Some(3),
316            per_page: Some(25),
317        };
318        let params = query.pagination();
319        assert_eq!(params.page, Some(3));
320        assert_eq!(params.per_page, Some(25));
321    }
322
323    #[test]
324    fn pagination_returns_none_when_not_set() {
325        let query = ListServicesQuery {
326            capability: None,
327            status: None,
328            page: None,
329            per_page: None,
330        };
331        let params = query.pagination();
332        assert!(params.page.is_none());
333        assert!(params.per_page.is_none());
334    }
335
336    #[test]
337    fn pagination_resolve_applies_defaults() {
338        let query = ListServicesQuery {
339            capability: None,
340            status: None,
341            page: None,
342            per_page: None,
343        };
344        let resolved = query.pagination().resolve();
345        assert_eq!(resolved.page, 1);
346        assert_eq!(resolved.per_page, crate::pagination::DEFAULT_PER_PAGE);
347    }
348
349    // ── UpdateServiceRequest ─────────────────────────────────────────
350
351    #[test]
352    fn update_service_request_with_ping_interval() {
353        let req = UpdateServiceRequest {
354            ping_interval_seconds: Some(60),
355            cert_lifetime_hours: None,
356        };
357        let json = serde_json::to_string(&req).expect("serialization should succeed");
358        assert!(json.contains(r#""ping_interval_seconds":60"#));
359        let parsed: UpdateServiceRequest =
360            serde_json::from_str(&json).expect("deserialization should succeed");
361        assert_eq!(parsed.ping_interval_seconds, Some(60));
362    }
363
364    #[test]
365    fn update_service_request_without_ping_interval() {
366        let req = UpdateServiceRequest {
367            ping_interval_seconds: None,
368            cert_lifetime_hours: None,
369        };
370        let json = serde_json::to_string(&req).expect("serialization should succeed");
371        assert!(!json.contains("ping_interval_seconds"));
372    }
373
374    #[test]
375    fn update_service_request_clear_with_zero() {
376        let json = r#"{"ping_interval_seconds":0}"#;
377        let parsed: UpdateServiceRequest =
378            serde_json::from_str(json).expect("deserialization should succeed");
379        assert_eq!(parsed.ping_interval_seconds, Some(0));
380    }
381
382    // ── Validate ─────────────────────────────────────────────────────
383
384    #[test]
385    fn validate_accepts_none_interval() {
386        let req = UpdateServiceRequest {
387            ping_interval_seconds: None,
388            cert_lifetime_hours: None,
389        };
390        assert!(req.validate().is_ok());
391    }
392
393    #[test]
394    fn validate_accepts_zero_interval_as_clear_sentinel() {
395        let req = UpdateServiceRequest {
396            ping_interval_seconds: Some(0),
397            cert_lifetime_hours: None,
398        };
399        assert!(req.validate().is_ok());
400    }
401
402    #[test]
403    fn validate_accepts_interval_of_five_or_more() {
404        for v in [5u32, 10, 60, 3600] {
405            let req = UpdateServiceRequest {
406                ping_interval_seconds: Some(v),
407                cert_lifetime_hours: None,
408            };
409            assert!(req.validate().is_ok(), "expected ok for {v}");
410        }
411    }
412
413    #[test]
414    fn validate_rejects_interval_below_five() {
415        for v in [1u32, 2, 3, 4] {
416            let req = UpdateServiceRequest {
417                ping_interval_seconds: Some(v),
418                cert_lifetime_hours: None,
419            };
420            let err = req.validate().unwrap_err();
421            assert_eq!(err.field, "ping_interval_seconds", "field mismatch for {v}");
422        }
423    }
424
425    // ── cert_lifetime_hours ───────────────────────────────────────────
426
427    #[test]
428    fn service_response_includes_cert_lifetime_hours() {
429        let resp = ServiceResponse {
430            id: sample_uuid(),
431            capabilities: vec!["graceful_shutdown".into()],
432            service_label: "Agent".into(),
433            hostname: "host".to_string(),
434            friendly_name: "H".to_string(),
435            is_embedded: true,
436            ip_address: None,
437            status: ServiceStatus::Approved,
438            client_version: None,
439            last_seen_at: None,
440            created_at: datetime!(2025-01-01 0:00:00 UTC),
441            updated_at: datetime!(2025-01-01 0:00:00 UTC),
442            ping_interval_seconds: None,
443            cert_lifetime_hours: Some(48),
444            yielded_to: Some(vec![sample_uuid()]),
445        };
446        let json = serde_json::to_string(&resp).expect("serialization should succeed");
447        assert!(json.contains(r#""cert_lifetime_hours":48"#));
448        let de: ServiceResponse =
449            serde_json::from_str(&json).expect("deserialization should succeed");
450        assert!(de.is_embedded);
451        assert_eq!(de.cert_lifetime_hours, Some(48));
452        assert_eq!(de.yielded_to, Some(vec![sample_uuid()]));
453    }
454
455    #[test]
456    fn service_response_omits_cert_lifetime_hours_when_none() {
457        let resp = ServiceResponse {
458            id: sample_uuid(),
459            capabilities: vec!["graceful_shutdown".into()],
460            service_label: "Agent".into(),
461            hostname: "host".to_string(),
462            friendly_name: "H".to_string(),
463            is_embedded: false,
464            ip_address: None,
465            status: ServiceStatus::Approved,
466            client_version: None,
467            last_seen_at: None,
468            created_at: datetime!(2025-01-01 0:00:00 UTC),
469            updated_at: datetime!(2025-01-01 0:00:00 UTC),
470            ping_interval_seconds: None,
471            cert_lifetime_hours: None,
472            yielded_to: None,
473        };
474        let json = serde_json::to_string(&resp).expect("serialization should succeed");
475        assert!(!json.contains("cert_lifetime_hours"));
476        assert!(!json.contains("yielded_to"));
477    }
478
479    #[test]
480    fn update_service_request_with_cert_lifetime_hours() {
481        let req = UpdateServiceRequest {
482            ping_interval_seconds: None,
483            cert_lifetime_hours: Some(48),
484        };
485        let json = serde_json::to_string(&req).expect("serialization should succeed");
486        assert!(json.contains(r#""cert_lifetime_hours":48"#));
487        let parsed: UpdateServiceRequest =
488            serde_json::from_str(&json).expect("deserialization should succeed");
489        assert_eq!(parsed.cert_lifetime_hours, Some(48));
490    }
491
492    #[test]
493    fn update_service_request_clear_cert_lifetime_with_zero() {
494        let json = r#"{"cert_lifetime_hours":0}"#;
495        let parsed: UpdateServiceRequest =
496            serde_json::from_str(json).expect("deserialization should succeed");
497        assert_eq!(parsed.cert_lifetime_hours, Some(0));
498        assert!(parsed.validate().is_ok());
499    }
500
501    #[test]
502    fn validate_accepts_cert_lifetime_hours_in_range() {
503        for v in [1u32, 12, 48, 168, 17_520] {
504            let req = UpdateServiceRequest {
505                ping_interval_seconds: None,
506                cert_lifetime_hours: Some(v),
507            };
508            assert!(req.validate().is_ok(), "expected ok for {v}");
509        }
510    }
511
512    #[test]
513    fn validate_rejects_cert_lifetime_hours_above_max() {
514        let req = UpdateServiceRequest {
515            ping_interval_seconds: None,
516            cert_lifetime_hours: Some(17_521),
517        };
518        let err = req.validate().unwrap_err();
519        assert_eq!(err.field, "cert_lifetime_hours");
520    }
521}