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}