Skip to main content

cider_api/
client.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Async HTTP client for the Cider REST API.
6
7use std::time::Duration;
8
9use reqwest::Client;
10use thiserror::Error;
11use tracing::{debug, instrument, warn};
12
13use crate::types::{
14    AmApiRequest, ApiResponse, AutoplayResponse, IsPlayingResponse, NowPlaying,
15    NowPlayingResponse, PlayItemHrefRequest, PlayItemRequest, PlayUrlRequest, QueueItem,
16    QueueMoveRequest, QueueRemoveRequest, RatingRequest, RepeatModeResponse, SeekRequest,
17    ShuffleModeResponse, VolumeRequest, VolumeResponse,
18};
19
20/// Default Cider RPC port.
21pub const DEFAULT_PORT: u16 = 10767;
22
23/// Connection timeout — short because the server is localhost.
24const CONNECTION_TIMEOUT: Duration = Duration::from_secs(1);
25
26/// Per-request timeout.
27const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
28
29/// Errors returned by [`CiderClient`] methods.
30///
31/// # Examples
32///
33/// ```no_run
34/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
35/// use cider_api::{CiderClient, CiderError};
36///
37/// let client = CiderClient::new();
38/// match client.is_active().await {
39///     Ok(()) => println!("Cider is running"),
40///     Err(CiderError::Unauthorized) => println!("Bad API token"),
41///     Err(CiderError::Http(e)) if e.is_connect() => println!("Cider not running"),
42///     Err(e) => println!("Error: {e}"),
43/// }
44/// # Ok(())
45/// # }
46/// ```
47#[derive(Debug, Error)]
48pub enum CiderError {
49    /// An HTTP-level error from [`reqwest`].
50    #[error("HTTP request failed: {0}")]
51    Http(#[from] reqwest::Error),
52
53    /// Cider is not running or the port is unreachable.
54    #[error("Cider is not running or not reachable")]
55    NotReachable,
56
57    /// The API token was rejected (HTTP 401/403).
58    #[error("Invalid API token")]
59    Unauthorized,
60
61    /// No track is currently loaded.
62    #[error("No track currently playing")]
63    NothingPlaying,
64
65    /// Catch-all for unexpected API responses.
66    #[error("API error: {0}")]
67    Api(String),
68}
69
70/// Async client for the [Cider](https://cider.sh) music player REST API.
71///
72/// Communicates with Cider's local HTTP server (default `http://127.0.0.1:10767`)
73/// to control playback, manage the queue, and query track information.
74///
75/// # Construction
76///
77/// ```
78/// use cider_api::CiderClient;
79///
80/// // Default (localhost:10767, no auth)
81/// let client = CiderClient::new();
82///
83/// // Custom port
84/// let client = CiderClient::with_port(9999);
85///
86/// // With authentication
87/// let client = CiderClient::new().with_token("my-token");
88/// ```
89///
90/// The client is cheaply [`Clone`]able — it shares an inner connection pool.
91///
92/// # Errors
93///
94/// All async methods return `Result<_, CiderError>`. Common error cases:
95///
96/// - [`CiderError::Http`] — network or connection failure.
97/// - [`CiderError::Unauthorized`] — invalid API token (HTTP 401/403).
98/// - [`CiderError::Api`] — unexpected response from Cider.
99#[derive(Debug, Clone)]
100pub struct CiderClient {
101    http: Client,
102    base_url: String,
103    api_token: Option<String>,
104}
105
106impl CiderClient {
107    /// Create a new client targeting `http://127.0.0.1:10767`.
108    #[must_use]
109    pub fn new() -> Self {
110        Self::with_port(DEFAULT_PORT)
111    }
112
113    /// Create a new client targeting `http://127.0.0.1:{port}`.
114    ///
115    /// # Panics
116    ///
117    /// Panics if the underlying HTTP client cannot be constructed (only
118    /// possible if TLS initialisation fails at the OS level).
119    #[must_use]
120    pub fn with_port(port: u16) -> Self {
121        let http = Client::builder()
122            .connect_timeout(CONNECTION_TIMEOUT)
123            .timeout(REQUEST_TIMEOUT)
124            .pool_max_idle_per_host(2)
125            .pool_idle_timeout(Duration::from_secs(10))
126            .tcp_keepalive(None)
127            .build()
128            .expect("Failed to build HTTP client");
129
130        Self {
131            http,
132            base_url: format!("http://127.0.0.1:{port}"),
133            api_token: None,
134        }
135    }
136
137    /// Attach an API token for authentication.
138    ///
139    /// The token is sent in the `apitoken` header on every request.
140    /// Generate one in Cider under **Settings > Connectivity > Manage External
141    /// Application Access**.
142    #[must_use]
143    pub fn with_token(mut self, token: impl Into<String>) -> Self {
144        self.api_token = Some(token.into());
145        self
146    }
147
148    // ── Internal helpers ─────────────────────────────────────────────────
149
150    /// Build a request under `/api/v1/playback`.
151    fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
152        let url = format!("{}/api/v1/playback{}", self.base_url, path);
153        let mut req = self.http.request(method, &url);
154        if let Some(token) = &self.api_token {
155            req = req.header("apitoken", token);
156        }
157        req
158    }
159
160    /// Build a request under an arbitrary API path.
161    fn request_raw(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
162        let url = format!("{}{}", self.base_url, path);
163        let mut req = self.http.request(method, &url);
164        if let Some(token) = &self.api_token {
165            req = req.header("apitoken", token);
166        }
167        req
168    }
169
170    // ── Status ───────────────────────────────────────────────────────────
171
172    /// Check that Cider is running and the RPC server is reachable.
173    ///
174    /// Sends `GET /active` — Cider responds with `204 No Content` if alive.
175    ///
176    /// # Errors
177    ///
178    /// - [`CiderError::Unauthorized`] if the token is wrong.
179    /// - [`CiderError::Api`] if the connection is refused or times out.
180    #[instrument(skip(self), fields(base_url = %self.base_url))]
181    pub async fn is_active(&self) -> Result<(), CiderError> {
182        debug!("Checking Cider connection");
183
184        let resp = self
185            .request(reqwest::Method::GET, "/active")
186            .send()
187            .await
188            .map_err(|e| {
189                warn!("Connection error: {e:?}");
190                if e.is_connect() {
191                    CiderError::Api(format!("Connection refused ({e})"))
192                } else if e.is_timeout() {
193                    CiderError::Api("Connection timed out".to_string())
194                } else {
195                    CiderError::Api(format!("Network error ({e})"))
196                }
197            })?;
198
199        debug!("Response status: {}", resp.status());
200
201        match resp.status().as_u16() {
202            200 | 204 => Ok(()),
203            401 | 403 => Err(CiderError::Unauthorized),
204            _ => Err(CiderError::Api(format!(
205                "Unexpected response (HTTP {})",
206                resp.status().as_u16()
207            ))),
208        }
209    }
210
211    /// Check whether music is currently playing.
212    ///
213    /// Sends `GET /is-playing`.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
218    pub async fn is_playing(&self) -> Result<bool, CiderError> {
219        let resp: ApiResponse<IsPlayingResponse> = self
220            .request(reqwest::Method::GET, "/is-playing")
221            .send()
222            .await?
223            .json()
224            .await?;
225
226        Ok(resp.data.is_playing)
227    }
228
229    /// Get the currently playing track.
230    ///
231    /// Returns `None` if nothing is loaded. The returned [`NowPlaying`] includes
232    /// both Apple Music catalog metadata and live playback state
233    /// (`current_playback_time`, `remaining_time`, etc.).
234    ///
235    /// Sends `GET /now-playing`.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`CiderError`] on network failure. Returns `Ok(None)` (not an
240    /// error) if nothing is playing or the response cannot be parsed.
241    pub async fn now_playing(&self) -> Result<Option<NowPlaying>, CiderError> {
242        let resp = self
243            .request(reqwest::Method::GET, "/now-playing")
244            .send()
245            .await?;
246
247        if resp.status() == 404 || resp.status() == 204 {
248            return Ok(None);
249        }
250
251        match resp.json::<ApiResponse<NowPlayingResponse>>().await {
252            Ok(data) => Ok(Some(data.data.info)),
253            Err(_) => Ok(None),
254        }
255    }
256
257    // ── Playback control ─────────────────────────────────────────────────
258
259    /// Resume playback.
260    ///
261    /// If nothing is loaded, the behaviour set under
262    /// **Settings > Play Button on Stopped Action** takes effect.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`CiderError`] if the request fails or the server rejects it.
267    pub async fn play(&self) -> Result<(), CiderError> {
268        self.request(reqwest::Method::POST, "/play")
269            .send()
270            .await?
271            .error_for_status()?;
272        Ok(())
273    }
274
275    /// Pause the current track. No-op if already paused or nothing is playing.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`CiderError`] if the request fails or the server rejects it.
280    pub async fn pause(&self) -> Result<(), CiderError> {
281        self.request(reqwest::Method::POST, "/pause")
282            .send()
283            .await?
284            .error_for_status()?;
285        Ok(())
286    }
287
288    /// Toggle between playing and paused.
289    ///
290    /// # Errors
291    ///
292    /// Returns [`CiderError`] if the request fails or the server rejects it.
293    pub async fn play_pause(&self) -> Result<(), CiderError> {
294        self.request(reqwest::Method::POST, "/playpause")
295            .send()
296            .await?
297            .error_for_status()?;
298        Ok(())
299    }
300
301    /// Stop playback and unload the current track. Queue items are kept.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`CiderError`] if the request fails or the server rejects it.
306    pub async fn stop(&self) -> Result<(), CiderError> {
307        self.request(reqwest::Method::POST, "/stop")
308            .send()
309            .await?
310            .error_for_status()?;
311        Ok(())
312    }
313
314    /// Skip to the next track in the queue.
315    ///
316    /// Respects autoplay status if the queue is empty.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`CiderError`] if the request fails or the server rejects it.
321    pub async fn next(&self) -> Result<(), CiderError> {
322        self.request(reqwest::Method::POST, "/next")
323            .send()
324            .await?
325            .error_for_status()?;
326        Ok(())
327    }
328
329    /// Go back to the previously played track (from playback history).
330    ///
331    /// # Errors
332    ///
333    /// Returns [`CiderError`] if the request fails or the server rejects it.
334    pub async fn previous(&self) -> Result<(), CiderError> {
335        self.request(reqwest::Method::POST, "/previous")
336            .send()
337            .await?
338            .error_for_status()?;
339        Ok(())
340    }
341
342    /// Seek to a position in the current track.
343    ///
344    /// # Arguments
345    ///
346    /// * `position_secs` — target offset in **seconds** (e.g. `30.0`).
347    ///
348    /// # Errors
349    ///
350    /// Returns [`CiderError`] if the request fails or the server rejects it.
351    pub async fn seek(&self, position_secs: f64) -> Result<(), CiderError> {
352        self.request(reqwest::Method::POST, "/seek")
353            .json(&SeekRequest {
354                position: position_secs,
355            })
356            .send()
357            .await?
358            .error_for_status()?;
359        Ok(())
360    }
361
362    /// Convenience wrapper for [`seek`](Self::seek) that accepts milliseconds.
363    ///
364    /// # Errors
365    ///
366    /// Returns [`CiderError`] if the request fails or the server rejects it.
367    pub async fn seek_ms(&self, position_ms: u64) -> Result<(), CiderError> {
368        #[allow(clippy::cast_precision_loss)] // ms precision loss only above ~143 million years
369        let secs = position_ms as f64 / 1000.0;
370        self.seek(secs).await
371    }
372
373    // ── Play items ───────────────────────────────────────────────────────
374
375    /// Start playback of an Apple Music URL.
376    ///
377    /// The URL can be obtained from **Share > Apple Music** in Cider or the
378    /// Apple Music web player.
379    ///
380    /// # Arguments
381    ///
382    /// * `url` — e.g. `"https://music.apple.com/ca/album/…/1719860281"`
383    ///
384    /// # Errors
385    ///
386    /// Returns [`CiderError`] if the request fails or the server rejects it.
387    pub async fn play_url(&self, url: &str) -> Result<(), CiderError> {
388        self.request(reqwest::Method::POST, "/play-url")
389            .json(&PlayUrlRequest {
390                url: url.to_string(),
391            })
392            .send()
393            .await?
394            .error_for_status()?;
395        Ok(())
396    }
397
398    /// Start playback of an item by Apple Music type and catalog ID.
399    ///
400    /// # Arguments
401    ///
402    /// * `item_type` — Apple Music type: `"songs"`, `"albums"`, `"playlists"`, etc.
403    /// * `id` — catalog ID as a **string** (e.g. `"1719861213"`).
404    ///
405    /// # Errors
406    ///
407    /// Returns [`CiderError`] if the request fails or the server rejects it.
408    pub async fn play_item(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
409        self.request(reqwest::Method::POST, "/play-item")
410            .json(&PlayItemRequest {
411                item_type: item_type.to_string(),
412                id: id.to_string(),
413            })
414            .send()
415            .await?
416            .error_for_status()?;
417        Ok(())
418    }
419
420    /// Start playback of an item by its Apple Music API href.
421    ///
422    /// # Arguments
423    ///
424    /// * `href` — API path, e.g. `"/v1/catalog/ca/songs/1719861213"`.
425    ///
426    /// # Errors
427    ///
428    /// Returns [`CiderError`] if the request fails or the server rejects it.
429    pub async fn play_item_href(&self, href: &str) -> Result<(), CiderError> {
430        self.request(reqwest::Method::POST, "/play-item-href")
431            .json(&PlayItemHrefRequest {
432                href: href.to_string(),
433            })
434            .send()
435            .await?
436            .error_for_status()?;
437        Ok(())
438    }
439
440    /// Add an item to the **start** of the queue (plays next).
441    ///
442    /// # Arguments
443    ///
444    /// * `item_type` — `"songs"`, `"albums"`, etc.
445    /// * `id` — catalog ID as a string.
446    ///
447    /// # Errors
448    ///
449    /// Returns [`CiderError`] if the request fails or the server rejects it.
450    pub async fn play_next(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
451        self.request(reqwest::Method::POST, "/play-next")
452            .json(&PlayItemRequest {
453                item_type: item_type.to_string(),
454                id: id.to_string(),
455            })
456            .send()
457            .await?
458            .error_for_status()?;
459        Ok(())
460    }
461
462    /// Add an item to the **end** of the queue (plays last).
463    ///
464    /// # Arguments
465    ///
466    /// * `item_type` — `"songs"`, `"albums"`, etc.
467    /// * `id` — catalog ID as a string.
468    ///
469    /// # Errors
470    ///
471    /// Returns [`CiderError`] if the request fails or the server rejects it.
472    pub async fn play_later(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
473        self.request(reqwest::Method::POST, "/play-later")
474            .json(&PlayItemRequest {
475                item_type: item_type.to_string(),
476                id: id.to_string(),
477            })
478            .send()
479            .await?
480            .error_for_status()?;
481        Ok(())
482    }
483
484    // ── Queue ────────────────────────────────────────────────────────────
485
486    /// Get the current playback queue.
487    ///
488    /// Returns a [`Vec<QueueItem>`] that includes history items, the currently
489    /// playing track, and upcoming items. Use [`QueueItem::is_current`] to
490    /// find the active track.
491    ///
492    /// Returns an empty `Vec` if the queue is empty or the response format is
493    /// unexpected.
494    ///
495    /// # Errors
496    ///
497    /// Returns [`CiderError`] on network failure. Returns `Ok(vec![])` (not an
498    /// error) if the queue is empty or the format is unrecognised.
499    pub async fn get_queue(&self) -> Result<Vec<QueueItem>, CiderError> {
500        let resp = self
501            .request(reqwest::Method::GET, "/queue")
502            .send()
503            .await?;
504
505        let status = resp.status();
506        if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::NO_CONTENT {
507            return Ok(vec![]);
508        }
509
510        let text = resp.text().await?;
511        match serde_json::from_str::<Vec<QueueItem>>(&text) {
512            Ok(items) => Ok(items),
513            Err(_) => Ok(vec![]),
514        }
515    }
516
517    /// Move a queue item from one position to another.
518    ///
519    /// Both indices are **1-based**. The queue includes history items, so the
520    /// first visible "Up Next" item may not be at index 1.
521    ///
522    /// # Errors
523    ///
524    /// Returns [`CiderError`] if the request fails or the server rejects it.
525    pub async fn queue_move_to_position(
526        &self,
527        start_index: u32,
528        destination_index: u32,
529    ) -> Result<(), CiderError> {
530        self.request(reqwest::Method::POST, "/queue/move-to-position")
531            .json(&QueueMoveRequest {
532                start_index,
533                destination_index,
534                return_queue: None,
535            })
536            .send()
537            .await?
538            .error_for_status()?;
539        Ok(())
540    }
541
542    /// Remove a queue item by its **1-based** index.
543    ///
544    /// # Errors
545    ///
546    /// Returns [`CiderError`] if the request fails or the server rejects it.
547    pub async fn queue_remove_by_index(&self, index: u32) -> Result<(), CiderError> {
548        self.request(reqwest::Method::POST, "/queue/remove-by-index")
549            .json(&QueueRemoveRequest { index })
550            .send()
551            .await?
552            .error_for_status()?;
553        Ok(())
554    }
555
556    /// Clear all items from the queue.
557    ///
558    /// # Errors
559    ///
560    /// Returns [`CiderError`] if the request fails or the server rejects it.
561    pub async fn clear_queue(&self) -> Result<(), CiderError> {
562        self.request(reqwest::Method::POST, "/queue/clear-queue")
563            .send()
564            .await?
565            .error_for_status()?;
566        Ok(())
567    }
568
569    // ── Volume ───────────────────────────────────────────────────────────
570
571    /// Get the current volume (`0.0` = muted, `1.0` = full).
572    ///
573    /// # Errors
574    ///
575    /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
576    pub async fn get_volume(&self) -> Result<f32, CiderError> {
577        let resp: ApiResponse<VolumeResponse> = self
578            .request(reqwest::Method::GET, "/volume")
579            .send()
580            .await?
581            .json()
582            .await?;
583
584        Ok(resp.data.volume)
585    }
586
587    /// Set the volume. Values are clamped to `0.0..=1.0`.
588    ///
589    /// # Errors
590    ///
591    /// Returns [`CiderError`] if the request fails or the server rejects it.
592    pub async fn set_volume(&self, volume: f32) -> Result<(), CiderError> {
593        self.request(reqwest::Method::POST, "/volume")
594            .json(&VolumeRequest {
595                volume: volume.clamp(0.0, 1.0),
596            })
597            .send()
598            .await?
599            .error_for_status()?;
600        Ok(())
601    }
602
603    // ── Library / ratings ────────────────────────────────────────────────
604
605    /// Add the currently playing track to the user's library.
606    ///
607    /// No-op if the track is already in the library.
608    ///
609    /// # Errors
610    ///
611    /// Returns [`CiderError`] if the request fails or the server rejects it.
612    pub async fn add_to_library(&self) -> Result<(), CiderError> {
613        self.request(reqwest::Method::POST, "/add-to-library")
614            .send()
615            .await?
616            .error_for_status()?;
617        Ok(())
618    }
619
620    /// Rate the currently playing track.
621    ///
622    /// * `-1` — dislike
623    /// * `0` — remove rating
624    /// * `1` — like
625    ///
626    /// The value is clamped to `-1..=1`.
627    ///
628    /// # Errors
629    ///
630    /// Returns [`CiderError`] if the request fails or the server rejects it.
631    pub async fn set_rating(&self, rating: i8) -> Result<(), CiderError> {
632        self.request(reqwest::Method::POST, "/set-rating")
633            .json(&RatingRequest {
634                rating: rating.clamp(-1, 1),
635            })
636            .send()
637            .await?
638            .error_for_status()?;
639        Ok(())
640    }
641
642    // ── Repeat / shuffle / autoplay ──────────────────────────────────────
643
644    /// Get the current repeat mode.
645    ///
646    /// * `0` — off
647    /// * `1` — repeat this song
648    /// * `2` — repeat all
649    ///
650    /// # Errors
651    ///
652    /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
653    pub async fn get_repeat_mode(&self) -> Result<u8, CiderError> {
654        let resp: ApiResponse<RepeatModeResponse> = self
655            .request(reqwest::Method::GET, "/repeat-mode")
656            .send()
657            .await?
658            .json()
659            .await?;
660
661        Ok(resp.data.value)
662    }
663
664    /// Cycle repeat mode: **repeat one > repeat all > off**.
665    ///
666    /// # Errors
667    ///
668    /// Returns [`CiderError`] if the request fails or the server rejects it.
669    pub async fn toggle_repeat(&self) -> Result<(), CiderError> {
670        self.request(reqwest::Method::POST, "/toggle-repeat")
671            .send()
672            .await?
673            .error_for_status()?;
674        Ok(())
675    }
676
677    /// Get the current shuffle mode (`0` = off, `1` = on).
678    ///
679    /// # Errors
680    ///
681    /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
682    pub async fn get_shuffle_mode(&self) -> Result<u8, CiderError> {
683        let resp: ApiResponse<ShuffleModeResponse> = self
684            .request(reqwest::Method::GET, "/shuffle-mode")
685            .send()
686            .await?
687            .json()
688            .await?;
689
690        Ok(resp.data.value)
691    }
692
693    /// Toggle shuffle on/off.
694    ///
695    /// # Errors
696    ///
697    /// Returns [`CiderError`] if the request fails or the server rejects it.
698    pub async fn toggle_shuffle(&self) -> Result<(), CiderError> {
699        self.request(reqwest::Method::POST, "/toggle-shuffle")
700            .send()
701            .await?
702            .error_for_status()?;
703        Ok(())
704    }
705
706    /// Get the current autoplay status (`true` = on).
707    ///
708    /// # Errors
709    ///
710    /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
711    pub async fn get_autoplay(&self) -> Result<bool, CiderError> {
712        let resp: ApiResponse<AutoplayResponse> = self
713            .request(reqwest::Method::GET, "/autoplay")
714            .send()
715            .await?
716            .json()
717            .await?;
718
719        Ok(resp.data.value)
720    }
721
722    /// Toggle autoplay on/off.
723    ///
724    /// # Errors
725    ///
726    /// Returns [`CiderError`] if the request fails or the server rejects it.
727    pub async fn toggle_autoplay(&self) -> Result<(), CiderError> {
728        self.request(reqwest::Method::POST, "/toggle-autoplay")
729            .send()
730            .await?
731            .error_for_status()?;
732        Ok(())
733    }
734
735    // ── Apple Music API passthrough ──────────────────────────────────────
736
737    /// Execute a raw Apple Music API request via Cider's passthrough.
738    ///
739    /// Sends `POST /api/v1/amapi/run-v3` with the given `path`, and returns
740    /// the raw JSON response from Apple Music.
741    ///
742    /// # Arguments
743    ///
744    /// * `path` — Apple Music API path, e.g. `"/v1/me/library/songs"` or
745    ///   `"/v1/catalog/us/search?term=flume&types=songs"`.
746    ///
747    /// # Errors
748    ///
749    /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
750    pub async fn amapi_run_v3(&self, path: &str) -> Result<serde_json::Value, CiderError> {
751        let resp = self
752            .request_raw(reqwest::Method::POST, "/api/v1/amapi/run-v3")
753            .json(&AmApiRequest {
754                path: path.to_string(),
755            })
756            .send()
757            .await?
758            .error_for_status()?;
759
760        resp.json().await.map_err(CiderError::from)
761    }
762}
763
764impl Default for CiderClient {
765    fn default() -> Self {
766        Self::new()
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[test]
775    fn default_client() {
776        let client = CiderClient::new();
777        assert_eq!(client.base_url, "http://127.0.0.1:10767");
778        assert!(client.api_token.is_none());
779    }
780
781    #[test]
782    fn client_with_token() {
783        let client = CiderClient::new().with_token("test-token");
784        assert_eq!(client.api_token, Some("test-token".to_string()));
785    }
786
787    #[test]
788    fn client_custom_port() {
789        let client = CiderClient::with_port(9999);
790        assert_eq!(client.base_url, "http://127.0.0.1:9999");
791    }
792
793    #[test]
794    fn client_is_clone() {
795        let a = CiderClient::new();
796        let b = a.clone();
797        assert_eq!(a.base_url, b.base_url);
798    }
799}