turbomcp_protocol/types/
domain.rs

1//! Validated domain types
2//!
3//! This module provides newtype wrappers around string types to add
4//! compile-time type safety and runtime validation for domain-specific
5//! values like URIs, MIME types, and Base64 strings.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// A validated URI string
11///
12/// URIs must follow the format: `scheme:path` where scheme contains only
13/// alphanumeric characters, plus, period, or hyphen.
14///
15/// # Examples
16///
17/// ```
18/// use turbomcp_protocol::types::domain::Uri;
19///
20/// // Valid URIs
21/// let uri1 = Uri::new("file:///path/to/file.txt").unwrap();
22/// let uri2 = Uri::new("https://example.com").unwrap();
23/// let uri3 = Uri::new("resource://my-resource").unwrap();
24///
25/// // Invalid URI (no scheme)
26/// assert!(Uri::new("not-a-uri").is_err());
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(transparent)]
30pub struct Uri(String);
31
32impl Uri {
33    /// Create a new URI with validation
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if the URI format is invalid (missing scheme separator ':')
38    pub fn new<S: Into<String>>(uri: S) -> Result<Self, UriError> {
39        let uri_string = uri.into();
40
41        // Validate URI format: must have scheme:path structure
42        if !uri_string.contains(':') {
43            return Err(UriError::MissingScheme(uri_string));
44        }
45
46        // Extract scheme and validate it contains only valid characters
47        if let Some(scheme_end) = uri_string.find(':') {
48            let scheme = &uri_string[..scheme_end];
49
50            if scheme.is_empty() {
51                return Err(UriError::EmptyScheme(uri_string));
52            }
53
54            // Scheme must start with letter and contain only alphanumeric, +, ., -
55            if !scheme
56                .chars()
57                .next()
58                .is_some_and(|c| c.is_ascii_alphabetic())
59            {
60                return Err(UriError::InvalidScheme(uri_string));
61            }
62
63            if !scheme
64                .chars()
65                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
66            {
67                return Err(UriError::InvalidScheme(uri_string));
68            }
69        }
70
71        Ok(Self(uri_string))
72    }
73
74    /// Create a URI without validation (use with caution)
75    ///
76    /// This should only be used when you're certain the URI is valid,
77    /// such as when deserializing from a trusted source.
78    #[must_use]
79    pub fn new_unchecked<S: Into<String>>(uri: S) -> Self {
80        Self(uri.into())
81    }
82
83    /// Get the URI as a string slice
84    #[must_use]
85    pub fn as_str(&self) -> &str {
86        &self.0
87    }
88
89    /// Get the scheme portion of the URI
90    ///
91    /// # Examples
92    ///
93    /// ```
94    /// use turbomcp_protocol::types::domain::Uri;
95    ///
96    /// let uri = Uri::new("https://example.com").unwrap();
97    /// assert_eq!(uri.scheme(), Some("https"));
98    /// ```
99    #[must_use]
100    pub fn scheme(&self) -> Option<&str> {
101        self.0.split(':').next()
102    }
103
104    /// Convert into the inner String
105    #[must_use]
106    pub fn into_inner(self) -> String {
107        self.0
108    }
109}
110
111impl fmt::Display for Uri {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        write!(f, "{}", self.0)
114    }
115}
116
117impl AsRef<str> for Uri {
118    fn as_ref(&self) -> &str {
119        &self.0
120    }
121}
122
123impl From<Uri> for String {
124    fn from(uri: Uri) -> Self {
125        uri.0
126    }
127}
128
129/// MIME type string
130///
131/// Represents a media type in the format `type/subtype` with optional parameters.
132///
133/// # Examples
134///
135/// ```
136/// use turbomcp_protocol::types::domain::MimeType;
137///
138/// let mime = MimeType::new("text/plain").unwrap();
139/// assert_eq!(mime.type_part(), Some("text"));
140/// assert_eq!(mime.subtype(), Some("plain"));
141///
142/// let mime_with_params = MimeType::new("text/html; charset=utf-8").unwrap();
143/// ```
144#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
145#[serde(transparent)]
146pub struct MimeType(String);
147
148impl MimeType {
149    /// Create a new MIME type with validation
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the MIME type format is invalid
154    pub fn new<S: Into<String>>(mime: S) -> Result<Self, MimeTypeError> {
155        let mime_string = mime.into();
156
157        // Basic validation: must contain '/' separator
158        if !mime_string.contains('/') {
159            return Err(MimeTypeError::InvalidFormat(mime_string));
160        }
161
162        // Extract type and subtype (before any parameters)
163        let main_part = mime_string.split(';').next().unwrap_or(&mime_string);
164        let parts: Vec<&str> = main_part.split('/').collect();
165
166        if parts.len() != 2 {
167            return Err(MimeTypeError::InvalidFormat(mime_string));
168        }
169
170        let type_part = parts[0].trim();
171        let subtype = parts[1].trim();
172
173        if type_part.is_empty() {
174            return Err(MimeTypeError::EmptyType(mime_string));
175        }
176
177        if subtype.is_empty() {
178            return Err(MimeTypeError::EmptySubtype(mime_string));
179        }
180
181        Ok(Self(mime_string))
182    }
183
184    /// Create a MIME type without validation
185    #[must_use]
186    pub fn new_unchecked<S: Into<String>>(mime: S) -> Self {
187        Self(mime.into())
188    }
189
190    /// Get the MIME type as a string slice
191    #[must_use]
192    pub fn as_str(&self) -> &str {
193        &self.0
194    }
195
196    /// Get the type part (before '/')
197    #[must_use]
198    pub fn type_part(&self) -> Option<&str> {
199        self.0
200            .split('/')
201            .next()
202            .map(|s| s.split(';').next().unwrap_or(s).trim())
203    }
204
205    /// Get the subtype part (after '/', before parameters)
206    #[must_use]
207    pub fn subtype(&self) -> Option<&str> {
208        self.0
209            .split('/')
210            .nth(1)
211            .map(|s| s.split(';').next().unwrap_or(s).trim())
212    }
213
214    /// Convert into the inner String
215    #[must_use]
216    pub fn into_inner(self) -> String {
217        self.0
218    }
219}
220
221impl fmt::Display for MimeType {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        write!(f, "{}", self.0)
224    }
225}
226
227impl AsRef<str> for MimeType {
228    fn as_ref(&self) -> &str {
229        &self.0
230    }
231}
232
233impl From<MimeType> for String {
234    fn from(mime: MimeType) -> Self {
235        mime.0
236    }
237}
238
239/// Base64-encoded string
240///
241/// Represents a Base64-encoded binary data string.
242///
243/// # Examples
244///
245/// ```
246/// use turbomcp_protocol::types::domain::Base64String;
247///
248/// let b64 = Base64String::new("SGVsbG8gV29ybGQh").unwrap();
249/// assert_eq!(b64.as_str(), "SGVsbG8gV29ybGQh");
250///
251/// // Invalid Base64 (contains invalid characters)
252/// assert!(Base64String::new("not valid!@#").is_err());
253/// ```
254#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
255#[serde(transparent)]
256pub struct Base64String(String);
257
258impl Base64String {
259    /// Create a new Base64 string with validation
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if the string contains invalid Base64 characters
264    pub fn new<S: Into<String>>(data: S) -> Result<Self, Base64Error> {
265        let data_string = data.into();
266
267        // Validate Base64 characters: A-Z, a-z, 0-9, +, /, =
268        if !data_string
269            .chars()
270            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '='))
271        {
272            return Err(Base64Error::InvalidCharacters(data_string));
273        }
274
275        // Check padding is only at the end
276        if let Some(first_pad) = data_string.find('=')
277            && !data_string[first_pad..].chars().all(|c| c == '=')
278        {
279            return Err(Base64Error::InvalidPadding(data_string));
280        }
281
282        Ok(Self(data_string))
283    }
284
285    /// Create a Base64 string without validation
286    #[must_use]
287    pub fn new_unchecked<S: Into<String>>(data: S) -> Self {
288        Self(data.into())
289    }
290
291    /// Get the Base64 string as a string slice
292    #[must_use]
293    pub fn as_str(&self) -> &str {
294        &self.0
295    }
296
297    /// Convert into the inner String
298    #[must_use]
299    pub fn into_inner(self) -> String {
300        self.0
301    }
302}
303
304impl fmt::Display for Base64String {
305    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306        write!(f, "{}", self.0)
307    }
308}
309
310impl AsRef<str> for Base64String {
311    fn as_ref(&self) -> &str {
312        &self.0
313    }
314}
315
316impl From<Base64String> for String {
317    fn from(b64: Base64String) -> Self {
318        b64.0
319    }
320}
321
322/// Error type for URI validation
323#[derive(Debug, Clone, thiserror::Error)]
324pub enum UriError {
325    /// URI is missing a scheme separator (':')
326    #[error("URI missing scheme separator: {0}")]
327    MissingScheme(String),
328
329    /// URI has an empty scheme
330    #[error("URI has empty scheme: {0}")]
331    EmptyScheme(String),
332
333    /// URI has an invalid scheme format
334    #[error(
335        "URI has invalid scheme (must start with letter and contain only alphanumeric, +, ., -): {0}"
336    )]
337    InvalidScheme(String),
338}
339
340/// Error type for MIME type validation
341#[derive(Debug, Clone, thiserror::Error)]
342pub enum MimeTypeError {
343    /// Invalid MIME type format
344    #[error("Invalid MIME type format (must be type/subtype): {0}")]
345    InvalidFormat(String),
346
347    /// Empty type part
348    #[error("MIME type has empty type part: {0}")]
349    EmptyType(String),
350
351    /// Empty subtype part
352    #[error("MIME type has empty subtype part: {0}")]
353    EmptySubtype(String),
354}
355
356/// Error type for Base64 validation
357#[derive(Debug, Clone, thiserror::Error)]
358pub enum Base64Error {
359    /// String contains invalid Base64 characters
360    #[error("Base64 string contains invalid characters: {0}")]
361    InvalidCharacters(String),
362
363    /// Invalid padding
364    #[error("Base64 string has invalid padding (= must only appear at end): {0}")]
365    InvalidPadding(String),
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_uri_validation() {
374        // Valid URIs
375        assert!(Uri::new("file:///path/to/file").is_ok());
376        assert!(Uri::new("https://example.com").is_ok());
377        assert!(Uri::new("resource://test").is_ok());
378        assert!(Uri::new("custom+scheme://data").is_ok());
379
380        // Invalid URIs
381        assert!(Uri::new("not-a-uri").is_err());
382        assert!(Uri::new(":no-scheme").is_err());
383        assert!(Uri::new("123://invalid-start").is_err());
384    }
385
386    #[test]
387    fn test_uri_scheme_extraction() {
388        let uri = Uri::new("https://example.com/path").unwrap();
389        assert_eq!(uri.scheme(), Some("https"));
390
391        let file_uri = Uri::new("file:///local/path").unwrap();
392        assert_eq!(file_uri.scheme(), Some("file"));
393    }
394
395    #[test]
396    fn test_mime_type_validation() {
397        // Valid MIME types
398        assert!(MimeType::new("text/plain").is_ok());
399        assert!(MimeType::new("application/json").is_ok());
400        assert!(MimeType::new("text/html; charset=utf-8").is_ok());
401        assert!(MimeType::new("image/png").is_ok());
402
403        // Invalid MIME types
404        assert!(MimeType::new("invalid").is_err());
405        assert!(MimeType::new("/no-type").is_err());
406        assert!(MimeType::new("no-subtype/").is_err());
407    }
408
409    #[test]
410    fn test_mime_type_parts() {
411        let mime = MimeType::new("text/html; charset=utf-8").unwrap();
412        assert_eq!(mime.type_part(), Some("text"));
413        assert_eq!(mime.subtype(), Some("html"));
414    }
415
416    #[test]
417    fn test_base64_validation() {
418        // Valid Base64
419        assert!(Base64String::new("SGVsbG8gV29ybGQh").is_ok());
420        assert!(Base64String::new("YWJjMTIz").is_ok());
421        assert!(Base64String::new("dGVzdA==").is_ok());
422        assert!(Base64String::new("").is_ok()); // Empty is valid
423
424        // Invalid Base64
425        assert!(Base64String::new("invalid!@#").is_err());
426        assert!(Base64String::new("test=data").is_err()); // Padding in middle
427    }
428
429    #[test]
430    fn test_domain_type_conversions() {
431        let uri = Uri::new("https://example.com").unwrap();
432        assert_eq!(uri.as_str(), "https://example.com");
433        assert_eq!(uri.to_string(), "https://example.com");
434
435        let mime = MimeType::new("text/plain").unwrap();
436        assert_eq!(mime.as_str(), "text/plain");
437
438        let b64 = Base64String::new("dGVzdA==").unwrap();
439        assert_eq!(b64.as_str(), "dGVzdA==");
440    }
441}