Skip to main content

rust_okx/api/trade/requests/
advanced.rs

1use std::collections::HashSet;
2
3use serde::Serialize;
4
5use crate::model::{
6    RequestValidationError, ValidateRequest, collection_length, non_empty, non_empty_items,
7    optional_one_of, optional_unsigned_integer_string, range_u64,
8};
9
10/// Request body for `POST /api/v5/trade/easy-convert`.
11#[derive(Debug, Clone, Serialize)]
12pub struct EasyConvertRequest {
13    #[serde(rename = "fromCcy")]
14    from_ccy: Vec<String>,
15    #[serde(rename = "toCcy")]
16    to_ccy: String,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    source: Option<String>,
19}
20
21impl EasyConvertRequest {
22    /// Create an easy-convert request with one to five source currencies.
23    pub fn new<I, S>(from_ccy: I, to_ccy: impl Into<String>) -> Self
24    where
25        I: IntoIterator<Item = S>,
26        S: Into<String>,
27    {
28        Self {
29            from_ccy: from_ccy.into_iter().map(Into::into).collect(),
30            to_ccy: to_ccy.into(),
31            source: None,
32        }
33    }
34
35    /// Set the funding source: `1` for trading or `2` for funding.
36    pub fn source(mut self, value: impl Into<String>) -> Self {
37        self.source = Some(value.into());
38        self
39    }
40}
41
42impl ValidateRequest for EasyConvertRequest {
43    fn validate(&self) -> Result<(), RequestValidationError> {
44        collection_length("fromCcy", self.from_ccy.len(), 1, 5)?;
45        non_empty_items("fromCcy", self.from_ccy.iter().map(String::as_str))?;
46        non_empty("toCcy", &self.to_ccy)?;
47        optional_one_of("source", self.source.as_deref(), &["1", "2"], "1 or 2")?;
48
49        if self.from_ccy.iter().any(|ccy| ccy == &self.to_ccy) {
50            return Err(RequestValidationError::InvalidFormat {
51                field: "toCcy",
52                expected: "a currency different from every fromCcy entry",
53            });
54        }
55        let unique: HashSet<&str> = self.from_ccy.iter().map(String::as_str).collect();
56        if unique.len() != self.from_ccy.len() {
57            return Err(RequestValidationError::InvalidFormat {
58                field: "fromCcy",
59                expected: "one to five distinct currencies",
60            });
61        }
62        Ok(())
63    }
64}
65
66/// Query parameters for easy-convert history.
67#[derive(Debug, Clone, Default, Serialize)]
68pub struct EasyConvertHistoryRequest {
69    #[serde(skip_serializing_if = "Option::is_none")]
70    after: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    before: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    limit: Option<u32>,
75}
76
77impl EasyConvertHistoryRequest {
78    /// Create an empty history query.
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Return records earlier than this millisecond timestamp.
84    pub fn after(mut self, value: impl Into<String>) -> Self {
85        self.after = Some(value.into());
86        self
87    }
88
89    /// Return records newer than this millisecond timestamp.
90    pub fn before(mut self, value: impl Into<String>) -> Self {
91        self.before = Some(value.into());
92        self
93    }
94
95    /// Set the number of results, from 1 through 100.
96    pub fn limit(mut self, value: u32) -> Self {
97        self.limit = Some(value);
98        self
99    }
100}
101
102impl ValidateRequest for EasyConvertHistoryRequest {
103    fn validate(&self) -> Result<(), RequestValidationError> {
104        optional_unsigned_integer_string("after", self.after.as_deref())?;
105        optional_unsigned_integer_string("before", self.before.as_deref())?;
106        if let Some(limit) = self.limit {
107            range_u64("limit", u64::from(limit), 1, 100)?;
108        }
109        Ok(())
110    }
111}
112
113/// Query parameters for one-click-repay currency-list endpoints.
114#[derive(Debug, Clone, Default, Serialize)]
115pub struct OneClickRepayCurrencyListRequest {
116    #[serde(rename = "debtType", skip_serializing_if = "Option::is_none")]
117    debt_type: Option<String>,
118}
119
120impl OneClickRepayCurrencyListRequest {
121    /// Create an unfiltered currency-list query.
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Filter debt by `cross` or `isolated` type.
127    pub fn debt_type(mut self, value: impl Into<String>) -> Self {
128        self.debt_type = Some(value.into());
129        self
130    }
131}
132
133impl ValidateRequest for OneClickRepayCurrencyListRequest {
134    fn validate(&self) -> Result<(), RequestValidationError> {
135        optional_one_of(
136            "debtType",
137            self.debt_type.as_deref(),
138            &["cross", "isolated"],
139            "cross or isolated",
140        )
141    }
142}
143
144/// One or more debt currencies accepted by the legacy and v2 repay APIs.
145#[derive(Debug, Clone, Serialize)]
146#[serde(untagged)]
147enum DebtCurrencySelection {
148    One(String),
149    Many(Vec<String>),
150}
151
152/// Request body shared by one-click-repay v1 and v2 endpoints.
153#[derive(Debug, Clone, Serialize)]
154pub struct OneClickRepayRequest {
155    #[serde(rename = "debtCcy")]
156    debt_ccy: DebtCurrencySelection,
157    #[serde(rename = "repayCcy", skip_serializing_if = "Option::is_none")]
158    repay_ccy: Option<String>,
159    #[serde(rename = "repayCcyList", skip_serializing_if = "Option::is_none")]
160    repay_ccy_list: Option<Vec<String>>,
161}
162
163impl OneClickRepayRequest {
164    /// Build the legacy v1 request (`debtCcy` array + one `repayCcy`).
165    pub fn new<I, S>(debt_ccy: I, repay_ccy: impl Into<String>) -> Self
166    where
167        I: IntoIterator<Item = S>,
168        S: Into<String>,
169    {
170        Self {
171            debt_ccy: DebtCurrencySelection::Many(debt_ccy.into_iter().map(Into::into).collect()),
172            repay_ccy: Some(repay_ccy.into()),
173            repay_ccy_list: None,
174        }
175    }
176
177    /// Build the v2 request (`debtCcy` string + prioritized `repayCcyList`).
178    pub fn v2<I, S>(debt_ccy: impl Into<String>, repay_ccy_list: I) -> Self
179    where
180        I: IntoIterator<Item = S>,
181        S: Into<String>,
182    {
183        Self {
184            debt_ccy: DebtCurrencySelection::One(debt_ccy.into()),
185            repay_ccy: None,
186            repay_ccy_list: Some(repay_ccy_list.into_iter().map(Into::into).collect()),
187        }
188    }
189}
190
191impl ValidateRequest for OneClickRepayRequest {
192    fn validate(&self) -> Result<(), RequestValidationError> {
193        match (&self.debt_ccy, &self.repay_ccy, &self.repay_ccy_list) {
194            (DebtCurrencySelection::Many(debt), Some(repay), None) => {
195                collection_length("debtCcy", debt.len(), 1, 5)?;
196                non_empty_items("debtCcy", debt.iter().map(String::as_str))?;
197                non_empty("repayCcy", repay)?;
198                if debt.iter().any(|ccy| ccy == repay) {
199                    return Err(RequestValidationError::InvalidFormat {
200                        field: "repayCcy",
201                        expected: "a currency different from every debtCcy entry",
202                    });
203                }
204                Ok(())
205            }
206            (DebtCurrencySelection::One(debt), None, Some(repay_list)) => {
207                non_empty("debtCcy", debt)?;
208                collection_length("repayCcyList", repay_list.len(), 1, 100)?;
209                non_empty_items("repayCcyList", repay_list.iter().map(String::as_str))
210            }
211            _ => Err(RequestValidationError::InvalidFormat {
212                field: "debtCcy",
213                expected: "the v1 or v2 one-click-repay request shape",
214            }),
215        }
216    }
217}
218
219/// Query parameters for one-click-repay history endpoints.
220#[derive(Debug, Clone, Default, Serialize)]
221pub struct OneClickRepayHistoryRequest {
222    #[serde(skip_serializing_if = "Option::is_none")]
223    after: Option<String>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    before: Option<String>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    limit: Option<u32>,
228}
229
230impl OneClickRepayHistoryRequest {
231    /// Create an empty history query.
232    pub fn new() -> Self {
233        Self::default()
234    }
235
236    /// Return records earlier than this millisecond timestamp.
237    pub fn after(mut self, value: impl Into<String>) -> Self {
238        self.after = Some(value.into());
239        self
240    }
241
242    /// Return records newer than this millisecond timestamp.
243    pub fn before(mut self, value: impl Into<String>) -> Self {
244        self.before = Some(value.into());
245        self
246    }
247
248    /// Set the number of results, from 1 through 100.
249    pub fn limit(mut self, value: u32) -> Self {
250        self.limit = Some(value);
251        self
252    }
253}
254
255impl ValidateRequest for OneClickRepayHistoryRequest {
256    fn validate(&self) -> Result<(), RequestValidationError> {
257        optional_unsigned_integer_string("after", self.after.as_deref())?;
258        optional_unsigned_integer_string("before", self.before.as_deref())?;
259        if let Some(limit) = self.limit {
260            range_u64("limit", u64::from(limit), 1, 100)?;
261        }
262        Ok(())
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn easy_convert_rejects_receiving_source_currency() {
272        let request = EasyConvertRequest::new(["BTC"], "BTC");
273        assert!(request.validate().is_err());
274    }
275
276    #[test]
277    fn legacy_repay_is_limited_to_five_debt_currencies() {
278        let request = OneClickRepayRequest::new(["A", "B", "C", "D", "E", "F"], "USDT");
279        assert!(request.validate().is_err());
280    }
281}