Skip to main content

offline_protocol_core/
service.rs

1//! Service discovery types for the Offline Protocol SDK.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use crate::error::Error;
7
8/// Maximum length for a service ID.
9pub const MAX_SERVICE_ID_LEN: usize = 256;
10
11/// Unique identifier for a service.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
13pub struct ServiceId(String);
14
15impl TryFrom<String> for ServiceId {
16    type Error = Error;
17
18    fn try_from(s: String) -> std::result::Result<Self, Self::Error> {
19        ServiceId::new(s)
20    }
21}
22
23impl<'de> Deserialize<'de> for ServiceId {
24    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
25    where
26        D: serde::Deserializer<'de>,
27    {
28        let s = String::deserialize(deserializer)?;
29        ServiceId::new(s).map_err(serde::de::Error::custom)
30    }
31}
32
33impl ServiceId {
34    /// Creates a new ServiceId with validation.
35    ///
36    /// Rejects empty strings, whitespace-only strings, strings exceeding
37    /// [`MAX_SERVICE_ID_LEN`], and strings starting with `__` (reserved for
38    /// internal protocol message prefixes).
39    pub fn new(id: impl Into<String>) -> Result<Self, Error> {
40        let id = id.into();
41        if id.is_empty() {
42            return Err(Error::InvalidServiceId("service ID cannot be empty".into()));
43        }
44        if id.trim().is_empty() {
45            return Err(Error::InvalidServiceId(
46                "service ID cannot be whitespace-only".into(),
47            ));
48        }
49        if id.len() > MAX_SERVICE_ID_LEN {
50            return Err(Error::InvalidServiceId(format!(
51                "service ID exceeds maximum length of {MAX_SERVICE_ID_LEN}"
52            )));
53        }
54        if id.starts_with("__") {
55            return Err(Error::InvalidServiceId(
56                "service ID cannot start with '__' (reserved for protocol internals)".into(),
57            ));
58        }
59        Ok(Self(id))
60    }
61
62    /// Returns the service ID as a string slice.
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66}
67
68impl std::fmt::Display for ServiceId {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        write!(f, "{}", self.0)
71    }
72}
73
74/// Describes a service that a node offers.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ServiceDescriptor {
77    /// The service identifier.
78    pub service_id: ServiceId,
79    /// Version of the service (e.g. "1.0").
80    pub version: String,
81    /// Arbitrary key-value capabilities advertised by this service.
82    pub capabilities: HashMap<String, String>,
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_service_id_valid() {
91        let id = ServiceId::new("my.service").unwrap();
92        assert_eq!(id.as_str(), "my.service");
93        assert_eq!(id.to_string(), "my.service");
94    }
95
96    #[test]
97    fn test_service_id_empty_rejected() {
98        let err = ServiceId::new("").unwrap_err();
99        assert!(matches!(err, Error::InvalidServiceId(_)));
100    }
101
102    #[test]
103    fn test_service_id_whitespace_only_rejected() {
104        let err = ServiceId::new("   ").unwrap_err();
105        assert!(matches!(err, Error::InvalidServiceId(_)));
106        let err = ServiceId::new("\t\n").unwrap_err();
107        assert!(matches!(err, Error::InvalidServiceId(_)));
108    }
109
110    #[test]
111    fn test_service_id_too_long_rejected() {
112        let long_id = "a".repeat(MAX_SERVICE_ID_LEN + 1);
113        let err = ServiceId::new(long_id).unwrap_err();
114        assert!(matches!(err, Error::InvalidServiceId(_)));
115    }
116
117    #[test]
118    fn test_service_id_max_length_accepted() {
119        let max_id = "a".repeat(MAX_SERVICE_ID_LEN);
120        let id = ServiceId::new(max_id).unwrap();
121        assert_eq!(id.as_str().len(), MAX_SERVICE_ID_LEN);
122    }
123
124    #[test]
125    fn test_service_id_reserved_prefix_rejected() {
126        let err = ServiceId::new("__SVC_DISC_Q__").unwrap_err();
127        assert!(matches!(err, Error::InvalidServiceId(_)));
128        let err = ServiceId::new("__internal").unwrap_err();
129        assert!(matches!(err, Error::InvalidServiceId(_)));
130    }
131
132    #[test]
133    fn test_service_id_single_underscore_accepted() {
134        let id = ServiceId::new("_my_service").unwrap();
135        assert_eq!(id.as_str(), "_my_service");
136    }
137
138    #[test]
139    fn test_service_id_serde_roundtrip() {
140        let id = ServiceId::new("weather.v1").unwrap();
141        let json = serde_json::to_string(&id).unwrap();
142        let parsed: ServiceId = serde_json::from_str(&json).unwrap();
143        assert_eq!(id, parsed);
144    }
145
146    #[test]
147    fn test_service_id_empty_deserialization_rejected() {
148        let result: std::result::Result<ServiceId, _> = serde_json::from_str("\"\"");
149        assert!(result.is_err());
150    }
151
152    #[test]
153    fn test_service_descriptor_serde_roundtrip() {
154        let mut caps = HashMap::new();
155        caps.insert("format".to_string(), "json".to_string());
156        let desc = ServiceDescriptor {
157            service_id: ServiceId::new("echo").unwrap(),
158            version: "1.0".to_string(),
159            capabilities: caps,
160        };
161        let json = serde_json::to_string(&desc).unwrap();
162        let parsed: ServiceDescriptor = serde_json::from_str(&json).unwrap();
163        assert_eq!(parsed.service_id.as_str(), "echo");
164        assert_eq!(parsed.version, "1.0");
165        assert_eq!(parsed.capabilities.get("format").unwrap(), "json");
166    }
167}