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::trade::Trade;
17use crate::credentials::Credentials;
18use crate::error::Error;
19use crate::model::OkxResponse;
20use crate::signing;
21use crate::transport::{DefaultTransport, Transport};
22
23pub struct OkxClient<T = DefaultTransport> {
35 transport: T,
36 credentials: Option<Credentials>,
37 base_url: String,
38 demo: bool,
39}
40
41#[cfg(feature = "reqwest")]
42impl OkxClient {
43 pub fn builder() -> OkxClientBuilder<DefaultTransport> {
46 OkxClientBuilder::from_transport(crate::transport::ReqwestTransport::new())
47 }
48}
49
50impl<T> OkxClient<T> {
51 pub fn with_transport(transport: T) -> OkxClientBuilder<T> {
53 OkxClientBuilder::from_transport(transport)
54 }
55}
56
57impl<T: Transport> OkxClient<T> {
58 pub fn market(&self) -> Market<'_, T> {
60 Market::new(self)
61 }
62
63 pub fn public_data(&self) -> PublicData<'_, T> {
65 PublicData::new(self)
66 }
67
68 pub fn account(&self) -> Account<'_, T> {
70 Account::new(self)
71 }
72
73 pub fn funding(&self) -> Funding<'_, T> {
75 Funding::new(self)
76 }
77
78 pub fn convert(&self) -> Convert<'_, T> {
80 Convert::new(self)
81 }
82
83 pub fn finance(&self) -> Finance<'_, T> {
85 Finance::new(self)
86 }
87
88 pub fn trade(&self) -> Trade<'_, T> {
90 Trade::new(self)
91 }
92
93 pub(crate) async fn get<Q, D>(
96 &self,
97 path: &str,
98 query: &Q,
99 authenticated: bool,
100 ) -> Result<D, Error>
101 where
102 Q: Serialize,
103 D: DeserializeOwned,
104 {
105 let qs = serde_urlencoded::to_string(query).map_err(Error::encode)?;
106 let request_path = if qs.is_empty() {
107 path.to_owned()
108 } else {
109 format!("{path}?{qs}")
110 };
111 self.send(Method::GET, &request_path, Bytes::new(), authenticated)
112 .await
113 }
114
115 pub(crate) async fn post<B, D>(
118 &self,
119 path: &str,
120 body: &B,
121 authenticated: bool,
122 ) -> Result<D, Error>
123 where
124 B: Serialize,
125 D: DeserializeOwned,
126 {
127 let body = serde_json::to_vec(body).map_err(Error::encode)?;
128 self.send(Method::POST, path, Bytes::from(body), authenticated)
129 .await
130 }
131
132 async fn send<D>(
133 &self,
134 method: Method,
135 request_path: &str,
136 body: Bytes,
137 authenticated: bool,
138 ) -> Result<D, Error>
139 where
140 D: DeserializeOwned,
141 {
142 let url = format!("{}{}", self.base_url, request_path);
143 let mut builder = http::Request::builder().method(method.clone()).uri(url);
144
145 let headers = builder
146 .headers_mut()
147 .expect("a freshly constructed request builder has no error");
148 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
149 if self.demo {
150 headers.insert(
151 HeaderName::from_static("x-simulated-trading"),
152 HeaderValue::from_static("1"),
153 );
154 }
155 if authenticated {
156 let credentials = self.credentials.as_ref().ok_or_else(|| {
157 Error::Configuration("authenticated endpoint requires credentials".to_owned())
158 })?;
159 let timestamp = signing::timestamp();
160 let body_str = std::str::from_utf8(&body).unwrap_or_default();
161 let prehash = signing::pre_hash(×tamp, method.as_str(), request_path, body_str);
162 let signature = signing::sign(&prehash, credentials.secret_key());
163 insert_header(headers, "ok-access-key", credentials.api_key())?;
164 insert_header(headers, "ok-access-sign", &signature)?;
165 insert_header(headers, "ok-access-timestamp", ×tamp)?;
166 insert_header(headers, "ok-access-passphrase", credentials.passphrase())?;
167 }
168
169 let request = builder.body(body).map_err(Error::encode)?;
170 let response = self.transport.send(request).await?;
171 let status = response.status();
172 let bytes = response.into_body();
173 if !status.is_success() {
174 return Err(Error::HttpStatus {
175 status,
176 body: String::from_utf8_lossy(&bytes).into_owned(),
177 });
178 }
179 let envelope: OkxResponse<D> = serde_json::from_slice(&bytes).map_err(Error::decode)?;
180 if envelope.code != "0" {
181 return Err(Error::Api {
182 code: envelope.code,
183 message: envelope.msg,
184 });
185 }
186 Ok(envelope.data)
187 }
188}
189
190fn insert_header(headers: &mut HeaderMap, name: &'static str, value: &str) -> Result<(), Error> {
191 let value = HeaderValue::from_str(value)
192 .map_err(|e| Error::Configuration(format!("invalid header value for {name}: {e}")))?;
193 headers.insert(HeaderName::from_static(name), value);
194 Ok(())
195}
196
197pub struct OkxClientBuilder<T = DefaultTransport> {
201 transport: T,
202 credentials: Option<Credentials>,
203 base_url: String,
204 demo: bool,
205}
206
207impl<T> OkxClientBuilder<T> {
208 fn from_transport(transport: T) -> Self {
209 Self {
210 transport,
211 credentials: None,
212 base_url: crate::API_URL.to_owned(),
213 demo: false,
214 }
215 }
216
217 pub fn credentials(mut self, credentials: Credentials) -> Self {
219 self.credentials = Some(credentials);
220 self
221 }
222
223 pub fn region(mut self, region: OkxRegion) -> Self {
229 self.base_url = region.api_url().to_owned();
230 self
231 }
232
233 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
238 self.base_url = base_url.into();
239 self
240 }
241
242 pub fn demo_trading(mut self, demo: bool) -> Self {
244 self.demo = demo;
245 self
246 }
247
248 pub fn transport<U>(self, transport: U) -> OkxClientBuilder<U> {
250 OkxClientBuilder {
251 transport,
252 credentials: self.credentials,
253 base_url: self.base_url,
254 demo: self.demo,
255 }
256 }
257
258 pub fn build(self) -> OkxClient<T> {
260 OkxClient {
261 transport: self.transport,
262 credentials: self.credentials,
263 base_url: self.base_url,
264 demo: self.demo,
265 }
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use crate::error::Error;
272 use crate::test_util::MockTransport;
273 use crate::{OkxClient, OkxRegion};
274
275 #[tokio::test]
279 async fn non_zero_code_is_api_error() {
280 let mock = MockTransport::new(r#"{"code":"51000","msg":"Parameter error","data":[]}"#);
281 let client = OkxClient::with_transport(mock).build();
282 let err = client.market().get_ticker("BAD").await.unwrap_err();
283 match err {
284 Error::Api { code, message } => {
285 assert_eq!(code, "51000");
286 assert_eq!(message, "Parameter error");
287 }
288 other => panic!("expected Error::Api, got {other:?}"),
289 }
290 }
291
292 #[test]
293 fn okx_region_returns_expected_api_urls() {
294 assert_eq!(OkxRegion::Global.api_url(), "https://www.okx.com");
295 assert_eq!(OkxRegion::Us.api_url(), "https://us.okx.com");
296 assert_eq!(OkxRegion::Eea.api_url(), "https://eea.okx.com");
297 }
298
299 #[tokio::test]
300 async fn region_sets_request_base_url() {
301 let mock = MockTransport::new(r#"{"code":"0","msg":"","data":[]}"#);
302 let client = OkxClient::with_transport(mock.clone())
303 .region(OkxRegion::Us)
304 .build();
305
306 client.market().get_ticker("BTC-USDT").await.unwrap();
307
308 let req = mock.captured();
309 assert!(
310 req.uri
311 .starts_with("https://us.okx.com/api/v5/market/ticker"),
312 "unexpected URI: {}",
313 req.uri
314 );
315 }
316
317 #[tokio::test]
318 async fn base_url_overrides_selected_region() {
319 let mock = MockTransport::new(r#"{"code":"0","msg":"","data":[]}"#);
320 let client = OkxClient::with_transport(mock.clone())
321 .region(OkxRegion::Eea)
322 .base_url("https://example.test")
323 .build();
324
325 client.market().get_ticker("BTC-USDT").await.unwrap();
326
327 let req = mock.captured();
328 assert!(
329 req.uri
330 .starts_with("https://example.test/api/v5/market/ticker"),
331 "unexpected URI: {}",
332 req.uri
333 );
334 }
335}