1use nextcloud_sdk_core::{
4 AuthProvider, DecodeError, Error, Headers, HttpMethod, Request, Response, StatusCode,
5 StatusError, Transport,
6};
7
8use super::multistatus;
9use crate::method::{DEPTH_HEADER, DESTINATION_HEADER, Depth, OVERWRITE_HEADER, Overwrite};
10use crate::path::DavPath;
11use crate::propfind::PropfindBody;
12use crate::resource::DavResource;
13
14const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8";
16
17#[derive(Clone, Copy, Debug)]
22pub struct DavClient<'a, T: Transport, A: AuthProvider> {
23 transport: &'a T,
24 auth: &'a A,
25}
26
27impl<'a, T: Transport, A: AuthProvider> DavClient<'a, T, A> {
28 pub const fn new(transport: &'a T, auth: &'a A) -> Self {
30 Self { transport, auth }
31 }
32
33 pub const fn transport(&self) -> &'a T {
35 self.transport
36 }
37
38 pub const fn auth(&self) -> &'a A {
40 self.auth
41 }
42
43 pub async fn propfind(
49 &self,
50 path: &DavPath,
51 depth: Depth,
52 body: &PropfindBody,
53 ) -> Result<Vec<DavResource>, Error> {
54 let request = self.build_propfind(path, depth, body);
55 let response = self.transport.execute(request).await?;
56
57 if response.status() != StatusCode::new(207) {
58 return Err(Error::Status(StatusError::new(
59 HttpMethod::Propfind.as_str(),
60 path.to_api_path(),
61 response.status(),
62 response.body(),
63 )));
64 }
65
66 let text = std::str::from_utf8(response.body())
67 .map_err(|error| Error::Decode(DecodeError::new(multistatus::TARGET, error)))?;
68 multistatus::decode(text).map_err(Error::Decode)
69 }
70
71 pub async fn get(&self, path: &DavPath) -> Result<Vec<u8>, Error> {
76 let headers = self.authenticated_headers();
77 let request = Request::new(HttpMethod::Get, path.to_api_path()).with_headers(headers);
78 let response = self.transport.execute(request).await?;
79
80 ensure_success(HttpMethod::Get, path, &response)?;
81 Ok(response.into_body())
82 }
83
84 pub async fn put(
89 &self,
90 path: &DavPath,
91 body: impl Into<Vec<u8>>,
92 content_type: Option<&str>,
93 ) -> Result<(), Error> {
94 let mut headers = self.authenticated_headers();
95 if let Some(content_type) = content_type {
96 headers.set("Content-Type", content_type);
97 }
98 let request = Request::new(HttpMethod::Put, path.to_api_path())
99 .with_headers(headers)
100 .body_bytes(body);
101 let response = self.transport.execute(request).await?;
102
103 ensure_success(HttpMethod::Put, path, &response)
104 }
105
106 pub async fn mkcol(&self, path: &DavPath) -> Result<(), Error> {
111 let headers = self.authenticated_headers();
112 let request = Request::new(HttpMethod::Mkcol, path.to_api_path()).with_headers(headers);
113 let response = self.transport.execute(request).await?;
114
115 ensure_success(HttpMethod::Mkcol, path, &response)
116 }
117
118 pub async fn delete(&self, path: &DavPath) -> Result<(), Error> {
123 let headers = self.authenticated_headers();
124 let request = Request::new(HttpMethod::Delete, path.to_api_path()).with_headers(headers);
125 let response = self.transport.execute(request).await?;
126
127 ensure_success(HttpMethod::Delete, path, &response)
128 }
129
130 pub async fn copy(
137 &self,
138 source: &DavPath,
139 destination: &DavPath,
140 overwrite: bool,
141 ) -> Result<(), Error> {
142 self.transfer(HttpMethod::Copy, source, destination, overwrite)
143 .await
144 }
145
146 pub async fn move_(
153 &self,
154 source: &DavPath,
155 destination: &DavPath,
156 overwrite: bool,
157 ) -> Result<(), Error> {
158 self.transfer(HttpMethod::Move, source, destination, overwrite)
159 .await
160 }
161
162 fn authenticated_headers(&self) -> Headers {
163 let mut headers = Headers::new();
166 self.auth.authenticate(&mut headers);
167 headers
168 }
169
170 async fn transfer(
171 &self,
172 method: HttpMethod,
173 source: &DavPath,
174 destination: &DavPath,
175 overwrite: bool,
176 ) -> Result<(), Error> {
177 let mut headers = self.authenticated_headers();
178 headers.set(DESTINATION_HEADER, destination.to_api_path().as_str());
179 headers.set(
180 OVERWRITE_HEADER,
181 Overwrite::from(overwrite).as_header_value(),
182 );
183 let request = Request::new(method, source.to_api_path()).with_headers(headers);
184 let response = self.transport.execute(request).await?;
185
186 ensure_success(method, source, &response)
187 }
188
189 fn build_propfind(&self, path: &DavPath, depth: Depth, body: &PropfindBody) -> Request {
190 let mut headers = self.authenticated_headers();
191 headers.set(DEPTH_HEADER, depth.as_header_value());
192 headers.set("Content-Type", XML_CONTENT_TYPE);
193
194 Request::new(HttpMethod::Propfind, path.to_api_path())
195 .with_headers(headers)
196 .body_bytes(body.to_xml().into_bytes())
197 }
198}
199
200fn ensure_success(method: HttpMethod, path: &DavPath, response: &Response) -> Result<(), Error> {
201 if !response.status().is_success() {
202 return Err(Error::Status(StatusError::new(
203 method.as_str(),
204 path.to_api_path(),
205 response.status(),
206 response.body(),
207 )));
208 }
209 Ok(())
210}
211
212impl<T: Transport, A: AuthProvider> std::fmt::Display for DavClient<'_, T, A> {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 f.write_str("DavClient")
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use std::future::Future;
222 use std::sync::Mutex;
223 use std::task::Poll;
224
225 use nextcloud_sdk_core::{
226 AuthProvider, Error, Headers, HttpMethod, Request, Response, StatusCode, Transport,
227 };
228
229 use super::{DavClient, XML_CONTENT_TYPE};
230 use crate::fixtures::TWO_ENTRIES;
231 use crate::method::{DEPTH_HEADER, DESTINATION_HEADER, Depth, OVERWRITE_HEADER};
232 use crate::path::DavPath;
233 use crate::propfind::PropfindBody;
234 use crate::resource::DavResource;
235
236 struct FakeTransport {
239 status: StatusCode,
240 body: Vec<u8>,
241 seen: Mutex<Vec<Request>>,
242 }
243
244 impl FakeTransport {
245 fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
246 Self {
247 status: StatusCode::new(status),
248 body: body.into(),
249 seen: Mutex::new(Vec::new()),
250 }
251 }
252
253 fn last_request(&self) -> Request {
254 let seen = self.seen.lock().expect("test mutex is not poisoned");
255 seen.last().cloned().expect("a request was sent")
256 }
257 }
258
259 impl Transport for FakeTransport {
260 async fn execute(&self, request: Request) -> Result<Response, Error> {
261 self.seen
262 .lock()
263 .expect("test mutex is not poisoned")
264 .push(request);
265 Ok(Response::new(
266 self.status,
267 Headers::new(),
268 self.body.clone(),
269 ))
270 }
271 }
272
273 struct FakeAuth;
274
275 impl AuthProvider for FakeAuth {
276 fn authenticate(&self, headers: &mut Headers) {
277 headers.set("Authorization", "Basic dGVzdDp0ZXN0");
278 }
279 }
280
281 fn run<F: Future>(future: F) -> F::Output {
282 let mut future = std::pin::pin!(future);
283 let waker = std::task::Waker::noop();
284 let mut context = std::task::Context::from_waker(waker);
285 loop {
286 if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
287 return value;
288 }
289 }
290 }
291
292 fn propfind(status: u16, body: &str) -> (Result<Vec<DavResource>, Error>, FakeTransport) {
293 let transport = FakeTransport::new(status, body);
294 let auth = FakeAuth;
295 let client = DavClient::new(&transport, &auth);
296 let path = DavPath::new("alice", "Photos").expect("path is valid");
297 let result = run(client.propfind(&path, Depth::One, &PropfindBody::all()));
298 (result, transport)
299 }
300
301 #[test]
302 fn propfind_sets_depth_content_type_and_auth() {
303 let (result, transport) = propfind(207, TWO_ENTRIES);
304 assert!(result.is_ok());
305
306 let request = transport.last_request();
307 assert_eq!(request.method(), HttpMethod::Propfind);
308 assert_eq!(
309 request.path().as_str(),
310 "/remote.php/dav/files/alice/Photos"
311 );
312 assert_eq!(request.headers().get(DEPTH_HEADER), Some("1"));
313 assert_eq!(
314 request.headers().get("Content-Type"),
315 Some(XML_CONTENT_TYPE)
316 );
317 assert_eq!(
318 request.headers().get("Authorization"),
319 Some("Basic dGVzdDp0ZXN0")
320 );
321 }
322
323 #[test]
324 fn propfind_sends_the_rendered_body() {
325 let (_, transport) = propfind(207, TWO_ENTRIES);
326 let request = transport.last_request();
327 let body = std::str::from_utf8(request.body().expect("body")).expect("utf-8");
328 assert!(body.contains("<d:propfind"));
329 assert!(body.contains(r#"xmlns:d="DAV:""#));
330 assert!(body.contains("<d:getetag/>"));
331 }
332
333 #[test]
334 fn depth_token_reaches_the_header() {
335 for (depth, expected) in [
336 (Depth::Zero, "0"),
337 (Depth::One, "1"),
338 (Depth::Infinity, "infinity"),
339 ] {
340 let transport = FakeTransport::new(207, TWO_ENTRIES);
341 let auth = FakeAuth;
342 let client = DavClient::new(&transport, &auth);
343 let path = DavPath::root("alice").expect("path is valid");
344 let _ = run(client.propfind(&path, depth, &PropfindBody::all()));
345 assert_eq!(
346 transport.last_request().headers().get(DEPTH_HEADER),
347 Some(expected)
348 );
349 }
350 }
351
352 #[test]
353 fn successful_propfind_decodes_the_resources() {
354 let (result, _) = propfind(207, TWO_ENTRIES);
355 let resources = result.expect("decode succeeds");
356 assert_eq!(resources.len(), 2);
357 assert!(resources[0].is_collection);
358 assert!(!resources[1].is_collection);
359 }
360
361 #[test]
362 fn non_multistatus_status_becomes_a_status_error() {
363 let (result, _) = propfind(404, "<d:error/>");
364 let error = result.expect_err("404 is not a multistatus");
365 assert_eq!(error.status_code(), Some(404));
366 assert!(error.is_not_found());
367 }
368
369 #[test]
370 fn unreadable_body_becomes_a_decode_error() {
371 let (result, _) = propfind(207, "<html>not dav</html>");
372 let error = result.expect_err("body is not a multistatus");
373 let decode = error.as_decode().expect("decode stage");
374 assert_eq!(decode.target(), "DavResource");
375 }
376
377 #[test]
378 fn invalid_utf8_body_becomes_a_decode_error() {
379 let transport = FakeTransport::new(207, vec![0xff, 0xfe]);
380 let auth = FakeAuth;
381 let client = DavClient::new(&transport, &auth);
382 let path = DavPath::root("alice").expect("path is valid");
383 let error = run(client.propfind(&path, Depth::Zero, &PropfindBody::all()))
384 .expect_err("invalid utf-8");
385 assert_eq!(error.stage().as_str(), "decode");
386 }
387
388 #[test]
389 fn get_downloads_content_and_sets_auth() {
390 let transport = FakeTransport::new(200, b"file payload");
391 let auth = FakeAuth;
392 let client = DavClient::new(&transport, &auth);
393 let path = DavPath::new("alice", "document.txt").expect("path is valid");
394
395 let body = run(client.get(&path)).expect("get succeeds");
396 assert_eq!(body, b"file payload");
397
398 let request = transport.last_request();
399 assert_eq!(request.method(), HttpMethod::Get);
400 assert_eq!(
401 request.path().as_str(),
402 "/remote.php/dav/files/alice/document.txt"
403 );
404 assert_eq!(
405 request.headers().get("Authorization"),
406 Some("Basic dGVzdDp0ZXN0")
407 );
408 }
409
410 #[test]
411 fn get_non_success_becomes_a_status_error() {
412 let transport = FakeTransport::new(404, "not found");
413 let auth = FakeAuth;
414 let client = DavClient::new(&transport, &auth);
415 let path = DavPath::new("alice", "missing.txt").expect("path is valid");
416
417 let error = run(client.get(&path)).expect_err("404 returns error");
418 assert_eq!(error.status_code(), Some(404));
419 let status = error.as_status().expect("status error");
420 assert_eq!(status.method(), "GET");
421 assert_eq!(
422 status.path().as_str(),
423 "/remote.php/dav/files/alice/missing.txt"
424 );
425 }
426
427 #[test]
428 fn put_uploads_body_and_sets_content_type() {
429 let transport = FakeTransport::new(201, b"");
430 let auth = FakeAuth;
431 let client = DavClient::new(&transport, &auth);
432 let path = DavPath::new("alice", "notes.md").expect("path is valid");
433
434 run(client.put(&path, b"# Hello".to_vec(), Some("text/markdown"))).expect("put succeeds");
435
436 let request = transport.last_request();
437 assert_eq!(request.method(), HttpMethod::Put);
438 assert_eq!(
439 request.path().as_str(),
440 "/remote.php/dav/files/alice/notes.md"
441 );
442 assert_eq!(request.headers().get("Content-Type"), Some("text/markdown"));
443 assert_eq!(
444 request.headers().get("Authorization"),
445 Some("Basic dGVzdDp0ZXN0")
446 );
447 assert_eq!(request.body(), Some(&b"# Hello"[..]));
448 }
449
450 #[test]
451 fn put_omits_content_type_when_none() {
452 let transport = FakeTransport::new(204, b"");
453 let auth = FakeAuth;
454 let client = DavClient::new(&transport, &auth);
455 let path = DavPath::new("alice", "raw.bin").expect("path is valid");
456
457 run(client.put(&path, vec![0xde, 0xad, 0xbe, 0xef], None)).expect("put succeeds");
458
459 let request = transport.last_request();
460 assert_eq!(request.headers().get("Content-Type"), None);
461 assert_eq!(request.body(), Some(&[0xde, 0xad, 0xbe, 0xef][..]));
462 }
463
464 #[test]
465 fn put_non_success_becomes_a_status_error() {
466 let transport = FakeTransport::new(409, "conflict");
467 let auth = FakeAuth;
468 let client = DavClient::new(&transport, &auth);
469 let path = DavPath::new("alice", "notes.md").expect("path is valid");
470
471 let error = run(client.put(&path, b"test".to_vec(), None)).expect_err("409 returns error");
472 assert_eq!(error.status_code(), Some(409));
473 let status = error.as_status().expect("status error");
474 assert_eq!(status.method(), "PUT");
475 }
476
477 #[test]
478 fn mkcol_creates_collection() {
479 let transport = FakeTransport::new(201, b"");
480 let auth = FakeAuth;
481 let client = DavClient::new(&transport, &auth);
482 let path = DavPath::new("alice", "NewFolder").expect("path is valid");
483
484 run(client.mkcol(&path)).expect("mkcol succeeds");
485
486 let request = transport.last_request();
487 assert_eq!(request.method(), HttpMethod::Mkcol);
488 assert_eq!(
489 request.path().as_str(),
490 "/remote.php/dav/files/alice/NewFolder"
491 );
492 assert_eq!(
493 request.headers().get("Authorization"),
494 Some("Basic dGVzdDp0ZXN0")
495 );
496 assert!(request.body().is_none());
497 }
498
499 #[test]
500 fn mkcol_non_success_becomes_a_status_error() {
501 let transport = FakeTransport::new(405, "not allowed");
502 let auth = FakeAuth;
503 let client = DavClient::new(&transport, &auth);
504 let path = DavPath::new("alice", "Folder").expect("path is valid");
505
506 let error = run(client.mkcol(&path)).expect_err("405 returns error");
507 assert_eq!(error.status_code(), Some(405));
508 let status = error.as_status().expect("status error");
509 assert_eq!(status.method(), "MKCOL");
510 }
511
512 #[test]
513 fn delete_removes_resource() {
514 let transport = FakeTransport::new(204, b"");
515 let auth = FakeAuth;
516 let client = DavClient::new(&transport, &auth);
517 let path = DavPath::new("alice", "old.txt").expect("path is valid");
518
519 run(client.delete(&path)).expect("delete succeeds");
520
521 let request = transport.last_request();
522 assert_eq!(request.method(), HttpMethod::Delete);
523 assert_eq!(
524 request.path().as_str(),
525 "/remote.php/dav/files/alice/old.txt"
526 );
527 assert_eq!(
528 request.headers().get("Authorization"),
529 Some("Basic dGVzdDp0ZXN0")
530 );
531 }
532
533 #[test]
534 fn delete_non_success_becomes_a_status_error() {
535 let transport = FakeTransport::new(404, "not found");
536 let auth = FakeAuth;
537 let client = DavClient::new(&transport, &auth);
538 let path = DavPath::new("alice", "old.txt").expect("path is valid");
539
540 let error = run(client.delete(&path)).expect_err("404 returns error");
541 assert_eq!(error.status_code(), Some(404));
542 let status = error.as_status().expect("status error");
543 assert_eq!(status.method(), "DELETE");
544 }
545
546 #[test]
547 fn copy_sends_destination_and_overwrite_headers() {
548 for (overwrite, expected_header) in [(true, "T"), (false, "F")] {
549 let transport = FakeTransport::new(201, b"");
550 let auth = FakeAuth;
551 let client = DavClient::new(&transport, &auth);
552 let source = DavPath::new("alice", "src.txt").expect("path is valid");
553 let destination = DavPath::new("alice", "dst.txt").expect("path is valid");
554
555 run(client.copy(&source, &destination, overwrite)).expect("copy succeeds");
556
557 let request = transport.last_request();
558 assert_eq!(request.method(), HttpMethod::Copy);
559 assert_eq!(
560 request.path().as_str(),
561 "/remote.php/dav/files/alice/src.txt"
562 );
563 assert_eq!(
564 request.headers().get(DESTINATION_HEADER),
565 Some("/remote.php/dav/files/alice/dst.txt")
566 );
567 assert_eq!(
568 request.headers().get(OVERWRITE_HEADER),
569 Some(expected_header)
570 );
571 assert_eq!(
572 request.headers().get("Authorization"),
573 Some("Basic dGVzdDp0ZXN0")
574 );
575 }
576 }
577
578 #[test]
579 fn copy_non_success_becomes_a_status_error() {
580 let transport = FakeTransport::new(412, "precondition failed");
581 let auth = FakeAuth;
582 let client = DavClient::new(&transport, &auth);
583 let source = DavPath::new("alice", "src.txt").expect("path is valid");
584 let destination = DavPath::new("alice", "dst.txt").expect("path is valid");
585
586 let error = run(client.copy(&source, &destination, false)).expect_err("412 returns error");
587 assert_eq!(error.status_code(), Some(412));
588 let status = error.as_status().expect("status error");
589 assert_eq!(status.method(), "COPY");
590 assert_eq!(
591 status.path().as_str(),
592 "/remote.php/dav/files/alice/src.txt"
593 );
594 }
595
596 #[test]
597 fn move_sends_destination_and_overwrite_headers() {
598 for (overwrite, expected_header) in [(true, "T"), (false, "F")] {
599 let transport = FakeTransport::new(201, b"");
600 let auth = FakeAuth;
601 let client = DavClient::new(&transport, &auth);
602 let source = DavPath::new("alice", "src.txt").expect("path is valid");
603 let destination = DavPath::new("alice", "dst.txt").expect("path is valid");
604
605 run(client.move_(&source, &destination, overwrite)).expect("move succeeds");
606
607 let request = transport.last_request();
608 assert_eq!(request.method(), HttpMethod::Move);
609 assert_eq!(
610 request.path().as_str(),
611 "/remote.php/dav/files/alice/src.txt"
612 );
613 assert_eq!(
614 request.headers().get(DESTINATION_HEADER),
615 Some("/remote.php/dav/files/alice/dst.txt")
616 );
617 assert_eq!(
618 request.headers().get(OVERWRITE_HEADER),
619 Some(expected_header)
620 );
621 assert_eq!(
622 request.headers().get("Authorization"),
623 Some("Basic dGVzdDp0ZXN0")
624 );
625 }
626 }
627
628 #[test]
629 fn move_non_success_becomes_a_status_error() {
630 let transport = FakeTransport::new(412, "precondition failed");
631 let auth = FakeAuth;
632 let client = DavClient::new(&transport, &auth);
633 let source = DavPath::new("alice", "src.txt").expect("path is valid");
634 let destination = DavPath::new("alice", "dst.txt").expect("path is valid");
635
636 let error = run(client.move_(&source, &destination, false)).expect_err("412 returns error");
637 assert_eq!(error.status_code(), Some(412));
638 let status = error.as_status().expect("status error");
639 assert_eq!(status.method(), "MOVE");
640 assert_eq!(
641 status.path().as_str(),
642 "/remote.php/dav/files/alice/src.txt"
643 );
644 }
645}