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