Skip to main content

protocol_droid/domain/ir/
resource.rs

1//! The contents a `resources/read` answers with.
2//!
3//! # Why this is separate from `Content`
4//!
5//! A resource's contents and a content block are different shapes that happen
6//! to share a vocabulary. A content block is a tagged union whose member is
7//! chosen by its `type`; a resource's contents entry is flat, with `uri` beside
8//! `text` *or* `blob` and no discriminator at all. The vendored schema puts
9//! them under different definitions — `TextResourceContents` and
10//! `BlobResourceContents`, joined by `anyOf` inside `ReadResourceResult` — so
11//! folding them into one type would have the IR assert a distinction the
12//! specification does not draw.
13//!
14//! The absence of a discriminator is why [`ResourceBody`] is an enum here
15//! rather than a pair of `Option`s. The schema permits an entry with both
16//! `text` and `blob`, or with neither; this type cannot express either, and
17//! says so by being impossible to construct rather than by validating later.
18
19/// One entry of a `resources/read` result's `contents` array.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ResourceContents {
22    /// The resource's URI, which the peer named in the request.
23    ///
24    /// Carried on every entry rather than once on the result, because the
25    /// schema requires it on every entry.
26    pub uri: String,
27    /// The media type, if known.
28    pub mime_type: Option<String>,
29    /// The body, in one of the two forms the schema allows.
30    pub body: ResourceBody,
31}
32
33/// A resource's body.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ResourceBody {
36    /// The text form, spelled `text` on the wire.
37    Text(String),
38    /// The base64 blob form, spelled `blob` on the wire.
39    Blob(String),
40}
41
42impl ResourceContents {
43    /// A textual resource.
44    #[must_use]
45    pub fn text(uri: impl Into<String>, text: impl Into<String>) -> Self {
46        Self {
47            uri: uri.into(),
48            mime_type: None,
49            body: ResourceBody::Text(text.into()),
50        }
51    }
52
53    /// A binary resource, already base64-encoded.
54    ///
55    /// The caller encodes. A decoder here would be a second opinion about
56    /// whether a payload is binary, and the MIME type the caller already holds
57    /// is the better witness.
58    #[must_use]
59    pub fn blob(uri: impl Into<String>, base64: impl Into<String>) -> Self {
60        Self {
61            uri: uri.into(),
62            mime_type: None,
63            body: ResourceBody::Blob(base64.into()),
64        }
65    }
66
67    /// The same contents, with a media type.
68    #[must_use]
69    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
70        self.mime_type = Some(mime_type.into());
71        self
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::{ResourceBody, ResourceContents};
78
79    #[test]
80    fn a_text_resource_carries_its_body_as_text() -> Result<(), String> {
81        let contents = ResourceContents::text("test://a", "hello");
82
83        match contents.body {
84            ResourceBody::Text(ref text) if text == "hello" => {}
85            other => return Err(format!("the body was {other:?}")),
86        }
87        if contents.uri != "test://a" {
88            return Err(format!("the uri was {:?}", contents.uri));
89        }
90        Ok(())
91    }
92
93    #[test]
94    fn a_blob_resource_carries_its_body_as_a_blob() -> Result<(), String> {
95        let contents = ResourceContents::blob("test://a", "aGk=");
96
97        match contents.body {
98            ResourceBody::Blob(ref data) if data == "aGk=" => Ok(()),
99            other => Err(format!("the body was {other:?}")),
100        }
101    }
102
103    #[test]
104    fn a_media_type_attaches_without_touching_the_body() -> Result<(), String> {
105        let contents = ResourceContents::text("test://a", "hello").with_mime_type("text/markdown");
106
107        match contents.mime_type.as_deref() {
108            Some("text/markdown") => {}
109            other => return Err(format!("the mime type was {other:?}")),
110        }
111        match &contents.body {
112            ResourceBody::Text(text) if text == "hello" => Ok(()),
113            other => Err(format!("the body became {other:?}")),
114        }
115    }
116}