1use 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
15pub struct SpotifyApi {
20 http: HttpClient,
21 access_token: String,
22 base_url: String,
23}
24
25impl SpotifyApi {
26 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 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 fn url(&self, path: &str) -> String {
48 format!("{}{}", self.base_url, path)
49 }
50
51 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 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 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 pub async fn get(&self, path: &str) -> Result<Option<Value>, HttpError> {
122 self.request(Method::GET, path, None).await
123 }
124
125 pub async fn post(&self, path: &str) -> Result<Option<Value>, HttpError> {
127 self.request(Method::POST, path, None).await
128 }
129
130 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 pub async fn put(&self, path: &str) -> Result<Option<Value>, HttpError> {
137 self.request(Method::PUT, path, None).await
138 }
139
140 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 pub async fn delete(&self, path: &str) -> Result<Option<Value>, HttpError> {
147 self.request(Method::DELETE, path, None).await
148 }
149
150 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 let bytes = response.bytes().await?;
169 if bytes.is_empty() {
170 return Ok(None);
171 }
172
173 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}