1use 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
26pub 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 pub fn builder() -> OkxClientBuilder<DefaultTransport> {
50 OkxClientBuilder::from_transport(crate::transport::ReqwestTransport::new())
51 }
52}
53
54impl<T> OkxClient<T> {
55 pub fn with_transport(transport: T) -> OkxClientBuilder<T> {
57 OkxClientBuilder::from_transport(transport)
58 }
59}
60
61impl<T: Transport> OkxClient<T> {
62 pub fn market(&self) -> Market<'_, T> {
64 Market::new(self)
65 }
66
67 pub fn public_data(&self) -> PublicData<'_, T> {
69 PublicData::new(self)
70 }
71
72 pub fn status(&self) -> Status<'_, T> {
74 Status::new(self)
75 }
76
77 pub fn support(&self) -> Support<'_, T> {
79 Support::new(self)
80 }
81
82 pub fn account(&self) -> Account<'_, T> {
84 Account::new(self)
85 }
86
87 pub fn funding(&self) -> Funding<'_, T> {
89 Funding::new(self)
90 }
91
92 pub fn convert(&self) -> Convert<'_, T> {
94 Convert::new(self)
95 }
96
97 pub fn finance(&self) -> Finance<'_, T> {
99 Finance::new(self)
100 }
101
102 pub fn sub_account(&self) -> SubAccount<'_, T> {
104 SubAccount::new(self)
105 }
106
107 pub fn trade(&self) -> Trade<'_, T> {
109 Trade::new(self)
110 }
111
112 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 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(×tamp, 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", ×tamp)?;
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
244pub 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 pub fn credentials(mut self, credentials: Credentials) -> Self {
266 self.credentials = Some(credentials);
267 self
268 }
269
270 pub fn region(mut self, region: OkxRegion) -> Self {
276 self.base_url = region.api_url().to_owned();
277 self
278 }
279
280 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
285 self.base_url = base_url.into();
286 self
287 }
288
289 pub fn demo_trading(mut self, demo: bool) -> Self {
291 self.demo = demo;
292 self
293 }
294
295 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 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 #[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}