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