Skip to main content

offline_protocol_core/
service.rs

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