spotify_cli/http/
api.rs

1//! Authenticated Spotify Web API client.
2//!
3//! All requests are made with Bearer token authentication to api.spotify.com/v1.
4//! Includes automatic retry with exponential backoff for rate limiting.
5
6use reqwest::Method;
7use serde_json::Value;
8use std::time::{Duration, Instant};
9use tokio::time::sleep;
10use tracing::{debug, info, trace, warn};
11
12use super::client::{HttpClient, HttpError};
13use crate::constants::{MAX_API_RETRIES, SPOTIFY_API_BASE_URL};
14
15/// Spotify Web API client.
16///
17/// Makes authenticated HTTP requests to the Spotify API.
18/// All methods require a valid access token.
19pub struct SpotifyApi {
20    http: HttpClient,
21    access_token: String,
22    base_url: String,
23}
24
25impl SpotifyApi {
26    /// Create a new API client with the given access token.
27    pub fn new(access_token: String) -> Self {
28        Self {
29            http: HttpClient::new(),
30            access_token,
31            base_url: SPOTIFY_API_BASE_URL.to_string(),
32        }
33    }
34
35    /// Create a new API client with a custom base URL.
36    ///
37    /// Useful for testing with mock servers or connecting to alternative endpoints.
38    pub fn with_base_url(access_token: String, base_url: String) -> Self {
39        Self {
40            http: HttpClient::new(),
41            access_token,
42            base_url,
43        }
44    }
45
46    /// Build a full API URL from a path.
47    fn url(&self, path: &str) -> String {
48        format!("{}{}", self.base_url, path)
49    }
50
51    /// Core request method - all HTTP methods delegate to this.
52    /// Includes automatic retry with exponential backoff for rate limiting.
53    async fn request(
54        &self,
55        method: Method,
56        path: &str,
57        body: Option<&Value>,
58    ) -> Result<Option<Value>, HttpError> {
59        let mut retries = 0;
60
61        loop {
62            debug!(method = %method, path = %path, retry = retries, "API request");
63            trace!(body = ?body, "Request body");
64
65            let mut req = self
66                .http
67                .inner()
68                .request(method.clone(), self.url(path))
69                .header("Authorization", format!("Bearer {}", self.access_token));
70
71            req = match body {
72                Some(json) => req.json(json),
73                // GET requests don't need Content-Length, other methods need it for empty body
74                None if method != Method::GET => req.header("Content-Length", "0"),
75                None => req,
76            };
77
78            let start = Instant::now();
79            let response = req.send().await?;
80            let elapsed_ms = start.elapsed().as_millis();
81
82            // Log response details
83            let status = response.status().as_u16();
84            let rate_limit = response
85                .headers()
86                .get("x-ratelimit-remaining")
87                .and_then(|v| v.to_str().ok())
88                .and_then(|s| s.parse::<u32>().ok());
89
90            info!(
91                method = %method,
92                path = %path,
93                status = status,
94                elapsed_ms = elapsed_ms,
95                rate_limit_remaining = ?rate_limit,
96                "API response"
97            );
98
99            let result = Self::handle_response(response).await;
100
101            match &result {
102                Err(HttpError::RateLimited { retry_after_secs }) if retries < MAX_API_RETRIES => {
103                    let wait_secs = *retry_after_secs;
104                    warn!(
105                        method = %method,
106                        path = %path,
107                        retry = retries + 1,
108                        wait_secs = wait_secs,
109                        "Rate limited, retrying"
110                    );
111                    sleep(Duration::from_secs(wait_secs)).await;
112                    retries += 1;
113                    continue;
114                }
115                _ => return result,
116            }
117        }
118    }
119
120    /// Make a GET request.
121    pub async fn get(&self, path: &str) -> Result<Option<Value>, HttpError> {
122        self.request(Method::GET, path, None).await
123    }
124
125    /// Make a POST request without body.
126    pub async fn post(&self, path: &str) -> Result<Option<Value>, HttpError> {
127        self.request(Method::POST, path, None).await
128    }
129
130    /// Make a POST request with JSON body.
131    pub async fn post_json(&self, path: &str, body: &Value) -> Result<Option<Value>, HttpError> {
132        self.request(Method::POST, path, Some(body)).await
133    }
134
135    /// Make a PUT request without body.
136    pub async fn put(&self, path: &str) -> Result<Option<Value>, HttpError> {
137        self.request(Method::PUT, path, None).await
138    }
139
140    /// Make a PUT request with JSON body.
141    pub async fn put_json(&self, path: &str, body: &Value) -> Result<Option<Value>, HttpError> {
142        self.request(Method::PUT, path, Some(body)).await
143    }
144
145    /// Make a DELETE request without body.
146    pub async fn delete(&self, path: &str) -> Result<Option<Value>, HttpError> {
147        self.request(Method::DELETE, path, None).await
148    }
149
150    /// Make a DELETE request with JSON body.
151    pub async fn delete_json(&self, path: &str, body: &Value) -> Result<Option<Value>, HttpError> {
152        self.request(Method::DELETE, path, Some(body)).await
153    }
154
155    async fn handle_response(response: reqwest::Response) -> Result<Option<Value>, HttpError> {
156        let status = response.status();
157
158        if status == reqwest::StatusCode::NO_CONTENT {
159            return Ok(None);
160        }
161
162        if !status.is_success() {
163            return Err(HttpError::from_response(response).await);
164        }
165
166        // Handle response bodies for successful requests
167        // Some endpoints return 200/202 with no body, plain text, or non-JSON responses
168        let bytes = response.bytes().await?;
169        if bytes.is_empty() {
170            return Ok(None);
171        }
172
173        // Try to parse as JSON, but don't error on failure for success responses
174        // Spotify sometimes returns plain text tokens/IDs for control endpoints
175        match serde_json::from_slice(&bytes) {
176            Ok(json) => Ok(Some(json)),
177            Err(_) => {
178                trace!(body = ?String::from_utf8_lossy(&bytes), "Non-JSON success response");
179                Ok(None)
180            }
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use serde_json::json;
189    use wiremock::matchers::{header, method, path};
190    use wiremock::{Mock, MockServer, ResponseTemplate};
191
192    async fn setup_mock_server() -> (MockServer, SpotifyApi) {
193        let mock_server = MockServer::start().await;
194        let api = SpotifyApi::with_base_url("test_token".to_string(), mock_server.uri());
195        (mock_server, api)
196    }
197
198    #[tokio::test]
199    async fn get_request_returns_json() {
200        let (mock_server, api) = setup_mock_server().await;
201
202        Mock::given(method("GET"))
203            .and(path("/me"))
204            .and(header("Authorization", "Bearer test_token"))
205            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
206                "id": "user123",
207                "display_name": "Test User"
208            })))
209            .mount(&mock_server)
210            .await;
211
212        let result = api.get("/me").await.unwrap();
213        assert!(result.is_some());
214        let payload = result.unwrap();
215        assert_eq!(payload["id"], "user123");
216        assert_eq!(payload["display_name"], "Test User");
217    }
218
219    #[tokio::test]
220    async fn get_request_handles_204_no_content() {
221        let (mock_server, api) = setup_mock_server().await;
222
223        Mock::given(method("GET"))
224            .and(path("/empty"))
225            .respond_with(ResponseTemplate::new(204))
226            .mount(&mock_server)
227            .await;
228
229        let result = api.get("/empty").await.unwrap();
230        assert!(result.is_none());
231    }
232
233    #[tokio::test]
234    async fn get_request_handles_401_unauthorized() {
235        let (mock_server, api) = setup_mock_server().await;
236
237        Mock::given(method("GET"))
238            .and(path("/protected"))
239            .respond_with(ResponseTemplate::new(401).set_body_json(json!({
240                "error": {
241                    "status": 401,
242                    "message": "Invalid access token"
243                }
244            })))
245            .mount(&mock_server)
246            .await;
247
248        let result = api.get("/protected").await;
249        assert!(matches!(result, Err(HttpError::Unauthorized)));
250    }
251
252    #[tokio::test]
253    async fn get_request_handles_404_not_found() {
254        let (mock_server, api) = setup_mock_server().await;
255
256        Mock::given(method("GET"))
257            .and(path("/missing"))
258            .respond_with(ResponseTemplate::new(404))
259            .mount(&mock_server)
260            .await;
261
262        let result = api.get("/missing").await;
263        assert!(matches!(result, Err(HttpError::NotFound)));
264    }
265
266    #[tokio::test]
267    async fn post_request_sends_empty_body() {
268        let (mock_server, api) = setup_mock_server().await;
269
270        Mock::given(method("POST"))
271            .and(path("/player/next"))
272            .and(header("Content-Length", "0"))
273            .respond_with(ResponseTemplate::new(204))
274            .mount(&mock_server)
275            .await;
276
277        let result = api.post("/player/next").await.unwrap();
278        assert!(result.is_none());
279    }
280
281    #[tokio::test]
282    async fn post_json_sends_body() {
283        let (mock_server, api) = setup_mock_server().await;
284
285        Mock::given(method("POST"))
286            .and(path("/playlists"))
287            .and(header("Authorization", "Bearer test_token"))
288            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
289                "id": "playlist123"
290            })))
291            .mount(&mock_server)
292            .await;
293
294        let body = json!({"name": "My Playlist"});
295        let result = api.post_json("/playlists", &body).await.unwrap();
296        assert!(result.is_some());
297        assert_eq!(result.unwrap()["id"], "playlist123");
298    }
299
300    #[tokio::test]
301    async fn put_request_works() {
302        let (mock_server, api) = setup_mock_server().await;
303
304        Mock::given(method("PUT"))
305            .and(path("/me/player/play"))
306            .respond_with(ResponseTemplate::new(204))
307            .mount(&mock_server)
308            .await;
309
310        let result = api.put("/me/player/play").await.unwrap();
311        assert!(result.is_none());
312    }
313
314    #[tokio::test]
315    async fn delete_request_works() {
316        let (mock_server, api) = setup_mock_server().await;
317
318        Mock::given(method("DELETE"))
319            .and(path("/playlists/123/tracks"))
320            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
321                "snapshot_id": "abc123"
322            })))
323            .mount(&mock_server)
324            .await;
325
326        let result = api.delete("/playlists/123/tracks").await.unwrap();
327        assert!(result.is_some());
328    }
329
330    #[tokio::test]
331    async fn handles_api_error_with_message() {
332        let (mock_server, api) = setup_mock_server().await;
333
334        Mock::given(method("GET"))
335            .and(path("/error"))
336            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
337                "error": {
338                    "status": 400,
339                    "message": "Invalid market code"
340                }
341            })))
342            .mount(&mock_server)
343            .await;
344
345        let result = api.get("/error").await;
346        match result {
347            Err(HttpError::Api { status, message }) => {
348                assert_eq!(status, 400);
349                assert_eq!(message, "Invalid market code");
350            }
351            _ => panic!("Expected Api error"),
352        }
353    }
354
355    #[tokio::test]
356    async fn url_building() {
357        let api =
358            SpotifyApi::with_base_url("token".to_string(), "https://api.example.com".to_string());
359        assert_eq!(api.url("/me"), "https://api.example.com/me");
360        assert_eq!(api.url("/tracks/123"), "https://api.example.com/tracks/123");
361    }
362}