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