1use super::{
8 DEFAULT_RETRY_AFTER_SECS, FUTURES_CONTRACT_HEADER, FUTURES_CONTRACT_HEADER_VALUE,
9 RobinhoodClient,
10};
11use crate::pagination::{CursorPaginatedResponse, PaginatedResponse};
12use crate::{Result, RhoodError};
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15
16impl RobinhoodClient {
17 pub async fn get<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
24 let auth = self.require_auth().await?;
25 let res = self
26 .http
27 .get(url)
28 .header("Authorization", &auth)
29 .send()
30 .await?;
31 handle_response(res).await
32 }
33
34 pub async fn get_with_params<T: DeserializeOwned>(
42 &self,
43 url: &str,
44 params: &[(&str, &str)],
45 ) -> Result<T> {
46 let auth = self.require_auth().await?;
47 let res = self
48 .http
49 .get(url)
50 .header("Authorization", &auth)
51 .query(params)
52 .send()
53 .await?;
54 handle_response(res).await
55 }
56
57 pub async fn get_paginated<T: DeserializeOwned>(
65 &self,
66 url: &str,
67 params: &[(&str, &str)],
68 ) -> Result<Vec<T>> {
69 let auth = self.require_auth().await?;
70 let res = self
71 .http
72 .get(url)
73 .header("Authorization", &auth)
74 .query(params)
75 .send()
76 .await?;
77 let mut page: PaginatedResponse<T> = handle_response(res).await?;
78 let mut all_results = page.results;
79 while let Some(next_url) = page.next {
80 let res = self
81 .http
82 .get(&next_url)
83 .header("Authorization", &auth)
84 .send()
85 .await?;
86 page = handle_response(res).await?;
87 all_results.extend(page.results);
88 }
89 Ok(all_results)
90 }
91
92 pub(crate) async fn get_futures<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
101 let auth = self.require_auth().await?;
102 let res = self
103 .http
104 .get(url)
105 .header("Authorization", &auth)
106 .header(FUTURES_CONTRACT_HEADER, FUTURES_CONTRACT_HEADER_VALUE)
107 .send()
108 .await?;
109 handle_response(res).await
110 }
111
112 pub(crate) async fn get_futures_with_params<T: DeserializeOwned>(
120 &self,
121 url: &str,
122 params: &[(&str, &str)],
123 ) -> Result<T> {
124 let auth = self.require_auth().await?;
125 let res = self
126 .http
127 .get(url)
128 .header("Authorization", &auth)
129 .header(FUTURES_CONTRACT_HEADER, FUTURES_CONTRACT_HEADER_VALUE)
130 .query(params)
131 .send()
132 .await?;
133 handle_response(res).await
134 }
135
136 pub(crate) async fn get_futures_cursor_paginated<T: DeserializeOwned>(
147 &self,
148 url: &str,
149 params: &[(&str, &str)],
150 ) -> Result<Vec<T>> {
151 let auth = self.require_auth().await?;
152 let res = self
153 .http
154 .get(url)
155 .header("Authorization", &auth)
156 .header(FUTURES_CONTRACT_HEADER, FUTURES_CONTRACT_HEADER_VALUE)
157 .query(params)
158 .send()
159 .await?;
160 let mut page: CursorPaginatedResponse<T> = handle_response(res).await?;
161 let mut all_results = page.results;
162 while let Some(cursor) = page.next {
163 let mut next_params: Vec<(&str, &str)> = params.to_vec();
164 let cursor_owned = cursor;
165 next_params.push(("cursor", &cursor_owned));
166 let res = self
167 .http
168 .get(url)
169 .header("Authorization", &auth)
170 .header(FUTURES_CONTRACT_HEADER, FUTURES_CONTRACT_HEADER_VALUE)
171 .query(&next_params)
172 .send()
173 .await?;
174 page = handle_response(res).await?;
175 all_results.extend(page.results);
176 }
177 Ok(all_results)
178 }
179
180 pub async fn post_form<T: DeserializeOwned, P: Serialize + ?Sized>(
188 &self,
189 url: &str,
190 payload: &P,
191 ) -> Result<T> {
192 let auth = self.require_auth().await?;
193 let res = self
194 .http
195 .post(url)
196 .header("Authorization", &auth)
197 .form(payload)
198 .send()
199 .await?;
200 handle_response(res).await
201 }
202
203 pub async fn post_json<T: DeserializeOwned, P: Serialize + ?Sized>(
211 &self,
212 url: &str,
213 payload: &P,
214 ) -> Result<T> {
215 let auth = self.require_auth().await?;
216 let res = self
217 .http
218 .post(url)
219 .header("Authorization", &auth)
220 .json(payload)
221 .send()
222 .await?;
223 handle_response(res).await
224 }
225
226 pub async fn post_empty(&self, url: &str) -> Result<()> {
233 let auth = self.require_auth().await?;
234 let res = self
235 .http
236 .post(url)
237 .header("Authorization", &auth)
238 .send()
239 .await?;
240 let status = res.status();
241 let body = res.text().await.unwrap_or_default();
242 tracing::debug!(
243 status = status.as_u16(),
244 url = %url,
245 body_len = body.len(),
246 "API response"
247 );
248 if status.is_success() {
249 Ok(())
250 } else {
251 Err(RhoodError::Api {
252 status: status.as_u16(),
253 message: redacted_response_body_message(&body),
254 })
255 }
256 }
257
258 pub async fn delete(&self, url: &str) -> Result<()> {
265 let auth = self.require_auth().await?;
266 let res = self
267 .http
268 .delete(url)
269 .header("Authorization", &auth)
270 .send()
271 .await?;
272 let status = res.status();
273 let body = res.text().await.unwrap_or_default();
274 tracing::debug!(
275 status = status.as_u16(),
276 url = %url,
277 body_len = body.len(),
278 "API response"
279 );
280 if status.is_success() {
281 Ok(())
282 } else {
283 Err(RhoodError::Api {
284 status: status.as_u16(),
285 message: redacted_response_body_message(&body),
286 })
287 }
288 }
289
290 pub async fn patch_json<T: DeserializeOwned, P: Serialize + ?Sized>(
298 &self,
299 url: &str,
300 payload: &P,
301 ) -> Result<T> {
302 let auth = self.require_auth().await?;
303 let res = self
304 .http
305 .patch(url)
306 .header("Authorization", &auth)
307 .json(payload)
308 .send()
309 .await?;
310 handle_response(res).await
311 }
312}
313
314async fn handle_response<T: DeserializeOwned>(res: reqwest::Response) -> Result<T> {
315 let status = res.status();
316 if status.as_u16() == 429 {
317 let retry_after = res
318 .headers()
319 .get("retry-after")
320 .and_then(|header| header.to_str().ok())
321 .and_then(|text| text.parse().ok())
322 .unwrap_or(DEFAULT_RETRY_AFTER_SECS);
323 return Err(RhoodError::RateLimited {
324 retry_after_secs: retry_after,
325 });
326 }
327 let url = res.url().clone();
328 let body = res.text().await.unwrap_or_default();
329 tracing::debug!(
330 status = status.as_u16(),
331 url = %url,
332 body_len = body.len(),
333 "API response"
334 );
335 if !status.is_success() {
336 return Err(RhoodError::Api {
337 status: status.as_u16(),
338 message: redacted_response_body_message(&body),
339 });
340 }
341 serde_json::from_str::<T>(&body).map_err(|e| RhoodError::Api {
342 status: status.as_u16(),
343 message: format!("Failed to parse response from {url}: {e}"),
344 })
345}
346
347pub(super) fn redacted_response_body_message(body: &str) -> String {
348 let Ok(serde_json::Value::Object(fields)) = serde_json::from_str(body) else {
349 return format!("Response body omitted ({} bytes)", body.len());
350 };
351
352 let mut diagnostic = serde_json::Map::new();
353 if let Some(instruments) = fields
354 .get("missing_instruments")
355 .and_then(serde_json::Value::as_array)
356 {
357 diagnostic.insert(
358 "missing_instruments".to_string(),
359 serde_json::Value::Array(
360 instruments
361 .iter()
362 .filter_map(serde_json::Value::as_str)
363 .map(|instrument| serde_json::Value::String(instrument.to_string()))
364 .collect(),
365 ),
366 );
367 }
368 for key in ["detail", "message"] {
369 if let Some(value) = fields
370 .get(key)
371 .and_then(serde_json::Value::as_str)
372 .map(|value| serde_json::Value::String(value.to_string()))
373 {
374 diagnostic.insert(key.to_string(), value);
375 }
376 }
377 if let Some(message) = fields
378 .get("error")
379 .and_then(serde_json::Value::as_object)
380 .and_then(|error| error.get("message"))
381 .and_then(serde_json::Value::as_str)
382 {
383 diagnostic.insert(
384 "error".to_string(),
385 serde_json::json!({ "message": message }),
386 );
387 }
388
389 if diagnostic.is_empty() {
390 format!("Response body omitted ({} bytes)", body.len())
391 } else {
392 serde_json::Value::Object(diagnostic).to_string()
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::super::test_config_with_tempdir;
399 use super::*;
400 use crate::auth::AuthState;
401 use secrecy::SecretString;
402 use serde::Deserialize;
403 use wiremock::matchers::{
404 body_string_contains, header, method, path, query_param, query_param_is_missing,
405 };
406 use wiremock::{Mock, MockServer, ResponseTemplate};
407
408 #[derive(Debug, Deserialize, PartialEq, Eq)]
409 struct TestBody {
410 value: String,
411 }
412
413 async fn authenticated_client(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
414 let dir = tempfile::tempdir().unwrap();
415 let mut config = test_config_with_tempdir(&dir);
416 config.api.base_url = base_url.to_string();
417 let client = RobinhoodClient::with_config(config).unwrap();
418 *client.auth_state.write().await = AuthState::Authenticated {
419 access_token: SecretString::from("access-token"),
420 token_type: "Bearer".to_string(),
421 refresh_token: SecretString::from("refresh-token"),
422 };
423 (dir, client)
424 }
425
426 #[test]
427 fn futures_header_constant_is_correct() {
428 assert_eq!(FUTURES_CONTRACT_HEADER, "Rh-Contract-Protected");
429 assert_eq!(FUTURES_CONTRACT_HEADER_VALUE, "true");
430 }
431
432 #[tokio::test]
433 async fn get_sends_auth_header_and_deserializes_json() {
434 let server = MockServer::start().await;
435 Mock::given(method("GET"))
436 .and(path("/plain"))
437 .and(header("Authorization", "Bearer access-token"))
438 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
439 "value": "ok"
440 })))
441 .mount(&server)
442 .await;
443 let (_dir, client) = authenticated_client(&server.uri()).await;
444
445 let body: TestBody = client
446 .get(&format!("{}/plain", server.uri()))
447 .await
448 .unwrap();
449
450 assert_eq!(body, TestBody { value: "ok".into() });
451 }
452
453 #[tokio::test]
454 async fn get_with_params_sends_query_params() {
455 let server = MockServer::start().await;
456 Mock::given(method("GET"))
457 .and(path("/search"))
458 .and(query_param("symbol", "HOOD"))
459 .and(query_param("active", "true"))
460 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
461 "value": "found"
462 })))
463 .mount(&server)
464 .await;
465 let (_dir, client) = authenticated_client(&server.uri()).await;
466
467 let body: TestBody = client
468 .get_with_params(
469 &format!("{}/search", server.uri()),
470 &[("symbol", "HOOD"), ("active", "true")],
471 )
472 .await
473 .unwrap();
474
475 assert_eq!(body.value, "found");
476 }
477
478 #[tokio::test]
479 async fn get_paginated_follows_next_links() {
480 let server = MockServer::start().await;
481 let next_url = format!("{}/page-2", server.uri());
482 Mock::given(method("GET"))
483 .and(path("/page-1"))
484 .and(query_param("nonzero", "true"))
485 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
486 "results": [1, 2],
487 "next": next_url,
488 "previous": null
489 })))
490 .mount(&server)
491 .await;
492 Mock::given(method("GET"))
493 .and(path("/page-2"))
494 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
495 "results": [3],
496 "next": null,
497 "previous": null
498 })))
499 .mount(&server)
500 .await;
501 let (_dir, client) = authenticated_client(&server.uri()).await;
502
503 let values: Vec<i32> = client
504 .get_paginated(&format!("{}/page-1", server.uri()), &[("nonzero", "true")])
505 .await
506 .unwrap();
507
508 assert_eq!(values, vec![1, 2, 3]);
509 }
510
511 #[tokio::test]
512 async fn futures_gets_include_contract_header_and_cursor_pagination() {
513 let server = MockServer::start().await;
514 Mock::given(method("GET"))
515 .and(path("/futures"))
516 .and(header(
517 FUTURES_CONTRACT_HEADER,
518 FUTURES_CONTRACT_HEADER_VALUE,
519 ))
520 .and(query_param("symbol", "ES"))
521 .and(query_param_is_missing("cursor"))
522 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
523 "results": [10],
524 "next": "cursor-2"
525 })))
526 .mount(&server)
527 .await;
528 Mock::given(method("GET"))
529 .and(path("/futures"))
530 .and(header(
531 FUTURES_CONTRACT_HEADER,
532 FUTURES_CONTRACT_HEADER_VALUE,
533 ))
534 .and(query_param("symbol", "ES"))
535 .and(query_param("cursor", "cursor-2"))
536 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
537 "results": [20],
538 "next": null
539 })))
540 .mount(&server)
541 .await;
542 let (_dir, client) = authenticated_client(&server.uri()).await;
543
544 let values: Vec<i32> = client
545 .get_futures_cursor_paginated(&format!("{}/futures", server.uri()), &[("symbol", "ES")])
546 .await
547 .unwrap();
548
549 assert_eq!(values, vec![10, 20]);
550 }
551
552 #[tokio::test]
553 async fn get_futures_with_params_includes_header_and_query() {
554 let server = MockServer::start().await;
555 Mock::given(method("GET"))
556 .and(path("/futures/quote"))
557 .and(header(
558 FUTURES_CONTRACT_HEADER,
559 FUTURES_CONTRACT_HEADER_VALUE,
560 ))
561 .and(query_param("symbol", "ES"))
562 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
563 "value": "quote"
564 })))
565 .mount(&server)
566 .await;
567 let (_dir, client) = authenticated_client(&server.uri()).await;
568
569 let body: TestBody = client
570 .get_futures_with_params(
571 &format!("{}/futures/quote", server.uri()),
572 &[("symbol", "ES")],
573 )
574 .await
575 .unwrap();
576
577 assert_eq!(body.value, "quote");
578 }
579
580 #[tokio::test]
581 async fn post_helpers_send_expected_body_shapes() {
582 let server = MockServer::start().await;
583 Mock::given(method("POST"))
584 .and(path("/form"))
585 .and(body_string_contains("name=rhood"))
586 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
587 "value": "form-ok"
588 })))
589 .mount(&server)
590 .await;
591 Mock::given(method("POST"))
592 .and(path("/json"))
593 .and(body_string_contains(r#""name":"rhood""#))
594 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
595 "value": "json-ok"
596 })))
597 .mount(&server)
598 .await;
599 Mock::given(method("POST"))
600 .and(path("/empty"))
601 .respond_with(ResponseTemplate::new(204))
602 .mount(&server)
603 .await;
604 let (_dir, client) = authenticated_client(&server.uri()).await;
605
606 let form: TestBody = client
607 .post_form(&format!("{}/form", server.uri()), &[("name", "rhood")])
608 .await
609 .unwrap();
610 let json: TestBody = client
611 .post_json(
612 &format!("{}/json", server.uri()),
613 &serde_json::json!({ "name": "rhood" }),
614 )
615 .await
616 .unwrap();
617 client
618 .post_empty(&format!("{}/empty", server.uri()))
619 .await
620 .unwrap();
621
622 assert_eq!(form.value, "form-ok");
623 assert_eq!(json.value, "json-ok");
624 }
625
626 #[tokio::test]
627 async fn delete_and_patch_json_handle_successful_responses() {
628 let server = MockServer::start().await;
629 Mock::given(method("DELETE"))
630 .and(path("/resource"))
631 .respond_with(ResponseTemplate::new(200).set_body_string("deleted"))
632 .mount(&server)
633 .await;
634 Mock::given(method("PATCH"))
635 .and(path("/resource"))
636 .and(body_string_contains(r#""enabled":true"#))
637 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
638 "value": "patched"
639 })))
640 .mount(&server)
641 .await;
642 let (_dir, client) = authenticated_client(&server.uri()).await;
643
644 client
645 .delete(&format!("{}/resource", server.uri()))
646 .await
647 .unwrap();
648 let body: TestBody = client
649 .patch_json(
650 &format!("{}/resource", server.uri()),
651 &serde_json::json!({ "enabled": true }),
652 )
653 .await
654 .unwrap();
655
656 assert_eq!(body.value, "patched");
657 }
658
659 #[tokio::test]
660 async fn handle_response_maps_rate_limit_with_header() {
661 let server = MockServer::start().await;
662 Mock::given(method("GET"))
663 .and(path("/limited"))
664 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "17"))
665 .mount(&server)
666 .await;
667 let (_dir, client) = authenticated_client(&server.uri()).await;
668
669 let err = client
670 .get::<serde_json::Value>(&format!("{}/limited", server.uri()))
671 .await
672 .unwrap_err();
673
674 assert!(matches!(
675 err,
676 RhoodError::RateLimited {
677 retry_after_secs: 17
678 }
679 ));
680 }
681
682 #[tokio::test]
683 async fn handle_response_uses_default_rate_limit_when_header_is_invalid() {
684 let server = MockServer::start().await;
685 Mock::given(method("GET"))
686 .and(path("/limited"))
687 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "soon"))
688 .mount(&server)
689 .await;
690 let (_dir, client) = authenticated_client(&server.uri()).await;
691
692 let err = client
693 .get::<serde_json::Value>(&format!("{}/limited", server.uri()))
694 .await
695 .unwrap_err();
696
697 assert!(matches!(
698 err,
699 RhoodError::RateLimited {
700 retry_after_secs: DEFAULT_RETRY_AFTER_SECS
701 }
702 ));
703 }
704
705 #[tokio::test]
706 async fn handle_response_maps_api_and_parse_errors() {
707 let server = MockServer::start().await;
708 let token = "access-token-should-not-appear";
709 Mock::given(method("GET"))
710 .and(path("/api-error"))
711 .respond_with(
712 ResponseTemplate::new(503)
713 .set_body_string(format!(r#"{{"access_token":"{token}"}}"#)),
714 )
715 .mount(&server)
716 .await;
717 Mock::given(method("GET"))
718 .and(path("/bad-json"))
719 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
720 .mount(&server)
721 .await;
722 let (_dir, client) = authenticated_client(&server.uri()).await;
723
724 let api_err = client
725 .get::<serde_json::Value>(&format!("{}/api-error", server.uri()))
726 .await
727 .unwrap_err();
728 let parse_err = client
729 .get::<serde_json::Value>(&format!("{}/bad-json", server.uri()))
730 .await
731 .unwrap_err();
732
733 assert!(matches!(
734 api_err,
735 RhoodError::Api {
736 status: 503,
737 message
738 } if message.starts_with("Response body omitted (") && !message.contains(token)
739 ));
740 assert!(matches!(
741 parse_err,
742 RhoodError::Api {
743 status: 200,
744 message
745 } if message.contains("Failed to parse response")
746 ));
747 }
748
749 #[tokio::test]
750 async fn transport_error_preserves_missing_instruments_for_display() {
751 let server = MockServer::start().await;
752 Mock::given(method("GET"))
753 .and(path("/missing-instrument"))
754 .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
755 "missing_instruments": ["NOTAREALSYM"]
756 })))
757 .mount(&server)
758 .await;
759 let (_dir, client) = authenticated_client(&server.uri()).await;
760
761 let err = client
762 .get::<serde_json::Value>(&format!("{}/missing-instrument", server.uri()))
763 .await
764 .unwrap_err();
765
766 assert!(format!("{err}").contains("NOTAREALSYM"));
767 }
768
769 #[tokio::test]
770 async fn transport_error_preserves_allowlisted_diagnostic_without_token() {
771 let server = MockServer::start().await;
772 let token = "access-token-should-not-appear";
773 Mock::given(method("GET"))
774 .and(path("/insufficient-buying-power"))
775 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
776 "detail": "insufficient buying power",
777 "access_token": token
778 })))
779 .mount(&server)
780 .await;
781 let (_dir, client) = authenticated_client(&server.uri()).await;
782
783 let err = client
784 .get::<serde_json::Value>(&format!("{}/insufficient-buying-power", server.uri()))
785 .await
786 .unwrap_err();
787 let display = format!("{err}");
788
789 assert!(display.contains("insufficient buying power"));
790 assert!(!display.contains(token));
791 }
792}