Skip to main content

alpaca_data/corporate_actions/
request.rs

1use 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}