Skip to main content

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, ops::Deref};
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 Deref for Uri {
124    type Target = str;
125
126    fn deref(&self) -> &Self::Target {
127        &self.0
128    }
129}
130
131impl From<String> for Uri {
132    fn from(uri: String) -> Self {
133        Self::new_unchecked(uri)
134    }
135}
136
137impl From<&str> for Uri {
138    fn from(uri: &str) -> Self {
139        Self::new_unchecked(uri)
140    }
141}
142
143impl PartialEq<&str> for Uri {
144    fn eq(&self, other: &&str) -> bool {
145        self.as_str() == *other
146    }
147}
148
149impl PartialEq<Uri> for &str {
150    fn eq(&self, other: &Uri) -> bool {
151        *self == other.as_str()
152    }
153}
154
155impl From<Uri> for String {
156    fn from(uri: Uri) -> Self {
157        uri.0
158    }
159}
160
161/// MIME type string
162///
163/// Represents a media type in the format `type/subtype` with optional parameters.
164///
165/// # Examples
166///
167/// ```
168/// use turbomcp_protocol::types::domain::MimeType;
169///
170/// let mime = MimeType::new("text/plain").unwrap();
171/// assert_eq!(mime.type_part(), Some("text"));
172/// assert_eq!(mime.subtype(), Some("plain"));
173///
174/// let mime_with_params = MimeType::new("text/html; charset=utf-8").unwrap();
175/// ```
176#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
177#[serde(transparent)]
178pub struct MimeType(String);
179
180impl MimeType {
181    /// Create a new MIME type with validation
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if the MIME type format is invalid
186    pub fn new<S: Into<String>>(mime: S) -> Result<Self, MimeTypeError> {
187        let mime_string = mime.into();
188
189        // Basic validation: must contain '/' separator
190        if !mime_string.contains('/') {
191            return Err(MimeTypeError::InvalidFormat(mime_string));
192        }
193
194        // Extract type and subtype (before any parameters)
195        let main_part = mime_string.split(';').next().unwrap_or(&mime_string);
196        let parts: Vec<&str> = main_part.split('/').collect();
197
198        if parts.len() != 2 {
199            return Err(MimeTypeError::InvalidFormat(mime_string));
200        }
201
202        let type_part = parts[0].trim();
203        let subtype = parts[1].trim();
204
205        if type_part.is_empty() {
206            return Err(MimeTypeError::EmptyType(mime_string));
207        }
208
209        if subtype.is_empty() {
210            return Err(MimeTypeError::EmptySubtype(mime_string));
211        }
212
213        Ok(Self(mime_string))
214    }
215
216    /// Create a MIME type without validation
217    #[must_use]
218    pub fn new_unchecked<S: Into<String>>(mime: S) -> Self {
219        Self(mime.into())
220    }
221
222    /// Get the MIME type as a string slice
223    #[must_use]
224    pub fn as_str(&self) -> &str {
225        &self.0
226    }
227
228    /// Get the type part (before '/')
229    #[must_use]
230    pub fn type_part(&self) -> Option<&str> {
231        self.0
232            .split('/')
233            .next()
234            .map(|s| s.split(';').next().unwrap_or(s).trim())
235    }
236
237    /// Get the subtype part (after '/', before parameters)
238    #[must_use]
239    pub fn subtype(&self) -> Option<&str> {
240        self.0
241            .split('/')
242            .nth(1)
243            .map(|s| s.split(';').next().unwrap_or(s).trim())
244    }
245
246    /// Convert into the inner String
247    #[must_use]
248    pub fn into_inner(self) -> String {
249        self.0
250    }
251}
252
253impl fmt::Display for MimeType {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        write!(f, "{}", self.0)
256    }
257}
258
259impl AsRef<str> for MimeType {
260    fn as_ref(&self) -> &str {
261        &self.0
262    }
263}
264
265impl Deref for MimeType {
266    type Target = str;
267
268    fn deref(&self) -> &Self::Target {
269        &self.0
270    }
271}
272
273impl From<String> for MimeType {
274    fn from(mime: String) -> Self {
275        Self::new_unchecked(mime)
276    }
277}
278
279impl From<&str> for MimeType {
280    fn from(mime: &str) -> Self {
281        Self::new_unchecked(mime)
282    }
283}
284
285impl PartialEq<&str> for MimeType {
286    fn eq(&self, other: &&str) -> bool {
287        self.as_str() == *other
288    }
289}
290
291impl PartialEq<MimeType> for &str {
292    fn eq(&self, other: &MimeType) -> bool {
293        *self == other.as_str()
294    }
295}
296
297impl From<MimeType> for String {
298    fn from(mime: MimeType) -> Self {
299        mime.0
300    }
301}
302
303/// Base64-encoded string
304///
305/// Represents a Base64-encoded binary data string.
306///
307/// # Examples
308///
309/// ```
310/// use turbomcp_protocol::types::domain::Base64String;
311///
312/// let b64 = Base64String::new("SGVsbG8gV29ybGQh").unwrap();
313/// assert_eq!(b64.as_str(), "SGVsbG8gV29ybGQh");
314///
315/// // Invalid Base64 (contains invalid characters)
316/// assert!(Base64String::new("not valid!@#").is_err());
317/// ```
318#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
319#[serde(transparent)]
320pub struct Base64String(String);
321
322impl Base64String {
323    /// Create a new Base64 string with validation
324    ///
325    /// # Errors
326    ///
327    /// Returns an error if the string contains invalid Base64 characters
328    pub fn new<S: Into<String>>(data: S) -> Result<Self, Base64Error> {
329        let data_string = data.into();
330
331        // Validate Base64 characters: A-Z, a-z, 0-9, +, /, =
332        if !data_string
333            .chars()
334            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '='))
335        {
336            return Err(Base64Error::InvalidCharacters(data_string));
337        }
338
339        // Check padding is only at the end
340        if let Some(first_pad) = data_string.find('=')
341            && !data_string[first_pad..].chars().all(|c| c == '=')
342        {
343            return Err(Base64Error::InvalidPadding(data_string));
344        }
345
346        Ok(Self(data_string))
347    }
348
349    /// Create a Base64 string without validation
350    #[must_use]
351    pub fn new_unchecked<S: Into<String>>(data: S) -> Self {
352        Self(data.into())
353    }
354
355    /// Get the Base64 string as a string slice
356    #[must_use]
357    pub fn as_str(&self) -> &str {
358        &self.0
359    }
360
361    /// Convert into the inner String
362    #[must_use]
363    pub fn into_inner(self) -> String {
364        self.0
365    }
366}
367
368impl fmt::Display for Base64String {
369    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370        write!(f, "{}", self.0)
371    }
372}
373
374impl AsRef<str> for Base64String {
375    fn as_ref(&self) -> &str {
376        &self.0
377    }
378}
379
380impl Deref for Base64String {
381    type Target = str;
382
383    fn deref(&self) -> &Self::Target {
384        &self.0
385    }
386}
387
388impl From<String> for Base64String {
389    fn from(data: String) -> Self {
390        Self::new_unchecked(data)
391    }
392}
393
394impl From<&str> for Base64String {
395    fn from(data: &str) -> Self {
396        Self::new_unchecked(data)
397    }
398}
399
400impl PartialEq<&str> for Base64String {
401    fn eq(&self, other: &&str) -> bool {
402        self.as_str() == *other
403    }
404}
405
406impl PartialEq<Base64String> for &str {
407    fn eq(&self, other: &Base64String) -> bool {
408        *self == other.as_str()
409    }
410}
411
412impl From<Base64String> for String {
413    fn from(b64: Base64String) -> Self {
414        b64.0
415    }
416}
417
418/// Error type for URI validation
419#[derive(Debug, Clone, thiserror::Error)]
420pub enum UriError {
421    /// URI is missing a scheme separator (':')
422    #[error("URI missing scheme separator: {0}")]
423    MissingScheme(String),
424
425    /// URI has an empty scheme
426    #[error("URI has empty scheme: {0}")]
427    EmptyScheme(String),
428
429    /// URI has an invalid scheme format
430    #[error(
431        "URI has invalid scheme (must start with letter and contain only alphanumeric, +, ., -): {0}"
432    )]
433    InvalidScheme(String),
434}
435
436/// Error type for MIME type validation
437#[derive(Debug, Clone, thiserror::Error)]
438pub enum MimeTypeError {
439    /// Invalid MIME type format
440    #[error("Invalid MIME type format (must be type/subtype): {0}")]
441    InvalidFormat(String),
442
443    /// Empty type part
444    #[error("MIME type has empty type part: {0}")]
445    EmptyType(String),
446
447    /// Empty subtype part
448    #[error("MIME type has empty subtype part: {0}")]
449    EmptySubtype(String),
450}
451
452/// Error type for Base64 validation
453#[derive(Debug, Clone, thiserror::Error)]
454pub enum Base64Error {
455    /// String contains invalid Base64 characters
456    #[error("Base64 string contains invalid characters: {0}")]
457    InvalidCharacters(String),
458
459    /// Invalid padding
460    #[error("Base64 string has invalid padding (= must only appear at end): {0}")]
461    InvalidPadding(String),
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_uri_validation() {
470        // Valid URIs
471        assert!(Uri::new("file:///path/to/file").is_ok());
472        assert!(Uri::new("https://example.com").is_ok());
473        assert!(Uri::new("resource://test").is_ok());
474        assert!(Uri::new("custom+scheme://data").is_ok());
475
476        // Invalid URIs
477        assert!(Uri::new("not-a-uri").is_err());
478        assert!(Uri::new(":no-scheme").is_err());
479        assert!(Uri::new("123://invalid-start").is_err());
480    }
481
482    #[test]
483    fn test_uri_scheme_extraction() {
484        let uri = Uri::new("https://example.com/path").unwrap();
485        assert_eq!(uri.scheme(), Some("https"));
486
487        let file_uri = Uri::new("file:///local/path").unwrap();
488        assert_eq!(file_uri.scheme(), Some("file"));
489    }
490
491    #[test]
492    fn test_mime_type_validation() {
493        // Valid MIME types
494        assert!(MimeType::new("text/plain").is_ok());
495        assert!(MimeType::new("application/json").is_ok());
496        assert!(MimeType::new("text/html; charset=utf-8").is_ok());
497        assert!(MimeType::new("image/png").is_ok());
498
499        // Invalid MIME types
500        assert!(MimeType::new("invalid").is_err());
501        assert!(MimeType::new("/no-type").is_err());
502        assert!(MimeType::new("no-subtype/").is_err());
503    }
504
505    #[test]
506    fn test_mime_type_parts() {
507        let mime = MimeType::new("text/html; charset=utf-8").unwrap();
508        assert_eq!(mime.type_part(), Some("text"));
509        assert_eq!(mime.subtype(), Some("html"));
510    }
511
512    #[test]
513    fn test_base64_validation() {
514        // Valid Base64
515        assert!(Base64String::new("SGVsbG8gV29ybGQh").is_ok());
516        assert!(Base64String::new("YWJjMTIz").is_ok());
517        assert!(Base64String::new("dGVzdA==").is_ok());
518        assert!(Base64String::new("").is_ok()); // Empty is valid
519
520        // Invalid Base64
521        assert!(Base64String::new("invalid!@#").is_err());
522        assert!(Base64String::new("test=data").is_err()); // Padding in middle
523    }
524
525    #[test]
526    fn test_domain_type_conversions() {
527        let uri = Uri::new("https://example.com").unwrap();
528        assert_eq!(uri.as_str(), "https://example.com");
529        assert_eq!(uri.to_string(), "https://example.com");
530
531        let mime = MimeType::new("text/plain").unwrap();
532        assert_eq!(mime.as_str(), "text/plain");
533
534        let b64 = Base64String::new("dGVzdA==").unwrap();
535        assert_eq!(b64.as_str(), "dGVzdA==");
536    }
537}