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, RestError};
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 endpoint: &'static 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)
106 .map_err(|e| RestError::Encode { source: e.into() })?;
107 let request_path = if qs.is_empty() {
108 endpoint.to_owned()
109 } else {
110 format!("{endpoint}?{qs}")
111 };
112 self.send(
113 endpoint,
114 Method::GET,
115 &request_path,
116 Bytes::new(),
117 authenticated,
118 )
119 .await
120 }
121
122 pub(crate) async fn post<B, D>(
125 &self,
126 endpoint: &'static str,
127 body: &B,
128 authenticated: bool,
129 ) -> Result<D, Error>
130 where
131 B: Serialize,
132 D: DeserializeOwned,
133 {
134 let body = serde_json::to_vec(body).map_err(|e| RestError::Encode { source: e.into() })?;
135 self.send(
136 endpoint,
137 Method::POST,
138 endpoint,
139 Bytes::from(body),
140 authenticated,
141 )
142 .await
143 }
144
145 async fn send<D>(
146 &self,
147 endpoint: &'static str,
148 method: Method,
149 request_path: &str,
150 body: Bytes,
151 authenticated: bool,
152 ) -> Result<D, Error>
153 where
154 D: DeserializeOwned,
155 {
156 let url = format!("{}{}", self.base_url, request_path);
157 let mut builder = http::Request::builder().method(method.clone()).uri(url);
158
159 let headers = builder
160 .headers_mut()
161 .expect("a freshly constructed request builder has no error");
162 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
163 if self.demo {
164 headers.insert(
165 HeaderName::from_static("x-simulated-trading"),
166 HeaderValue::from_static("1"),
167 );
168 }
169 if authenticated {
170 let credentials = self.credentials.as_ref().ok_or_else(|| {
171 RestError::Configuration("authenticated endpoint requires credentials".to_owned())
172 })?;
173 let timestamp = signing::timestamp();
174 let body_str = std::str::from_utf8(&body).unwrap_or_default();
175 let prehash = signing::pre_hash(×tamp, method.as_str(), request_path, body_str);
176 let signature = signing::sign(&prehash, credentials.secret_key());
177 insert_header(headers, "ok-access-key", credentials.api_key())?;
178 insert_header(headers, "ok-access-sign", &signature)?;
179 insert_header(headers, "ok-access-timestamp", ×tamp)?;
180 insert_header(headers, "ok-access-passphrase", credentials.passphrase())?;
181 }
182
183 let request = builder
184 .body(body)
185 .map_err(|e| RestError::Encode { source: e.into() })?;
186 let response = self
187 .transport
188 .send(request)
189 .await
190 .map_err(RestError::from)?;
191 let status = response.status();
192 let bytes = response.into_body();
193 if !status.is_success() {
194 return Err(RestError::HttpStatus {
195 endpoint,
196 status,
197 body: String::from_utf8_lossy(&bytes).into_owned(),
198 }
199 .into());
200 }
201 let envelope: OkxResponse<D> =
202 serde_json::from_slice(&bytes).map_err(|e| RestError::Decode {
203 endpoint,
204 source: e,
205 })?;
206 if envelope.code != "0" {
207 return Err(RestError::Okx {
208 endpoint,
209 code: envelope.code,
210 message: envelope.msg,
211 }
212 .into());
213 }
214 Ok(envelope.data)
215 }
216}
217
218fn insert_header(headers: &mut HeaderMap, name: &'static str, value: &str) -> Result<(), Error> {
219 let value = HeaderValue::from_str(value)
220 .map_err(|e| RestError::Configuration(format!("invalid header value for {name}: {e}")))?;
221 headers.insert(HeaderName::from_static(name), value);
222 Ok(())
223}
224
225pub struct OkxClientBuilder<T = DefaultTransport> {
229 transport: T,
230 credentials: Option<Credentials>,
231 base_url: String,
232 demo: bool,
233}
234
235impl<T> OkxClientBuilder<T> {
236 fn from_transport(transport: T) -> Self {
237 Self {
238 transport,
239 credentials: None,
240 base_url: crate::API_URL.to_owned(),
241 demo: false,
242 }
243 }
244
245 pub fn credentials(mut self, credentials: Credentials) -> Self {
247 self.credentials = Some(credentials);
248 self
249 }
250
251 pub fn region(mut self, region: OkxRegion) -> Self {
257 self.base_url = region.api_url().to_owned();
258 self
259 }
260
261 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
266 self.base_url = base_url.into();
267 self
268 }
269
270 pub fn demo_trading(mut self, demo: bool) -> Self {
272 self.demo = demo;
273 self
274 }
275
276 pub fn transport<U>(self, transport: U) -> OkxClientBuilder<U> {
278 OkxClientBuilder {
279 transport,
280 credentials: self.credentials,
281 base_url: self.base_url,
282 demo: self.demo,
283 }
284 }
285
286 pub fn build(self) -> OkxClient<T> {
288 OkxClient {
289 transport: self.transport,
290 credentials: self.credentials,
291 base_url: self.base_url,
292 demo: self.demo,
293 }
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use crate::error::{Error, RestError};
300 use crate::test_util::MockTransport;
301 use crate::{OkxClient, OkxRegion};
302
303 #[tokio::test]
307 async fn non_zero_code_is_api_error() {
308 let mock = MockTransport::new(r#"{"code":"51000","msg":"Parameter error","data":[]}"#);
309 let client = OkxClient::with_transport(mock).build();
310 let err = client.market().get_ticker("BAD").await.unwrap_err();
311 match err {
312 Error::Rest(RestError::Okx { code, message, .. }) => {
313 assert_eq!(code, "51000");
314 assert_eq!(message, "Parameter error");
315 }
316 other => panic!("expected Error::Rest(RestError::Okx), got {other:?}"),
317 }
318 }
319
320 #[test]
321 fn okx_region_returns_expected_api_urls() {
322 assert_eq!(OkxRegion::Global.api_url(), "https://www.okx.com");
323 assert_eq!(OkxRegion::Us.api_url(), "https://us.okx.com");
324 assert_eq!(OkxRegion::Eea.api_url(), "https://eea.okx.com");
325 }
326
327 #[tokio::test]
328 async fn region_sets_request_base_url() {
329 let mock = MockTransport::new(r#"{"code":"0","msg":"","data":[]}"#);
330 let client = OkxClient::with_transport(mock.clone())
331 .region(OkxRegion::Us)
332 .build();
333
334 client.market().get_ticker("BTC-USDT").await.unwrap();
335
336 let req = mock.captured();
337 assert!(
338 req.uri
339 .starts_with("https://us.okx.com/api/v5/market/ticker"),
340 "unexpected URI: {}",
341 req.uri
342 );
343 }
344
345 #[tokio::test]
346 async fn base_url_overrides_selected_region() {
347 let mock = MockTransport::new(r#"{"code":"0","msg":"","data":[]}"#);
348 let client = OkxClient::with_transport(mock.clone())
349 .region(OkxRegion::Eea)
350 .base_url("https://example.test")
351 .build();
352
353 client.market().get_ticker("BTC-USDT").await.unwrap();
354
355 let req = mock.captured();
356 assert!(
357 req.uri
358 .starts_with("https://example.test/api/v5/market/ticker"),
359 "unexpected URI: {}",
360 req.uri
361 );
362 }
363}