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