alpaca_data/corporate_actions/
request.rs1use alpaca_core::{QueryWriter, pagination::PaginatedRequest};
2
3use crate::Error;
4use crate::symbols::display_stock_symbol;
5
6use super::{CorporateActionType, Sort};
7
8#[derive(Clone, Debug, Default)]
9pub struct ListRequest {
10 pub symbols: Option<Vec<String>>,
11 pub cusips: Option<Vec<String>>,
12 pub types: Option<Vec<CorporateActionType>>,
13 pub start: Option<String>,
14 pub end: Option<String>,
15 pub ids: Option<Vec<String>>,
16 pub limit: Option<u32>,
17 pub sort: Option<Sort>,
18 pub page_token: Option<String>,
19}
20
21impl ListRequest {
22 pub(crate) fn validate(&self) -> Result<(), Error> {
23 validate_limit(self.limit, 1, 1_000)?;
24 validate_optional_identifiers(self.symbols.as_deref(), "symbols")?;
25 validate_optional_identifiers(self.cusips.as_deref(), "cusips")?;
26 validate_optional_identifiers(self.ids.as_deref(), "ids")?;
27
28 if let Some(types) = &self.types
29 && types.is_empty()
30 {
31 return Err(Error::InvalidRequest(
32 "types are invalid: must not be empty when provided".to_owned(),
33 ));
34 }
35
36 if self.ids.is_some()
37 && (self.symbols.is_some()
38 || self.cusips.is_some()
39 || self.types.is_some()
40 || self.start.is_some()
41 || self.end.is_some())
42 {
43 return Err(Error::InvalidRequest(
44 "ids cannot be combined with other corporate actions filters".to_owned(),
45 ));
46 }
47
48 Ok(())
49 }
50
51 pub(crate) fn into_query(self) -> Vec<(String, String)> {
52 let mut query = QueryWriter::default();
53 if let Some(symbols) = self.symbols {
54 query.push_csv(
55 "symbols",
56 symbols
57 .into_iter()
58 .map(|symbol| display_stock_symbol(&symbol))
59 .collect::<Vec<_>>(),
60 );
61 }
62 if let Some(cusips) = self.cusips {
63 query.push_csv("cusips", cusips);
64 }
65 if let Some(types) = self.types {
66 query.push_csv("types", types.into_iter().map(|value| value.to_string()));
67 }
68 query.push_opt("start", self.start);
69 query.push_opt("end", self.end);
70 if let Some(ids) = self.ids {
71 query.push_csv("ids", ids);
72 }
73 query.push_opt("limit", self.limit);
74 query.push_opt("sort", self.sort);
75 query.push_opt("page_token", self.page_token);
76 query.finish()
77 }
78}
79
80impl PaginatedRequest for ListRequest {
81 fn with_page_token(&self, page_token: Option<String>) -> Self {
82 let mut next = self.clone();
83 next.page_token = page_token;
84 next
85 }
86}
87
88fn validate_optional_identifiers(values: Option<&[String]>, field_name: &str) -> Result<(), Error> {
89 let Some(values) = values else {
90 return Ok(());
91 };
92
93 if values.is_empty() {
94 return Err(Error::InvalidRequest(format!(
95 "{field_name} are invalid: must not be empty when provided"
96 )));
97 }
98
99 if values.iter().any(|value| value.trim().is_empty()) {
100 return Err(Error::InvalidRequest(format!(
101 "{field_name} are invalid: must not contain empty or whitespace-only entries"
102 )));
103 }
104
105 Ok(())
106}
107
108fn validate_limit(limit: Option<u32>, min: u32, max: u32) -> Result<(), Error> {
109 if let Some(limit) = limit
110 && !(min..=max).contains(&limit)
111 {
112 return Err(Error::InvalidRequest(format!(
113 "limit must be between {min} and {max}"
114 )));
115 }
116
117 Ok(())
118}
119
120#[cfg(test)]
121mod tests {
122 use super::{CorporateActionType, ListRequest};
123
124 #[test]
125 fn list_request_normalizes_stock_symbols_in_query() {
126 let query = ListRequest {
127 symbols: Some(vec![" brk/b ".to_owned(), "aapl".to_owned()]),
128 cusips: None,
129 types: Some(vec![CorporateActionType::CashDividend]),
130 start: Some("2025-01-01".to_owned()),
131 end: Some("2025-01-31".to_owned()),
132 ids: None,
133 limit: Some(100),
134 sort: None,
135 page_token: None,
136 }
137 .into_query();
138
139 assert!(
140 query
141 .iter()
142 .any(|(key, value)| key == "symbols" && value == "BRK.B,AAPL"),
143 "corporate actions query should normalize stock symbols: {query:?}"
144 );
145 }
146}