1use facet::Facet;
9use serde::{Deserialize, Serialize};
10
11use crate::http::HttpOutcome;
12
13#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
16#[repr(C)]
17pub enum TransferEvent {
18 Progress { transferred: u64, total: Option<u64> },
20 Done { outcome: HttpOutcome, handle: Option<String> },
25}
26
27impl TransferEvent {
28 pub fn encode(&self) -> Vec<u8> {
32 use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
33 let mut buffer = Vec::new();
34 BincodeFfiFormat::serialize(&mut buffer, self).expect("encode TransferEvent");
35 buffer
36 }
37
38 pub fn decode(bytes: &[u8]) -> Result<Self, String> {
40 use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
41 BincodeFfiFormat::deserialize(bytes).map_err(|e| e.to_string())
42 }
43}
44
45use crate::{Cx, HttpHeader, PluginResponse};
46
47#[derive(Serialize)]
50struct Multipart {
51 field: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 filename: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 file_content_type: Option<String>,
59 fields: Vec<HttpHeader>,
61}
62
63#[derive(Serialize)]
66struct TransferReq {
67 url: String,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 source: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 dest: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 method: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 multipart: Option<Multipart>,
76 headers: Vec<HttpHeader>,
77}
78
79pub struct TransferBuilder<'a, E> {
82 cx: &'a mut Cx<E>,
83 op: &'static str, req: TransferReq,
85 method_set_by_caller: bool,
86}
87
88impl<'a, E> TransferBuilder<'a, E> {
89 pub(crate) fn upload(cx: &'a mut Cx<E>, url: String, source: String) -> Self {
90 Self {
91 cx,
92 op: "upload",
93 req: TransferReq {
94 url,
95 source: Some(source),
96 dest: None,
97 method: Some("PUT".into()),
98 multipart: None,
99 headers: Vec::new(),
100 },
101 method_set_by_caller: false,
102 }
103 }
104
105 pub(crate) fn download(cx: &'a mut Cx<E>, url: String, dest: String) -> Self {
106 Self {
107 cx,
108 op: "download",
109 req: TransferReq {
110 url,
111 source: None,
112 dest: Some(dest),
113 method: None,
114 multipart: None,
115 headers: Vec::new(),
116 },
117 method_set_by_caller: false,
118 }
119 }
120
121 #[must_use]
123 pub fn method(mut self, m: impl Into<String>) -> Self {
124 if self.op == "upload" {
125 self.req.method = Some(m.into());
126 self.method_set_by_caller = true;
127 }
128 self
129 }
130
131 #[must_use]
135 pub fn multipart(mut self, field: impl Into<String>) -> Self {
136 if self.op == "upload" {
137 if !self.method_set_by_caller {
138 self.req.method = Some("POST".into());
139 }
140 match &mut self.req.multipart {
141 Some(m) => m.field = field.into(),
142 None => {
143 self.req.multipart = Some(Multipart {
144 field: field.into(),
145 filename: None,
146 file_content_type: None,
147 fields: Vec::new(),
148 })
149 }
150 }
151 }
152 self
153 }
154
155 #[must_use]
158 pub fn field(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
159 if let Some(m) = &mut self.req.multipart {
160 m.fields.push(HttpHeader { name: name.into(), value: value.into() });
161 }
162 self
163 }
164
165 #[must_use]
167 pub fn filename(mut self, name: impl Into<String>) -> Self {
168 if let Some(m) = &mut self.req.multipart {
169 m.filename = Some(name.into());
170 }
171 self
172 }
173
174 #[must_use]
179 pub fn file_content_type(mut self, ct: impl Into<String>) -> Self {
180 if let Some(m) = &mut self.req.multipart {
181 m.file_content_type = Some(ct.into());
182 }
183 self
184 }
185
186 #[must_use]
188 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
189 self.req.headers.push(HttpHeader { name: name.into(), value: value.into() });
190 self
191 }
192
193 #[must_use]
195 pub fn bearer(self, token: impl AsRef<str>) -> Self {
196 self.header("Authorization", format!("Bearer {}", token.as_ref()))
197 }
198
199 pub fn start(self, key: impl Into<String>, on_event: impl Fn(TransferEvent) -> E + Send + 'static) -> String {
202 let key = key.into();
203 let input = serde_json::to_string(&self.req).expect("serialize transfer request");
204 self.cx.subscribe(key.clone(), "transfer", self.op, input, move |r: PluginResponse| {
205 on_event(decode_event(&r))
206 });
207 key
208 }
209}
210
211fn decode_event(r: &PluginResponse) -> TransferEvent {
214 TransferEvent::decode(&r.output).unwrap_or_else(|e| TransferEvent::Done {
215 outcome: HttpOutcome::TransportError { message: format!("malformed transfer event: {e}") },
216 handle: None,
217 })
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::http::{HttpHeader, HttpOutcome};
224 use serde::Serialize;
225
226 #[derive(Serialize)]
227 enum Ev {
228 Tap,
229 }
230
231 #[test]
232 fn round_trips_progress_with_and_without_total() {
233 for ev in [
234 TransferEvent::Progress { transferred: 0, total: Some(1024) },
235 TransferEvent::Progress { transferred: 999_999_999, total: None },
236 ] {
237 assert_eq!(TransferEvent::decode(&ev.encode()).unwrap(), ev);
238 }
239 }
240
241 #[test]
242 fn round_trips_done_upload_and_download() {
243 let up = TransferEvent::Done {
244 outcome: HttpOutcome::Response { status: 200, headers: vec![], body: vec![] },
245 handle: None,
246 };
247 let down = TransferEvent::Done {
248 outcome: HttpOutcome::Response {
249 status: 200,
250 headers: vec![HttpHeader { name: "Content-Length".into(), value: "5".into() }],
251 body: vec![],
252 },
253 handle: Some("blob:abc".into()),
254 };
255 for ev in [up, down] {
256 assert_eq!(TransferEvent::decode(&ev.encode()).unwrap(), ev);
257 }
258 }
259
260 #[test]
261 fn decode_rejects_garbage_without_panicking() {
262 assert!(TransferEvent::decode(&[0xff, 0xff, 0xff]).is_err());
263 }
264
265 #[test]
266 fn multipart_sets_config_and_flips_method_to_post() {
267 let mut cx = Cx::<Ev>::default();
268 cx.upload("https://h/up", "file:///tmp/a.jpg")
269 .multipart("file")
270 .field("title", "My Photo")
271 .field("album", "vac")
272 .start("m-1", |_| Ev::Tap);
273
274 let (call, _) = &cx.streams[0];
275 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
276 assert_eq!(v["method"], "POST", "multipart flips default PUT -> POST");
277 assert_eq!(v["multipart"]["field"], "file");
278 assert_eq!(v["multipart"]["fields"][0]["name"], "title");
279 assert_eq!(v["multipart"]["fields"][0]["value"], "My Photo");
280 assert_eq!(v["multipart"]["fields"][1]["name"], "album");
281 assert!(v["multipart"].get("filename").is_none());
283 assert!(v["multipart"].get("file_content_type").is_none());
284 }
285
286 #[test]
287 fn explicit_method_survives_multipart() {
288 let mut cx = Cx::<Ev>::default();
289 cx.upload("https://h/up", "file:///tmp/a.jpg")
290 .method("PUT")
291 .multipart("file")
292 .start("m-2", |_| Ev::Tap);
293 let (call, _) = &cx.streams[0];
294 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
295 assert_eq!(v["method"], "PUT", "an explicit .method() is not overridden by .multipart()");
296 }
297
298 #[test]
299 fn multipart_overrides_land_in_config() {
300 let mut cx = Cx::<Ev>::default();
301 cx.upload("https://h/up", "file:///tmp/a.bin")
302 .multipart("f")
303 .filename("photo.jpg")
304 .file_content_type("image/jpeg")
305 .start("m-3", |_| Ev::Tap);
306 let (call, _) = &cx.streams[0];
307 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
308 assert_eq!(v["multipart"]["filename"], "photo.jpg");
309 assert_eq!(v["multipart"]["file_content_type"], "image/jpeg");
310 }
311
312 #[test]
313 fn multipart_is_a_noop_on_download() {
314 let mut cx = Cx::<Ev>::default();
315 cx.download("https://h/get", "/d").multipart("f").field("k", "v").start("d-1", |_| Ev::Tap);
316 let (call, _) = &cx.streams[0];
317 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
318 assert!(v.get("multipart").is_none(), "download ignores multipart");
319 assert!(v.get("method").is_none(), "download has no method");
320 }
321}