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