1use crate::{
2 error::{Error, Result},
3 types::{
4 cluster::{PendingDevices, PendingFolders},
5 config::{
6 Configuration, DeviceConfiguration, FolderConfiguration, NewDeviceConfiguration,
7 NewFolderConfiguration,
8 },
9 events::Event,
10 },
11};
12use reqwest::{StatusCode, header};
13use tokio::sync::broadcast::Sender;
14
15const ADDR: &str = "http://localhost:8384/rest";
16
17#[must_use]
19pub struct ClientBuilder {
20 base_url: Option<String>,
21 api_key: String,
22}
23
24impl ClientBuilder {
25 pub fn new(api_key: impl Into<String>) -> Self {
31 Self {
32 base_url: None,
33 api_key: api_key.into(),
34 }
35 }
36
37 pub fn base_url(mut self, url: impl Into<String>) -> Self {
39 self.base_url = Some(url.into());
40 self
41 }
42
43 pub fn build(self) -> Result<Client> {
50 let base_url = self.base_url.unwrap_or_else(|| ADDR.to_string());
51
52 let mut headers = header::HeaderMap::new();
53 let mut api_key_header = header::HeaderValue::from_str(&self.api_key)?;
54 api_key_header.set_sensitive(true);
55 headers.insert("X-API-KEY", api_key_header);
56
57 let client = reqwest::Client::builder()
58 .default_headers(headers)
59 .build()?;
60
61 Ok(Client { client, base_url })
62 }
63}
64
65#[derive(Clone, Debug)]
71pub struct Client {
72 client: reqwest::Client,
73 base_url: String,
74}
75
76impl Client {
77 #[must_use]
86 pub fn new(api_key: &str) -> Self {
87 ClientBuilder::new(api_key).build().expect("Client::new()")
88 }
89
90 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
96 ClientBuilder::new(api_key)
97 }
98
99 pub async fn ping(&self) -> Result<()> {
104 log::debug!("GET /system/ping");
105 self.client
106 .get(format!("{}/system/ping", self.base_url))
107 .send()
108 .await?
109 .error_for_status()?;
110
111 Ok(())
112 }
113
114 pub async fn health(&self) -> Result<()> {
119 log::debug!("GET /noauth/health");
120 self.client
121 .get(format!("{}/noauth/health", self.base_url))
122 .send()
123 .await?
124 .error_for_status()?;
125
126 Ok(())
127 }
128
129 pub async fn get_id(&self) -> Result<String> {
132 log::debug!("GET /noauth/health");
133 Ok(self
134 .client
135 .get(format!("{}/noauth/health", self.base_url))
136 .send()
137 .await?
138 .error_for_status()?
139 .headers()
140 .get("X-Syncthing-Id")
141 .ok_or(Error::HeaderDeviceIDError)?
142 .to_str()
143 .map_err(|_| Error::HeaderParseError)?
144 .to_string())
145 }
146
147 pub async fn get_events(&self, tx: Sender<Event>, mut skip_old: bool) -> Result<()> {
152 let mut current_id = 0;
153 loop {
154 log::debug!("GET /events");
155 let events: Vec<Event> = self
156 .client
157 .get(format!("{}/events?since={}", self.base_url, current_id))
158 .send()
159 .await?
160 .error_for_status()?
161 .json()
162 .await?;
163
164 log::debug!("received {} new events", events.len());
165 for event in events {
166 current_id = event.id;
167 if !skip_old {
168 tx.send(event)?;
169 }
170 }
171 log::debug!("current event id is {current_id}");
172 skip_old = false;
173 }
174 }
175
176 pub async fn get_configuration(&self) -> Result<Configuration> {
183 log::debug!("GET /config");
184 Ok(self
185 .client
186 .get(format!("{}/config", self.base_url))
187 .send()
188 .await?
189 .error_for_status()?
190 .json()
191 .await?)
192 }
193
194 pub async fn post_folder(&self, folder: impl Into<NewFolderConfiguration>) -> Result<()> {
200 let folder = folder.into();
201 log::debug!("POST /config/folders {:?}", folder);
202 self.client
203 .post(format!("{}/config/folders", self.base_url))
204 .json(&folder)
205 .send()
206 .await?
207 .error_for_status()?;
208
209 Ok(())
210 }
211
212 pub async fn add_folder(&self, folder: impl Into<NewFolderConfiguration>) -> Result<()> {
219 let folder = folder.into();
220 match self.get_folder(folder.get_id()).await {
221 Ok(_) => return Err(Error::DuplicateFolderError),
222 Err(Error::UnknownFolderError) => (),
223 Err(e) => return Err(e),
224 }
225 self.post_folder(folder).await
226 }
227
228 pub async fn get_folder(&self, folder_id: &str) -> Result<FolderConfiguration> {
232 log::debug!("GET /config/folders/{}", folder_id);
233 let response = self
234 .client
235 .get(format!("{}/config/folders/{}", self.base_url, folder_id))
236 .send()
237 .await?;
238
239 if response.status() == StatusCode::NOT_FOUND {
240 Err(Error::UnknownFolderError)
242 } else {
243 Ok(response.error_for_status()?.json().await?)
244 }
245 }
246
247 pub async fn post_device(&self, device: impl Into<NewDeviceConfiguration>) -> Result<()> {
253 let device = device.into();
254 log::debug!("POST /config/devices {:?}", device);
255 self.client
256 .post(format!("{}/config/devices", self.base_url))
257 .json(&device)
258 .send()
259 .await?
260 .error_for_status()?;
261
262 Ok(())
263 }
264
265 pub async fn add_device(&self, device: impl Into<NewDeviceConfiguration>) -> Result<()> {
272 let device = device.into();
273 match self.get_device(device.get_device_id()).await {
274 Ok(_) => return Err(Error::DuplicateDeviceError),
275 Err(Error::UnknownDeviceError) => (),
276 Err(e) => return Err(e),
277 }
278 self.post_device(device).await
279 }
280
281 pub async fn get_device(&self, device_id: &str) -> Result<DeviceConfiguration> {
283 log::debug!("GET /config/devices/{}", device_id);
284 let response = self
285 .client
286 .get(format!("{}/config/devices/{}", self.base_url, device_id))
287 .send()
288 .await?;
289
290 if response.status() == StatusCode::NOT_FOUND {
291 Err(Error::UnknownDeviceError)
293 } else {
294 Ok(response.error_for_status()?.json().await?)
295 }
296 }
297
298 pub async fn get_pending_devices(&self) -> Result<PendingDevices> {
301 log::debug!("GET /cluster/pending/devices");
302 Ok(self
303 .client
304 .get(format!("{}/cluster/pending/devices", self.base_url))
305 .send()
306 .await?
307 .error_for_status()?
308 .json()
309 .await?)
310 }
311
312 pub async fn get_pending_folders(&self) -> Result<PendingFolders> {
315 log::debug!("GET /cluster/pending/folders");
316 Ok(self
317 .client
318 .get(format!("{}/cluster/pending/folders", self.base_url))
319 .send()
320 .await?
321 .error_for_status()?
322 .json()
323 .await?)
324 }
325
326 pub async fn delete_pending_device(&self, device_id: &str) -> Result<()> {
330 log::debug!("DELETE /cluster/pending/devices?device={device_id}");
331 self.client
332 .delete(format!(
333 "{}/cluster/pending/devices?device={}",
334 self.base_url, device_id
335 ))
336 .send()
337 .await?
338 .error_for_status()?;
339
340 Ok(())
341 }
342
343 pub async fn delete_pending_folder(
349 &self,
350 folder_id: &str,
351 device_id: Option<&str>,
352 ) -> Result<()> {
353 let device_str = match device_id {
354 Some(device_id) => format!("?device={}", device_id),
355 None => String::new(),
356 };
357 log::debug!("DELETE /clusterpending/folders?folder={folder_id}{device_str}");
358 self.client
359 .delete(format!(
360 "{}/cluster/pending/folders?folder={}{}",
361 self.base_url, folder_id, device_str
362 ))
363 .send()
364 .await?
365 .error_for_status()?;
366
367 Ok(())
368 }
369
370 pub async fn get_default_device(&self) -> Result<DeviceConfiguration> {
373 log::debug!("GET /config/defaults/device");
374 Ok(self
375 .client
376 .get(format!("{}/config/defaults/device", self.base_url))
377 .send()
378 .await?
379 .error_for_status()?
380 .json()
381 .await?)
382 }
383
384 pub async fn get_default_folder(&self) -> Result<FolderConfiguration> {
387 log::debug!("GET /config/defaults/folder");
388 Ok(self
389 .client
390 .get(format!("{}/config/defaults/folder", self.base_url))
391 .send()
392 .await?
393 .error_for_status()?
394 .json()
395 .await?)
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use crate::types::events::EventType;
402
403 use super::*;
404
405 use httpmock::prelude::*;
406 use testcontainers::{
407 ContainerAsync, GenericImage, ImageExt,
408 core::{ContainerPort::Tcp, WaitFor},
409 runners::AsyncRunner,
410 };
411 use tokio::sync::broadcast;
412
413 use rstest::*;
414
415 const DEVICE_ID: &str = "MFZWI3D-BONSGYC-YLTMRWG-C43ENR5-QXGZDMM-FZWI3DP-BONSGYY-LTMRWAD";
417
418 #[fixture]
419 async fn syncthing_setup() -> (ContainerAsync<GenericImage>, Client) {
420 let api_key = "foobar";
421 let container = GenericImage::new("syncthing/syncthing", "latest")
422 .with_exposed_port(Tcp(8384))
423 .with_wait_for(WaitFor::message_on_stdout("GUI and API listening on "))
424 .with_env_var("STGUIAPIKEY", api_key)
425 .start()
426 .await
427 .expect("failed to start syncthing container");
428
429 let host = container
430 .get_host()
431 .await
432 .expect("could not get syncthing host");
433 let port = container
434 .get_host_port_ipv4(8384)
435 .await
436 .expect("could not get syncthing port");
437
438 let url = format!("http://{host}:{port}/rest");
439
440 let client = ClientBuilder::new(api_key).base_url(url).build().unwrap();
441
442 (container, client)
443 }
444
445 #[test]
446 fn test_new() {
447 let client = Client::new("foo");
448
449 assert_eq!(client.base_url, "http://localhost:8384/rest");
450 }
451
452 #[tokio::test]
454 async fn test_ping() {
455 let server = MockServer::start();
456
457 let ping_mock = server.mock(|when, then| {
458 when.method(GET).path("/system/ping");
459 then.status(200)
460 .header("content-type", "application/json")
461 .body(r#"{"ping": "pong"}"#);
462 });
463
464 let client = ClientBuilder::new("")
465 .base_url(server.base_url())
466 .build()
467 .unwrap();
468
469 let result = client.ping().await;
470 ping_mock.assert();
471
472 assert!(result.is_ok());
473 }
474
475 #[tokio::test]
478 async fn test_single_event() {
479 let server = MockServer::start();
480
481 let event_mock = server.mock(|when, then| {
482 when.method(GET).path("/events");
483 then.status(200)
484 .header("content-type", "application/json")
485 .body(
486 r#"
487[
488 {
489 "id": 1,
490 "globalID": 1,
491 "time": "2025-05-07T17:05:44.514050967+02:00",
492 "type": "Starting",
493 "data": {
494 "home": "/home/user/.config/syncthing",
495 "myID": "XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX"
496 }
497 }
498]
499"#,
500 );
501 });
502
503 let client = ClientBuilder::new("")
504 .base_url(server.base_url())
505 .build()
506 .unwrap();
507
508 let (tx, mut rx) = broadcast::channel(1);
509
510 tokio::spawn(async move {
512 let result = client.get_events(tx, false).await;
513 unreachable!("get_events should not have returned: {:?}", result);
514 });
515
516 let event = rx.recv().await;
517 assert!(event_mock.hits() > 0);
518 assert!(event.is_ok());
519 assert_eq!(event.unwrap().ty, EventType::Starting {})
520 }
521
522 #[tokio::test]
523 async fn container_test_health() {
524 let container = GenericImage::new("syncthing/syncthing", "latest")
527 .with_exposed_port(Tcp(8384))
528 .with_wait_for(WaitFor::message_on_stdout("GUI and API listening on "))
529 .start()
530 .await
531 .expect("failed to start syncthing container");
532
533 let host = container
534 .get_host()
535 .await
536 .expect("could not get syncthing host");
537 let port = container
538 .get_host_port_ipv4(8384)
539 .await
540 .expect("could not get syncthing port");
541
542 let url = format!("http://{host}:{port}/rest");
543
544 let client = ClientBuilder::new("idk").base_url(url).build().unwrap();
545
546 client.health().await.unwrap();
547 }
548
549 #[tokio::test]
550 async fn container_test_id() {
551 let container = GenericImage::new("syncthing/syncthing", "latest")
554 .with_exposed_port(Tcp(8384))
555 .with_wait_for(WaitFor::message_on_stdout("GUI and API listening on "))
556 .start()
557 .await
558 .expect("failed to start syncthing container");
559
560 let host = container
561 .get_host()
562 .await
563 .expect("could not get syncthing host");
564 let port = container
565 .get_host_port_ipv4(8384)
566 .await
567 .expect("could not get syncthing port");
568
569 let url = format!("http://{host}:{port}/rest");
570
571 let client = ClientBuilder::new("idk").base_url(url).build().unwrap();
572
573 client.get_id().await.unwrap();
574 }
575
576 #[rstest]
577 #[tokio::test]
578 async fn container_test_ping(
579 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
580 ) {
581 let (_container, client) = syncthing_setup.await;
582
583 client.ping().await.unwrap();
584 }
585
586 #[rstest]
587 #[tokio::test]
588 async fn container_test_get_config(
589 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
590 ) {
591 let (_container, client) = syncthing_setup.await;
592
593 client
594 .get_configuration()
595 .await
596 .expect("could not get config");
597 }
598
599 #[rstest]
600 #[tokio::test]
601 async fn container_test_post_folder(
602 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
603 ) {
604 let (_container, client) = syncthing_setup.await;
605 let folder_id = "this-is-a-new-folder";
606 let path = "/tmp";
607
608 let folder = NewFolderConfiguration::new(folder_id.to_string(), path.to_string());
609
610 client
611 .post_folder(folder)
612 .await
613 .expect("could not post folder");
614
615 let api_folder = client
616 .get_folder(folder_id)
617 .await
618 .expect("could not get folder");
619
620 assert_eq!(&api_folder.id, folder_id);
621 assert_eq!(&api_folder.path, path);
622 }
623
624 #[rstest]
625 #[tokio::test]
626 async fn container_test_add_folder(
627 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
628 ) {
629 let (_container, client) = syncthing_setup.await;
630 let folder_id = "this-is-a-new-folder";
631 let path = "/tmp";
632
633 let folder = NewFolderConfiguration::new(folder_id.to_string(), path.to_string());
634
635 client
636 .add_folder(folder)
637 .await
638 .expect("could not add folder");
639
640 let api_folder = client
641 .get_folder(folder_id)
642 .await
643 .expect("could not get folder");
644
645 assert_eq!(&api_folder.id, folder_id);
646 assert_eq!(&api_folder.path, path);
647 }
648
649 #[rstest]
650 #[tokio::test]
651 #[should_panic(expected = "DuplicateFolderError")]
652 async fn container_test_add_folder_twice_panic(
653 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
654 ) {
655 let (_container, client) = syncthing_setup.await;
656 let folder_id = "this-is-a-new-folder";
657 let path = "/tmp";
658
659 let folder = NewFolderConfiguration::new(folder_id.to_string(), path.to_string());
660
661 client
662 .add_folder(folder)
663 .await
664 .expect("could not add folder");
665
666 let duplicate_path = "/usr";
668 let duplicate_folder =
669 NewFolderConfiguration::new(folder_id.to_string(), duplicate_path.to_string());
670
671 client
672 .add_folder(duplicate_folder)
673 .await
674 .expect("could not add folder")
675 }
676
677 #[rstest]
678 #[tokio::test]
679 async fn container_test_post_folder_twice(
680 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
681 ) {
682 let (_container, client) = syncthing_setup.await;
683 let folder_id = "this-is-a-new-folder";
684 let path = "/tmp";
685
686 let folder = NewFolderConfiguration::new(folder_id.to_string(), path.to_string());
687
688 client
689 .add_folder(folder)
690 .await
691 .expect("could not add folder");
692
693 let duplicate_path = "/usr";
695 let duplicate_folder =
696 NewFolderConfiguration::new(folder_id.to_string(), duplicate_path.to_string());
697
698 client
699 .post_folder(duplicate_folder)
700 .await
701 .expect("could not post folder");
702
703 let api_folder = client
704 .get_folder(folder_id)
705 .await
706 .expect("could not get folder");
707
708 assert_eq!(&api_folder.id, folder_id);
709 assert_eq!(&api_folder.path, duplicate_path);
710 }
711
712 #[rstest]
713 #[tokio::test]
714 async fn container_test_post_device(
715 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
716 ) {
717 let (_container, client) = syncthing_setup.await;
718
719 let device = NewDeviceConfiguration::new(DEVICE_ID.to_string());
720
721 client
722 .post_device(device)
723 .await
724 .expect("could not post device");
725
726 let api_device = client
727 .get_device(DEVICE_ID)
728 .await
729 .expect("could not get device");
730
731 assert_eq!(&api_device.device_id, DEVICE_ID);
732 }
733
734 #[rstest]
735 #[tokio::test]
736 async fn container_test_add_device(
737 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
738 ) {
739 let (_container, client) = syncthing_setup.await;
740
741 let device = NewDeviceConfiguration::new(DEVICE_ID.to_string());
742
743 client
744 .add_device(device)
745 .await
746 .expect("could not add device");
747
748 let api_device = client
749 .get_device(DEVICE_ID)
750 .await
751 .expect("could not get device");
752
753 assert_eq!(&api_device.device_id, DEVICE_ID);
754 }
755
756 #[rstest]
757 #[tokio::test]
758 #[should_panic(expected = "DuplicateDeviceError")]
759 async fn container_test_add_device_twice_panic(
760 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
761 ) {
762 let (_container, client) = syncthing_setup.await;
763
764 let device = NewDeviceConfiguration::new(DEVICE_ID.to_string());
765
766 client
767 .add_device(device)
768 .await
769 .expect("could not add device");
770
771 let duplicate_device = NewDeviceConfiguration::new(DEVICE_ID.to_string());
773
774 client
775 .add_device(duplicate_device)
776 .await
777 .expect("could not add device")
778 }
779
780 #[rstest]
781 #[tokio::test]
782 async fn container_test_post_device_twice(
783 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
784 ) {
785 let (_container, client) = syncthing_setup.await;
786 let name = "original";
787
788 let device = NewDeviceConfiguration::new(DEVICE_ID.to_string()).name(name.to_string());
789
790 client
791 .add_device(device)
792 .await
793 .expect("could not add device");
794
795 let duplicate_name = "duplicate";
797 let duplicate_device =
798 NewDeviceConfiguration::new(DEVICE_ID.to_string()).name(duplicate_name.to_string());
799
800 client
801 .post_device(duplicate_device)
802 .await
803 .expect("could not post device");
804
805 let api_device = client
806 .get_device(DEVICE_ID)
807 .await
808 .expect("could not get device");
809
810 assert_eq!(&api_device.device_id, DEVICE_ID);
811 assert_eq!(&api_device.name, duplicate_name);
812 }
813
814 #[rstest]
815 #[tokio::test]
816 async fn container_test_pending_device(
817 #[future]
818 #[from(syncthing_setup)]
819 first: (ContainerAsync<GenericImage>, Client),
820 #[future]
821 #[from(syncthing_setup)]
822 second: (ContainerAsync<GenericImage>, Client),
823 ) {
824 let (_first_container, first_client) = first.await;
825 let (_second_container, second_client) = second.await;
826
827 let first_id = first_client
828 .get_id()
829 .await
830 .expect("could not get id of first container");
831 let second_id = second_client
832 .get_id()
833 .await
834 .expect("could not get id of second container");
835
836 let (event_tx, mut event_rx) = broadcast::channel(10);
838 let first_client_handle = first_client.clone();
839 tokio::spawn(async move {
840 first_client_handle
841 .get_events(event_tx, true)
842 .await
843 .unwrap();
844 });
845
846 second_client
848 .add_device(NewDeviceConfiguration::new(first_id))
849 .await
850 .expect("could not add device");
851
852 loop {
854 let event = event_rx.recv().await.unwrap();
855 match event.ty {
856 EventType::PendingDevicesChanged {
857 added: Some(added), ..
858 } => {
859 if !added.is_empty() {
860 break;
861 }
862 }
863 _ => {}
865 }
866 }
867
868 let pending = first_client
870 .get_pending_devices()
871 .await
872 .expect("could not get pending devices");
873 assert!(pending.devices.contains_key(&second_id));
874 }
875
876 #[rstest]
877 #[tokio::test]
878 async fn container_test_delete_pending_device(
879 #[future]
880 #[from(syncthing_setup)]
881 first: (ContainerAsync<GenericImage>, Client),
882 #[future]
883 #[from(syncthing_setup)]
884 second: (ContainerAsync<GenericImage>, Client),
885 ) {
886 let (_first_container, first_client) = first.await;
887 let (_second_container, second_client) = second.await;
888
889 let first_id = first_client
890 .get_id()
891 .await
892 .expect("could not get id of first container");
893 let second_id = second_client
894 .get_id()
895 .await
896 .expect("could not get id of second container");
897
898 let (event_tx, mut event_rx) = broadcast::channel(10);
900 let first_client_handle = first_client.clone();
901 tokio::spawn(async move {
902 first_client_handle
903 .get_events(event_tx, true)
904 .await
905 .unwrap();
906 });
907
908 second_client
910 .add_device(NewDeviceConfiguration::new(first_id))
911 .await
912 .expect("could not add device");
913
914 loop {
916 let event = event_rx.recv().await.unwrap();
917 match event.ty {
918 EventType::PendingDevicesChanged {
919 added: Some(added), ..
920 } => {
921 if !added.is_empty() {
922 break;
923 }
924 }
925 _ => {}
927 }
928 }
929
930 let pending = first_client
932 .get_pending_devices()
933 .await
934 .expect("could not get pending devices");
935 assert!(pending.devices.contains_key(&second_id));
936
937 first_client
938 .delete_pending_device(&second_id)
939 .await
940 .expect("could not delete pending device");
941
942 loop {
944 let event = event_rx.recv().await.unwrap();
945 match event.ty {
946 EventType::PendingDevicesChanged {
947 removed: Some(removed),
948 ..
949 } => {
950 if !removed.is_empty() {
951 break;
952 }
953 }
954 _ => {}
956 }
957 }
958
959 let pending = first_client
961 .get_pending_devices()
962 .await
963 .expect("could not get pending devices");
964 assert!(!pending.devices.contains_key(&second_id));
965 assert_eq!(pending.devices.len(), 0)
967 }
968
969 #[rstest]
970 #[tokio::test]
971 async fn container_test_get_default_device(
972 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
973 ) {
974 let (_container, client) = syncthing_setup.await;
975
976 client
977 .get_default_device()
978 .await
979 .expect("could not get default device");
980 }
981
982 #[rstest]
983 #[tokio::test]
984 async fn container_test_get_default_folder(
985 #[future] syncthing_setup: (ContainerAsync<GenericImage>, Client),
986 ) {
987 let (_container, client) = syncthing_setup.await;
988
989 client
990 .get_default_folder()
991 .await
992 .expect("could not get default folder");
993 }
994}