Skip to main content

rust_okx/
client.rs

1//! The OKX client and its builder.
2
3use bytes::Bytes;
4use http::Method;
5use http::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
6use serde::Serialize;
7use serde::de::DeserializeOwned;
8
9use crate::OkxRegion;
10use crate::api::account::Account;
11use crate::api::convert::Convert;
12use crate::api::finance::Finance;
13use crate::api::funding::Funding;
14use crate::api::market::Market;
15use crate::api::public_data::PublicData;
16use crate::api::trade::Trade;
17use crate::credentials::Credentials;
18use crate::error::{Error, RestError};
19use crate::model::OkxResponse;
20use crate::signing;
21use crate::transport::{DefaultTransport, Transport};
22
23/// An OKX v5 REST API client, generic over the HTTP [`Transport`].
24///
25/// Construct one with [`OkxClient::builder`] (uses the default
26/// [`ReqwestTransport`](crate::ReqwestTransport)) or [`OkxClient::with_transport`]
27/// for a custom transport. The client is cheap to share behind an `Arc` and all
28/// request methods take `&self`.
29///
30/// API groups are reached through accessor methods: [`market`](Self::market),
31/// [`public_data`](Self::public_data), [`account`](Self::account),
32/// [`funding`](Self::funding), [`convert`](Self::convert),
33/// [`finance`](Self::finance), and [`trade`](Self::trade).
34pub struct OkxClient<T = DefaultTransport> {
35    transport: T,
36    credentials: Option<Credentials>,
37    base_url: String,
38    demo: bool,
39}
40
41#[cfg(feature = "reqwest")]
42impl OkxClient {
43    /// Start building a client backed by the default
44    /// [`ReqwestTransport`](crate::ReqwestTransport).
45    pub fn builder() -> OkxClientBuilder<DefaultTransport> {
46        OkxClientBuilder::from_transport(crate::transport::ReqwestTransport::new())
47    }
48}
49
50impl<T> OkxClient<T> {
51    /// Start building a client around a custom [`Transport`].
52    pub fn with_transport(transport: T) -> OkxClientBuilder<T> {
53        OkxClientBuilder::from_transport(transport)
54    }
55}
56
57impl<T: Transport> OkxClient<T> {
58    /// Access the public market-data endpoints.
59    pub fn market(&self) -> Market<'_, T> {
60        Market::new(self)
61    }
62
63    /// Access the public reference-data endpoints.
64    pub fn public_data(&self) -> PublicData<'_, T> {
65        PublicData::new(self)
66    }
67
68    /// Access the (authenticated) account endpoints.
69    pub fn account(&self) -> Account<'_, T> {
70        Account::new(self)
71    }
72
73    /// Access the (authenticated) funding-account and asset endpoints.
74    pub fn funding(&self) -> Funding<'_, T> {
75        Funding::new(self)
76    }
77
78    /// Access the (authenticated) asset conversion endpoints.
79    pub fn convert(&self) -> Convert<'_, T> {
80        Convert::new(self)
81    }
82
83    /// Access the finance endpoints.
84    pub fn finance(&self) -> Finance<'_, T> {
85        Finance::new(self)
86    }
87
88    /// Access the (authenticated) trading endpoints.
89    pub fn trade(&self) -> Trade<'_, T> {
90        Trade::new(self)
91    }
92
93    /// Send a `GET` request, serializing `query` into the URL query string and
94    /// returning the deserialized `data` array.
95    pub(crate) async fn get<Q, D>(
96        &self,
97        endpoint: &'static str,
98        query: &Q,
99        authenticated: bool,
100    ) -> Result<D, Error>
101    where
102        Q: Serialize,
103        D: DeserializeOwned,
104    {
105        let qs = serde_urlencoded::to_string(query)
106            .map_err(|e| RestError::Encode { source: e.into() })?;
107        let request_path = if qs.is_empty() {
108            endpoint.to_owned()
109        } else {
110            format!("{endpoint}?{qs}")
111        };
112        self.send(
113            endpoint,
114            Method::GET,
115            &request_path,
116            Bytes::new(),
117            authenticated,
118        )
119        .await
120    }
121
122    /// Send a `POST` request with `body` serialized as JSON and return the
123    /// deserialized `data` array.
124    pub(crate) async fn post<B, D>(
125        &self,
126        endpoint: &'static str,
127        body: &B,
128        authenticated: bool,
129    ) -> Result<D, Error>
130    where
131        B: Serialize,
132        D: DeserializeOwned,
133    {
134        let body = serde_json::to_vec(body).map_err(|e| RestError::Encode { source: e.into() })?;
135        self.send(
136            endpoint,
137            Method::POST,
138            endpoint,
139            Bytes::from(body),
140            authenticated,
141        )
142        .await
143    }
144
145    async fn send<D>(
146        &self,
147        endpoint: &'static str,
148        method: Method,
149        request_path: &str,
150        body: Bytes,
151        authenticated: bool,
152    ) -> Result<D, Error>
153    where
154        D: DeserializeOwned,
155    {
156        let url = format!("{}{}", self.base_url, request_path);
157        let mut builder = http::Request::builder().method(method.clone()).uri(url);
158
159        let headers = builder
160            .headers_mut()
161            .expect("a freshly constructed request builder has no error");
162        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
163        if self.demo {
164            headers.insert(
165                HeaderName::from_static("x-simulated-trading"),
166                HeaderValue::from_static("1"),
167            );
168        }
169        if authenticated {
170            let credentials = self.credentials.as_ref().ok_or_else(|| {
171                RestError::Configuration("authenticated endpoint requires credentials".to_owned())
172            })?;
173            let timestamp = signing::timestamp();
174            let body_str = std::str::from_utf8(&body).unwrap_or_default();
175            let prehash = signing::pre_hash(&timestamp, method.as_str(), request_path, body_str);
176            let signature = signing::sign(&prehash, credentials.secret_key());
177            insert_header(headers, "ok-access-key", credentials.api_key())?;
178            insert_header(headers, "ok-access-sign", &signature)?;
179            insert_header(headers, "ok-access-timestamp", &timestamp)?;
180            insert_header(headers, "ok-access-passphrase", credentials.passphrase())?;
181        }
182
183        let request = builder
184            .body(body)
185            .map_err(|e| RestError::Encode { source: e.into() })?;
186        let response = self
187            .transport
188            .send(request)
189            .await
190            .map_err(RestError::from)?;
191        let status = response.status();
192        let bytes = response.into_body();
193        if !status.is_success() {
194            return Err(RestError::HttpStatus {
195                endpoint,
196                status,
197                body: String::from_utf8_lossy(&bytes).into_owned(),
198            }
199            .into());
200        }
201        let envelope: OkxResponse<D> =
202            serde_json::from_slice(&bytes).map_err(|e| RestError::Decode {
203                endpoint,
204                source: e,
205            })?;
206        if envelope.code != "0" {
207            return Err(RestError::Okx {
208                endpoint,
209                code: envelope.code,
210                message: envelope.msg,
211            }
212            .into());
213        }
214        Ok(envelope.data)
215    }
216}
217
218fn insert_header(headers: &mut HeaderMap, name: &'static str, value: &str) -> Result<(), Error> {
219    let value = HeaderValue::from_str(value)
220        .map_err(|e| RestError::Configuration(format!("invalid header value for {name}: {e}")))?;
221    headers.insert(HeaderName::from_static(name), value);
222    Ok(())
223}
224
225/// Builder for [`OkxClient`].
226///
227/// Created by [`OkxClient::builder`] or [`OkxClient::with_transport`].
228pub struct OkxClientBuilder<T = DefaultTransport> {
229    transport: T,
230    credentials: Option<Credentials>,
231    base_url: String,
232    demo: bool,
233}
234
235impl<T> OkxClientBuilder<T> {
236    fn from_transport(transport: T) -> Self {
237        Self {
238            transport,
239            credentials: None,
240            base_url: crate::API_URL.to_owned(),
241            demo: false,
242        }
243    }
244
245    /// Set the API credentials used to sign authenticated requests.
246    pub fn credentials(mut self, credentials: Credentials) -> Self {
247        self.credentials = Some(credentials);
248        self
249    }
250
251    /// Select the OKX REST API region.
252    ///
253    /// The default is [`OkxRegion::Global`]. US and AU users registered on
254    /// `app.okx.com` should use [`OkxRegion::Us`]. EU users registered on
255    /// `my.okx.com` should use [`OkxRegion::Eea`].
256    pub fn region(mut self, region: OkxRegion) -> Self {
257        self.base_url = region.api_url().to_owned();
258        self
259    }
260
261    /// Override the API base URL.
262    ///
263    /// This overrides the default global domain and any region selected through
264    /// [`Self::region`].
265    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
266        self.base_url = base_url.into();
267        self
268    }
269
270    /// Toggle the `x-simulated-trading` header for OKX demo trading.
271    pub fn demo_trading(mut self, demo: bool) -> Self {
272        self.demo = demo;
273        self
274    }
275
276    /// Replace the transport, changing the client's transport type.
277    pub fn transport<U>(self, transport: U) -> OkxClientBuilder<U> {
278        OkxClientBuilder {
279            transport,
280            credentials: self.credentials,
281            base_url: self.base_url,
282            demo: self.demo,
283        }
284    }
285
286    /// Build the [`OkxClient`].
287    pub fn build(self) -> OkxClient<T> {
288        OkxClient {
289            transport: self.transport,
290            credentials: self.credentials,
291            base_url: self.base_url,
292            demo: self.demo,
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use crate::error::{Error, RestError};
300    use crate::test_util::MockTransport;
301    use crate::{OkxClient, OkxRegion};
302
303    /// A non-zero OKX response code is surfaced as [`RestError::Okx`] with the
304    /// code and message preserved (offline unit test; network path covered by
305    /// integration tests).
306    #[tokio::test]
307    async fn non_zero_code_is_api_error() {
308        let mock = MockTransport::new(r#"{"code":"51000","msg":"Parameter error","data":[]}"#);
309        let client = OkxClient::with_transport(mock).build();
310        let err = client.market().get_ticker("BAD").await.unwrap_err();
311        match err {
312            Error::Rest(RestError::Okx { code, message, .. }) => {
313                assert_eq!(code, "51000");
314                assert_eq!(message, "Parameter error");
315            }
316            other => panic!("expected Error::Rest(RestError::Okx), got {other:?}"),
317        }
318    }
319
320    #[test]
321    fn okx_region_returns_expected_api_urls() {
322        assert_eq!(OkxRegion::Global.api_url(), "https://www.okx.com");
323        assert_eq!(OkxRegion::Us.api_url(), "https://us.okx.com");
324        assert_eq!(OkxRegion::Eea.api_url(), "https://eea.okx.com");
325    }
326
327    #[tokio::test]
328    async fn region_sets_request_base_url() {
329        let mock = MockTransport::new(r#"{"code":"0","msg":"","data":[]}"#);
330        let client = OkxClient::with_transport(mock.clone())
331            .region(OkxRegion::Us)
332            .build();
333
334        client.market().get_ticker("BTC-USDT").await.unwrap();
335
336        let req = mock.captured();
337        assert!(
338            req.uri
339                .starts_with("https://us.okx.com/api/v5/market/ticker"),
340            "unexpected URI: {}",
341            req.uri
342        );
343    }
344
345    #[tokio::test]
346    async fn base_url_overrides_selected_region() {
347        let mock = MockTransport::new(r#"{"code":"0","msg":"","data":[]}"#);
348        let client = OkxClient::with_transport(mock.clone())
349            .region(OkxRegion::Eea)
350            .base_url("https://example.test")
351            .build();
352
353        client.market().get_ticker("BTC-USDT").await.unwrap();
354
355        let req = mock.captured();
356        assert!(
357            req.uri
358                .starts_with("https://example.test/api/v5/market/ticker"),
359            "unexpected URI: {}",
360            req.uri
361        );
362    }
363}