use bytes::Bytes;
use http::Method;
use http::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::OkxRegion;
use crate::api::account::Account;
use crate::api::convert::Convert;
use crate::api::finance::Finance;
use crate::api::funding::Funding;
use crate::api::market::Market;
use crate::api::public_data::PublicData;
use crate::api::trade::Trade;
use crate::credentials::Credentials;
use crate::error::{Error, RestError};
use crate::model::OkxResponse;
use crate::signing;
use crate::transport::{DefaultTransport, Transport};
pub struct OkxClient<T = DefaultTransport> {
transport: T,
credentials: Option<Credentials>,
base_url: String,
demo: bool,
}
#[cfg(feature = "reqwest")]
impl OkxClient {
pub fn builder() -> OkxClientBuilder<DefaultTransport> {
OkxClientBuilder::from_transport(crate::transport::ReqwestTransport::new())
}
}
impl<T> OkxClient<T> {
pub fn with_transport(transport: T) -> OkxClientBuilder<T> {
OkxClientBuilder::from_transport(transport)
}
}
impl<T: Transport> OkxClient<T> {
pub fn market(&self) -> Market<'_, T> {
Market::new(self)
}
pub fn public_data(&self) -> PublicData<'_, T> {
PublicData::new(self)
}
pub fn account(&self) -> Account<'_, T> {
Account::new(self)
}
pub fn funding(&self) -> Funding<'_, T> {
Funding::new(self)
}
pub fn convert(&self) -> Convert<'_, T> {
Convert::new(self)
}
pub fn finance(&self) -> Finance<'_, T> {
Finance::new(self)
}
pub fn trade(&self) -> Trade<'_, T> {
Trade::new(self)
}
pub(crate) async fn get<Q, D>(
&self,
endpoint: &'static str,
query: &Q,
authenticated: bool,
) -> Result<D, Error>
where
Q: Serialize,
D: DeserializeOwned,
{
let qs = serde_urlencoded::to_string(query)
.map_err(|e| RestError::Encode { source: e.into() })?;
let request_path = if qs.is_empty() {
endpoint.to_owned()
} else {
format!("{endpoint}?{qs}")
};
self.send(
endpoint,
Method::GET,
&request_path,
Bytes::new(),
authenticated,
)
.await
}
pub(crate) async fn post<B, D>(
&self,
endpoint: &'static str,
body: &B,
authenticated: bool,
) -> Result<D, Error>
where
B: Serialize,
D: DeserializeOwned,
{
let body = serde_json::to_vec(body).map_err(|e| RestError::Encode { source: e.into() })?;
self.send(
endpoint,
Method::POST,
endpoint,
Bytes::from(body),
authenticated,
)
.await
}
async fn send<D>(
&self,
endpoint: &'static str,
method: Method,
request_path: &str,
body: Bytes,
authenticated: bool,
) -> Result<D, Error>
where
D: DeserializeOwned,
{
let url = format!("{}{}", self.base_url, request_path);
let mut builder = http::Request::builder().method(method.clone()).uri(url);
let headers = builder
.headers_mut()
.expect("a freshly constructed request builder has no error");
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
if self.demo {
headers.insert(
HeaderName::from_static("x-simulated-trading"),
HeaderValue::from_static("1"),
);
}
if authenticated {
let credentials = self.credentials.as_ref().ok_or_else(|| {
RestError::Configuration("authenticated endpoint requires credentials".to_owned())
})?;
let timestamp = signing::timestamp();
let body_str = std::str::from_utf8(&body).unwrap_or_default();
let prehash = signing::pre_hash(×tamp, method.as_str(), request_path, body_str);
let signature = signing::sign(&prehash, credentials.secret_key());
insert_header(headers, "ok-access-key", credentials.api_key())?;
insert_header(headers, "ok-access-sign", &signature)?;
insert_header(headers, "ok-access-timestamp", ×tamp)?;
insert_header(headers, "ok-access-passphrase", credentials.passphrase())?;
}
let request = builder
.body(body)
.map_err(|e| RestError::Encode { source: e.into() })?;
let response = self
.transport
.send(request)
.await
.map_err(RestError::from)?;
let status = response.status();
let bytes = response.into_body();
if !status.is_success() {
return Err(RestError::HttpStatus {
endpoint,
status,
body: String::from_utf8_lossy(&bytes).into_owned(),
}
.into());
}
let envelope: OkxResponse<D> =
serde_json::from_slice(&bytes).map_err(|e| RestError::Decode {
endpoint,
source: e,
})?;
if envelope.code != "0" {
return Err(RestError::Okx {
endpoint,
code: envelope.code,
message: envelope.msg,
}
.into());
}
Ok(envelope.data)
}
}
fn insert_header(headers: &mut HeaderMap, name: &'static str, value: &str) -> Result<(), Error> {
let value = HeaderValue::from_str(value)
.map_err(|e| RestError::Configuration(format!("invalid header value for {name}: {e}")))?;
headers.insert(HeaderName::from_static(name), value);
Ok(())
}
pub struct OkxClientBuilder<T = DefaultTransport> {
transport: T,
credentials: Option<Credentials>,
base_url: String,
demo: bool,
}
impl<T> OkxClientBuilder<T> {
fn from_transport(transport: T) -> Self {
Self {
transport,
credentials: None,
base_url: crate::API_URL.to_owned(),
demo: false,
}
}
pub fn credentials(mut self, credentials: Credentials) -> Self {
self.credentials = Some(credentials);
self
}
pub fn region(mut self, region: OkxRegion) -> Self {
self.base_url = region.api_url().to_owned();
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
pub fn demo_trading(mut self, demo: bool) -> Self {
self.demo = demo;
self
}
pub fn transport<U>(self, transport: U) -> OkxClientBuilder<U> {
OkxClientBuilder {
transport,
credentials: self.credentials,
base_url: self.base_url,
demo: self.demo,
}
}
pub fn build(self) -> OkxClient<T> {
OkxClient {
transport: self.transport,
credentials: self.credentials,
base_url: self.base_url,
demo: self.demo,
}
}
}
#[cfg(test)]
mod tests {
use crate::error::{Error, RestError};
use crate::test_util::MockTransport;
use crate::{OkxClient, OkxRegion};
#[tokio::test]
async fn non_zero_code_is_api_error() {
let mock = MockTransport::new(r#"{"code":"51000","msg":"Parameter error","data":[]}"#);
let client = OkxClient::with_transport(mock).build();
let err = client.market().get_ticker("BAD").await.unwrap_err();
match err {
Error::Rest(RestError::Okx { code, message, .. }) => {
assert_eq!(code, "51000");
assert_eq!(message, "Parameter error");
}
other => panic!("expected Error::Rest(RestError::Okx), got {other:?}"),
}
}
#[test]
fn okx_region_returns_expected_api_urls() {
assert_eq!(OkxRegion::Global.api_url(), "https://www.okx.com");
assert_eq!(OkxRegion::Us.api_url(), "https://us.okx.com");
assert_eq!(OkxRegion::Eea.api_url(), "https://eea.okx.com");
}
#[tokio::test]
async fn region_sets_request_base_url() {
let mock = MockTransport::new(r#"{"code":"0","msg":"","data":[]}"#);
let client = OkxClient::with_transport(mock.clone())
.region(OkxRegion::Us)
.build();
client.market().get_ticker("BTC-USDT").await.unwrap();
let req = mock.captured();
assert!(
req.uri
.starts_with("https://us.okx.com/api/v5/market/ticker"),
"unexpected URI: {}",
req.uri
);
}
#[tokio::test]
async fn base_url_overrides_selected_region() {
let mock = MockTransport::new(r#"{"code":"0","msg":"","data":[]}"#);
let client = OkxClient::with_transport(mock.clone())
.region(OkxRegion::Eea)
.base_url("https://example.test")
.build();
client.market().get_ticker("BTC-USDT").await.unwrap();
let req = mock.captured();
assert!(
req.uri
.starts_with("https://example.test/api/v5/market/ticker"),
"unexpected URI: {}",
req.uri
);
}
}