crypto_botters_coincheck/
lib.rs

1//! A crate for communicating with the [coincheck API](https://coincheck.com/ja/documents/exchange/api).
2//! For example usages, see files in the examples/ directory.
3
4use std::{
5    marker::PhantomData,
6    time::SystemTime,
7};
8use hmac::{Hmac, Mac};
9use sha2::Sha256;
10use serde::{de::DeserializeOwned, Serialize};
11use serde_json::json;
12use crypto_botters_api::{HandlerOption, HandlerOptions, HttpOption, WebSocketOption};
13use generic_api_client::{http::{*, header::HeaderValue}, websocket::*};
14
15/// The type returned by [Client::request()].
16pub type CoincheckRequestResult<T> = Result<T, CoincheckRequestError>;
17pub type CoincheckRequestError = RequestError<&'static str, CoincheckHandlerError>;
18
19/// Options that can be set when creating handlers
20pub enum CoincheckOption {
21    /// [Default] variant, does nothing
22    Default,
23    /// API key
24    Key(String),
25    /// Api secret
26    Secret(String),
27    /// Base url for HTTP requests
28    HttpUrl(CoincheckHttpUrl),
29    /// Whether [CoincheckRequestHandler] should perform authentication
30    HttpAuth(bool),
31    /// [RequestConfig] used when sending requests.
32    /// `url_prefix` will be overridden by [HttpUrl](Self::HttpUrl) unless `HttpUrl` is [CoincheckHttpUrl::None].
33    RequestConfig(RequestConfig),
34    /// Base url for WebSocket connections
35    WebSocketUrl(CoincheckWebSocketUrl),
36    /// The channels to be subscribed by [WebSocketHandler].
37    WebSocketChannels(Vec<String>),
38    /// [WebSocketConfig] used for creating [WebSocketConnection]s
39    /// `url_prefix` will be overridden by [WebSocketUrl](Self::WebSocketUrl) unless `WebSocketUrl` is [CoincheckWebSocketUrl::None].
40    /// By default, ignore_duplicate_during_reconnection` is set to `true`.
41    WebSocketConfig(WebSocketConfig),
42}
43
44/// A `struct` that represents a set of [CoincheckOption] s.
45#[derive(Clone, Debug)]
46pub struct CoincheckOptions {
47    /// see [CoincheckOption::Key]
48    pub key: Option<String>,
49    /// see [CoincheckOption::Secret]
50    pub secret: Option<String>,
51    /// see [CoincheckOption::HttpUrl]
52    pub http_url: CoincheckHttpUrl,
53    /// see [CoincheckOption::HttpAuth]
54    pub http_auth: bool,
55    /// see [CoincheckOption::RequestConfig]
56    pub request_config: RequestConfig,
57    /// see [CoincheckOption::WebSocketUrl]
58    pub websocket_url: CoincheckWebSocketUrl,
59    /// see [CoincheckOptions::WebSocketChannels]
60    pub websocket_channels: Vec<String>,
61    /// see [CoincheckOption::WebSocketConfig]
62    pub websocket_config: WebSocketConfig,
63}
64
65/// A `enum` that represents the base url of the Coincheck HTTP API.
66#[derive(Debug, Eq, PartialEq, Copy, Clone)]
67pub enum CoincheckHttpUrl {
68    /// `https://coincheck.com`
69    Default,
70    /// The url will not be modified by [CoincheckRequestHandler]
71    None,
72}
73
74/// A `enum` that represents the base url of the Coincheck Realtime API
75#[derive(Debug, Eq, PartialEq, Copy, Clone)]
76#[non_exhaustive]
77pub enum CoincheckWebSocketUrl {
78    /// `wss://ws-api.coincheck.com/`
79    Default,
80    /// The url will not be modified by [CoincheckWebSocketHandler]
81    None,
82}
83
84#[derive(Debug)]
85pub enum CoincheckHandlerError {
86    ApiError(serde_json::Value),
87    RequestLimitExceeded(serde_json::Value),
88    ParseError,
89}
90
91/// A `struct` that implements [RequestHandler]
92pub struct CoincheckRequestHandler<'a, R: DeserializeOwned> {
93    options: CoincheckOptions,
94    _phantom: PhantomData<&'a R>,
95}
96
97/// A `struct` that implements [WebSocketHandler]
98pub struct CoincheckWebSocketHandler<H: FnMut(serde_json::Value) + Send + 'static> {
99    message_handler: H,
100    options: CoincheckOptions,
101}
102
103impl<'a, B, R> RequestHandler<B> for CoincheckRequestHandler<'a, R>
104where
105    B: Serialize,
106    R: DeserializeOwned,
107{
108    type Successful = R;
109    type Unsuccessful = CoincheckHandlerError;
110    type BuildError = &'static str;
111
112    fn request_config(&self) -> RequestConfig {
113        let mut config = self.options.request_config.clone();
114        if self.options.http_url != CoincheckHttpUrl::None {
115            config.url_prefix = self.options.http_url.as_str().to_owned();
116        }
117        config
118    }
119
120    fn build_request(&self, mut builder: RequestBuilder, request_body: &Option<B>, _: u8) -> Result<Request, Self::BuildError> {
121        if let Some(body) = request_body {
122            let encoded = serde_urlencoded::to_string(body).or(Err("could not serialize body as application/x-www-form-urlencoded"))?;
123            builder = builder
124                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
125                .body(encoded);
126        }
127
128        let mut request = builder.build().or(Err("failed to build request"))?;
129
130        if self.options.http_auth {
131            // https://coincheck.com/ja/documents/exchange/api#auth
132            let time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); // always after the epoch
133            let nonce = time.as_millis() as u64;
134
135            let body = request.body()
136                .and_then(|body| body.as_bytes())
137                .map(String::from_utf8_lossy)
138                .unwrap_or_default();
139
140            let sign_contents = format!("{}{}{}", nonce, request.url(), body);
141
142            let secret = self.options.secret.as_deref().ok_or("API secret not set")?;
143            let mut hmac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); // hmac accepts key of any length
144
145            hmac.update(sign_contents.as_bytes());
146            let signature = hex::encode(hmac.finalize().into_bytes());
147
148            let key = HeaderValue::from_str(self.options.key.as_deref().ok_or("API key not set")?).or(
149                Err("invalid character in API key")
150            )?;
151            let headers = request.headers_mut();
152            headers.insert("ACCESS-KEY", key);
153            headers.insert("ACCESS-NONCE", HeaderValue::from(nonce));
154            headers.insert("ACCESS-SIGNATURE", HeaderValue::from_str(&signature).unwrap()); // hex digits are valid
155        }
156
157        Ok(request)
158    }
159
160    fn handle_response(&self, status: StatusCode, _: HeaderMap, response_body: Bytes) -> Result<Self::Successful, Self::Unsuccessful> {
161        if status.is_success() {
162            serde_json::from_slice(&response_body).map_err(|error| {
163                log::error!("Failed to parse response due to an error: {}", error);
164                CoincheckHandlerError::ParseError
165            })
166        } else {
167            let error = match serde_json::from_slice(&response_body) {
168                Ok(parsed_error) => {
169                    if status == 429 {
170                        CoincheckHandlerError::RequestLimitExceeded(parsed_error)
171                    } else {
172                        CoincheckHandlerError::ApiError(parsed_error)
173                    }
174                },
175                Err(error) => {
176                    log::error!("Failed to parse error response due to an error: {}", error);
177                    CoincheckHandlerError::ParseError
178                }
179            };
180            Err(error)
181        }
182    }
183}
184
185impl<H> WebSocketHandler for CoincheckWebSocketHandler<H> where H: FnMut(serde_json::Value) + Send + 'static, {
186    fn websocket_config(&self) -> WebSocketConfig {
187        let mut config = self.options.websocket_config.clone();
188        if self.options.websocket_url != CoincheckWebSocketUrl::None {
189            config.url_prefix = self.options.websocket_url.as_str().to_owned();
190        }
191        config
192    }
193
194    fn handle_start(&mut self) -> Vec<WebSocketMessage> {
195        self.options.websocket_channels.clone().into_iter().map(|channel| {
196            WebSocketMessage::Text(json!({ "type": "subscribe", "channel": channel }).to_string())
197        }).collect()
198    }
199
200    fn handle_message(&mut self, message: WebSocketMessage) -> Vec<WebSocketMessage> {
201        match message {
202            WebSocketMessage::Text(message) => {
203                match serde_json::from_str(&message) {
204                    Ok(message) => (self.message_handler)(message),
205                    Err(_) => log::warn!("Invalid JSON message received"),
206                };
207            },
208            WebSocketMessage::Binary(_) => log::warn!("Unexpected binary message received"),
209            WebSocketMessage::Ping(_) | WebSocketMessage::Pong(_) => (),
210        }
211        vec![]
212    }
213}
214
215impl CoincheckHttpUrl {
216    /// The base URL that this variant represents.
217    #[inline(always)]
218    fn as_str(&self) -> &'static str {
219        match self {
220            Self::Default => "https://coincheck.com",
221            Self::None => "",
222        }
223    }
224}
225
226impl CoincheckWebSocketUrl {
227    /// The base URL that this variant represents.
228    #[inline(always)]
229    fn as_str(&self) -> &'static str {
230        match self {
231            Self::Default => "wss://ws-api.coincheck.com/",
232            Self::None => "",
233        }
234    }
235}
236
237impl HandlerOptions for CoincheckOptions {
238    type OptionItem = CoincheckOption;
239
240    fn update(&mut self, option: Self::OptionItem) {
241        match option {
242            CoincheckOption::Default => (),
243            CoincheckOption::Key(v) => self.key = Some(v),
244            CoincheckOption::Secret(v) => self.secret = Some(v),
245            CoincheckOption::HttpUrl(v) => self.http_url = v,
246            CoincheckOption::HttpAuth(v) => self.http_auth = v,
247            CoincheckOption::RequestConfig(v) => self.request_config = v,
248            CoincheckOption::WebSocketUrl(v) => self.websocket_url = v,
249            CoincheckOption::WebSocketChannels(v) => self.websocket_channels = v,
250            CoincheckOption::WebSocketConfig(v) => self.websocket_config = v,
251        }
252    }
253}
254
255impl Default for CoincheckOptions {
256    fn default() -> Self {
257        let mut websocket_config = WebSocketConfig::new();
258        websocket_config.ignore_duplicate_during_reconnection = true;
259        Self {
260            key: None,
261            secret: None,
262            http_url: CoincheckHttpUrl::Default,
263            http_auth: false,
264            request_config: RequestConfig::default(),
265            websocket_url: CoincheckWebSocketUrl::Default,
266            websocket_channels: vec![],
267            websocket_config,
268        }
269    }
270}
271
272impl<'a, R: DeserializeOwned + 'a> HttpOption<'a, R> for CoincheckOption {
273    type RequestHandler = CoincheckRequestHandler<'a, R>;
274
275    #[inline(always)]
276    fn request_handler(options: Self::Options) -> Self::RequestHandler {
277        CoincheckRequestHandler::<'a, R> {
278            options,
279            _phantom: PhantomData,
280        }
281    }
282}
283
284impl<H: FnMut(serde_json::Value) + Send + 'static> WebSocketOption<H> for CoincheckOption {
285    type WebSocketHandler = CoincheckWebSocketHandler<H>;
286
287    #[inline(always)]
288    fn websocket_handler(handler: H, options: Self::Options) -> Self::WebSocketHandler {
289        CoincheckWebSocketHandler {
290            message_handler: handler,
291            options,
292        }
293    }
294}
295
296impl HandlerOption for CoincheckOption {
297    type Options = CoincheckOptions;
298}
299
300impl Default for CoincheckOption {
301    fn default() -> Self {
302        Self::Default
303    }
304}