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