1use chrono::{DateTime, NaiveDateTime, Utc};
2use envconfig::Envconfig;
3use regex::Regex;
4use reqwest::{
5 header::{HeaderMap, HeaderValue, InvalidHeaderName, InvalidHeaderValue},
6 Client, Method, StatusCode, Url,
7};
8use serde::{de::DeserializeOwned, Deserialize, Serialize};
9use thiserror::Error;
10
11const BASE_URL: &str = "https://api.swapspace.co/api/v2/";
12
13#[derive(Error, Debug)]
15pub enum Error {
16 #[error(transparent)]
17 ReqwestError(#[from] reqwest::Error),
18 #[error(transparent)]
19 InvalidHeaderValue(#[from] InvalidHeaderValue),
20 #[error(transparent)]
21 InvalidHeaderName(#[from] InvalidHeaderName),
22 #[error(transparent)]
23 UrlError(#[from] url::ParseError),
24 #[error("Access denied. Invalid api key.")]
26 InvalidApiKey,
27 #[error("Access denied. No api key provided.")]
29 NoApiKey,
30 #[error("Too many requests, your key access is suspended until {0}")]
32 TooManyRequests(DateTime<Utc>),
33 #[error("Internal Server Error")]
35 InternalServerError,
36 #[error("Partner not found")]
38 PartnerNotFound,
39 #[error("Currencies not found: {0}")]
41 CurrenciesNotFound(String),
42 #[error("Invalid param {0}")]
44 InvalidParam(String),
45 #[error("No partners found using filter: #{0}")]
47 NoPartnersFoundUsingFilter(String),
48 #[error("Missing required param {0}")]
50 MissingParam(String),
51 #[error("The following required params are missing: {0}")]
53 MissingRequiredParams(String),
54 #[error("Currency not found: {0}")]
56 CurrencyNotFound(String),
57 #[error("Partner {0} does not support fixed type of exchanges")]
59 PartnerDoesNotSupportFixed(String),
60 #[error("Pair cannot be processed by {0}")]
62 PartnerCannotProcessPair(String),
63 #[error("Validation of address failed")]
65 ValidationOfAddressFailed,
66 #[error("The following refund address invalid")]
68 RefundAddressInvalid,
69 #[error("userIp is incorrect")]
71 UserIpIsIncorrect,
72 #[error("Amount request failed")]
74 AmountRequestFailed,
75 #[error("IP address is forbidden")]
77 IpAddressIsForbidden,
78 #[error("Amount minimum is {0}")]
80 AmountMinimum(f64),
81 #[error("Amount maximum is {0}")]
83 AmountMaximum(f64),
84 #[error("Exchange not found")]
86 ExchangeNotFound,
87 #[error("Unmatched error: {0}")]
88 UnmatchedError(String),
89}
90
91impl From<(StatusCode, String)> for Error {
92 fn from(val: (StatusCode, String)) -> Self {
93 match val {
94 (StatusCode::UNAUTHORIZED, text) => match text.as_str() {
95 "Access denied. Invalid api key." => Error::InvalidApiKey,
96 "Access denied. No api key provided." => Error::NoApiKey,
97 _ => Error::UnmatchedError(text),
98 },
99 (StatusCode::TOO_MANY_REQUESTS, text) => text
100 .strip_prefix("Too many requests, your key access is suspended until ")
101 .map_or_else(
102 || Error::UnmatchedError(text.clone()),
103 |date| {
104 NaiveDateTime::parse_from_str(date, "%Y-%m-%d %H:%M UTC.")
105 .as_ref()
106 .map(NaiveDateTime::and_utc)
107 .map_or_else(
108 |_| Error::UnmatchedError(text.clone()),
109 Error::TooManyRequests,
110 )
111 },
112 ),
113 (StatusCode::BAD_REQUEST, text) => match text.as_str() {
114 t if t.starts_with("Missing required param ") => t
115 .strip_prefix("Missing required param ")
116 .map(|param| Error::MissingParam(param.to_string()))
117 .unwrap(),
118 t if t.starts_with("The following required params are missing: ") => t
119 .strip_prefix("The following required params are missing: ")
120 .map(|params| Error::MissingRequiredParams(params.to_string()))
121 .unwrap(),
122 t if t.starts_with("Partner ")
123 && t.ends_with(" does not support fixed type of exchanges") =>
124 {
125 t.strip_prefix("Partner ")
126 .unwrap()
127 .strip_suffix(" does not support fixed type of exchanges")
128 .map(|partner| Error::PartnerDoesNotSupportFixed(partner.to_string()))
129 .unwrap()
130 }
131 _ => Error::UnmatchedError(text),
132 },
133 (StatusCode::INTERNAL_SERVER_ERROR, _) => Error::InternalServerError,
134 (StatusCode::UNPROCESSABLE_ENTITY, text) => match text.as_str() {
135 "Partner not found" => Error::PartnerNotFound,
136 "Validation of address failed" => Error::ValidationOfAddressFailed,
137 "The following refund address invalid" => Error::RefundAddressInvalid,
138 "userIp is incorrect" => Error::UserIpIsIncorrect,
139 "Amount request failed" => Error::AmountRequestFailed,
140 t if t.starts_with("No partners found using filter: ") => t
141 .strip_prefix("No partners found using filter: ")
142 .map(|filter| Error::NoPartnersFoundUsingFilter(filter.to_string()))
143 .unwrap(),
144 t if t.starts_with("Currencies not found: ") => t
145 .strip_prefix("Currencies not found: ")
146 .map(|currency| Error::CurrenciesNotFound(currency.to_string()))
147 .unwrap(),
148 t if t.starts_with("Invalid param ") => t
149 .strip_prefix("Invalid param ")
150 .map(|param| Error::InvalidParam(param.to_string()))
151 .unwrap(),
152 t if t.starts_with("Missing required param ") => t
153 .strip_prefix("Missing required param ")
154 .map(|param| Error::MissingParam(param.to_string()))
155 .unwrap(),
156 t if t.starts_with("Currency not found: ") => t
157 .strip_prefix("Currency not found: ")
158 .map(|currency| Error::CurrencyNotFound(currency.to_string()))
159 .unwrap(),
160 t if t.starts_with("Pair cannot be processed by ") => t
161 .strip_prefix("Pair cannot be processed by ")
162 .map(|partner| Error::PartnerCannotProcessPair(partner.to_string()))
163 .unwrap(),
164 t if t.starts_with("Amount minimum is ") => t
165 .strip_prefix("Amount minimum is ")
166 .map(|amount| {
167 amount.parse().map_or_else(
168 |_| Error::UnmatchedError(text.clone()),
169 Error::AmountMinimum,
170 )
171 })
172 .unwrap(),
173 t if t.starts_with("Amount maximum is ") => t
174 .strip_prefix("Amount maximum is ")
175 .map(|amount| {
176 amount.parse().map_or_else(
177 |_| Error::UnmatchedError(text.clone()),
178 Error::AmountMaximum,
179 )
180 })
181 .unwrap(),
182 _ => Error::UnmatchedError(text),
183 },
184 (StatusCode::FORBIDDEN, _) => Error::IpAddressIsForbidden,
185 (StatusCode::NOT_FOUND, _) => Error::ExchangeNotFound,
186 (_, text) => Error::UnmatchedError(text),
187 }
188 }
189}
190
191#[derive(Envconfig)]
192pub struct Config {
193 #[envconfig(from = "SWAPSPACE_API_KEY")]
194 pub swapspace_api_key: String,
195}
196
197impl Default for SwapSpaceApi {
198 fn default() -> Self {
199 let config = Config::init_from_env().expect("Failed to read environment variables");
200 Self::new(config.swapspace_api_key.clone()).expect("Failed to create reqwest client")
201 }
202}
203
204pub struct SwapSpaceApi {
205 pub client: Client,
206 pub base_url: Url,
207}
208
209#[derive(Debug, Clone)]
210pub struct GetAmounts {
211 pub from_currency: String,
212 pub from_network: String,
213 pub to_currency: String,
214 pub to_network: String,
215 pub amount: f64,
216 pub partner: Option<Vec<String>>,
217 pub fixed: bool,
218 pub float: bool,
219}
220
221#[derive(Debug, Clone)]
222pub struct ValidationRegexp(pub Regex);
223
224impl ValidationRegexp {
225 pub fn new(validation_regexp: &str) -> Result<Self, regex::Error> {
226 let regexp = validation_regexp
227 .strip_prefix('/')
228 .unwrap_or(validation_regexp)
229 .strip_suffix('/')
230 .unwrap_or(validation_regexp);
231 Regex::new(regexp).map(Self)
232 }
233}
234
235fn deserialize_validation_regexp<'de, D>(deserializer: D) -> Result<ValidationRegexp, D::Error>
236where
237 D: serde::Deserializer<'de>,
238{
239 let validation_regexp = String::deserialize(deserializer)?;
240 ValidationRegexp::new(&validation_regexp).map_err(serde::de::Error::custom)
241}
242
243#[derive(Default, Clone, Serialize, Deserialize, Debug)]
244pub struct Address(pub String);
245
246impl Address {
247 pub fn new(address: String, validation_regexp: &ValidationRegexp) -> Result<Self, Error> {
248 if !validation_regexp.0.is_match(&address) {
249 Err(Error::ValidationOfAddressFailed)
250 } else {
251 Ok(Self(address))
252 }
253 }
254}
255
256#[derive(Debug, Serialize, Clone)]
257#[serde(rename_all = "camelCase")]
258pub struct ExchangeRequest {
259 pub partner: String,
260 pub from_currency: String,
261 pub from_network: String,
262 pub to_currency: String,
263 pub to_network: String,
264 pub address: Address,
265 pub amount: f64,
266 pub fixed: bool,
267 pub extra_id: String,
268 pub rate_id: String,
269 pub user_ip: String,
270 pub refund: Address,
271}
272
273#[derive(Debug, Deserialize, Clone)]
274#[serde(rename_all = "camelCase")]
275pub struct CurrencyResponse {
276 pub name: String,
277 pub extra_id_name: String,
278 pub icon: String,
279 pub deposit: bool,
280 pub withdrawal: bool,
281 #[serde(deserialize_with = "deserialize_validation_regexp")]
282 pub validation_regexp: ValidationRegexp,
283 pub contract_address: Option<String>,
284 pub code: String,
285 pub network: String,
286 pub has_extra_id: bool,
287 pub id: String,
288 pub popular: bool,
289 pub fiat: Option<bool>,
290 pub buy: Option<bool>,
291 pub network_name: Option<String>,
292}
293pub type Currencies = Vec<CurrencyResponse>;
294
295#[derive(Debug, Deserialize, Clone)]
296#[serde(rename_all = "camelCase")]
297pub struct PartnerResponse {
298 pub fixed: bool,
299 pub float: bool,
300 pub req_fixed_refund: bool,
301 pub req_float_refund: bool,
302 pub name: String,
303 pub path: String,
304 pub fiat_provider: Option<bool>,
305 pub prohibited_countries: Option<Vec<String>>,
306 pub kyc_level: Option<String>,
307}
308pub type Partners = Vec<PartnerResponse>;
309
310#[derive(Debug, Deserialize, Clone, PartialEq)]
311#[serde(rename_all = "camelCase")]
312pub struct AmountResponse {
313 pub partner: String,
314 pub from_amount: f64,
315 pub to_amount: f64,
316 pub from_currency: String,
317 pub from_network: String,
318 pub to_currency: String,
319 pub to_network: String,
320 pub support_rate: u32,
321 pub duration: String,
322 pub fixed: bool,
323 pub min: f64,
324 pub max: f64,
325 pub exists: bool,
326 pub id: String,
327}
328pub type Amounts = Vec<AmountResponse>;
329
330#[derive(Debug, Deserialize, Clone)]
331#[serde(rename_all = "camelCase")]
332pub struct Timestamps {
333 pub created_at: String,
334 pub expires_at: String,
335}
336
337#[derive(Debug, Deserialize, Clone)]
338#[serde(rename_all = "camelCase")]
339pub struct ExchangeCurrency {
340 pub code: String,
341 pub network: String,
342 pub amount: f64,
343 pub address: String,
344 pub extra_id: String,
345 pub transaction_hash: String,
346 pub contract_address: Option<String>,
347}
348
349#[derive(Debug, Deserialize, Clone)]
350#[serde(rename_all = "camelCase")]
351pub struct BlockExplorerUrl {
352 pub from: String,
353 pub to: String,
354}
355
356#[derive(Debug, Deserialize, Clone)]
357#[serde(rename_all = "camelCase")]
358pub struct ExchangeResponse {
359 pub id: String,
360 pub partner: String,
361 pub fixed: bool,
362 pub timestamps: Timestamps,
363 pub from: ExchangeCurrency,
364 pub to: ExchangeCurrency,
365 pub rate: f64,
366 pub status: String,
367 pub confirmations: i32,
368 pub refund_extra_id: String,
369 pub block_explorer_transaction_url: BlockExplorerUrl,
370 pub block_explorer_address_url: BlockExplorerUrl,
371 pub payment_url: Option<String>,
372 pub refund_address: Option<String>,
373 pub error: Option<bool>,
374 pub token: Option<String>,
375 pub warnings: BlockExplorerUrl,
376}
377
378impl From<AmountResponse> for GetAmounts {
379 fn from(amount: AmountResponse) -> Self {
380 Self {
381 from_currency: amount.from_currency,
382 from_network: amount.from_network,
383 to_currency: amount.to_currency,
384 to_network: amount.to_network,
385 amount: amount.from_amount,
386 partner: None,
387 fixed: false,
388 float: false,
389 }
390 }
391}
392
393impl SwapSpaceApi {
394 pub fn new(api_key: String) -> Result<Self, Error> {
404 if api_key.is_empty() {
405 return Err(Error::NoApiKey);
406 }
407 let mut headers = HeaderMap::new();
408 headers.insert("Authorization", HeaderValue::from_str(&api_key)?);
409 headers.insert("Content-Type", HeaderValue::from_static("application/json"));
410 let client = Client::builder().default_headers(headers).build()?;
411 let base_url = BASE_URL.parse().unwrap();
412 Ok(Self { client, base_url })
413 }
414
415 fn get_full_url(&self, endpoint: &str, params: Option<&GetAmounts>) -> Result<Url, Error> {
416 let mut url = self.base_url.join(endpoint)?;
417 let mut url_builder = url.query_pairs_mut();
418 if let Some(params) = params {
419 url_builder.append_pair("fromCurrency", ¶ms.from_currency);
420 url_builder.append_pair("fromNetwork", ¶ms.from_network);
421 url_builder.append_pair("toCurrency", ¶ms.to_currency);
422 url_builder.append_pair("toNetwork", ¶ms.to_network);
423 url_builder.append_pair("amount", ¶ms.amount.to_string());
424 if let Some(partner) = ¶ms.partner {
425 url_builder.append_pair("partner", &partner.join(","));
426 }
427 if params.fixed {
428 url_builder.append_pair("fixed", "true");
429 }
430 if params.float {
431 url_builder.append_pair("float", "true");
432 }
433 }
434 Ok(url_builder.finish().to_owned())
435 }
436
437 async fn send_request<T: DeserializeOwned>(
438 &self,
439 url: Url,
440 method: Method,
441 json_params: Option<ExchangeRequest>,
442 ) -> Result<T, Error> {
443 #[cfg(feature = "log")]
444 log::debug!("Sending {method} request to {url}");
445 #[cfg(feature = "log")]
446 log::debug!("Params: {json_params:#?}");
447 let request = self.client.request(method, url);
448 let request = match json_params {
449 Some(json_params) => request.json(&json_params),
450 None => request,
451 };
452 let response = request.send().await?;
453 match response.error_for_status_ref() {
454 Ok(_) => Ok(response.json().await?),
455 Err(err) => {
456 let status = err.status();
457 match status {
458 Some(status) => {
459 let text = response.text().await?;
460 Err((status, text).into())
461 }
462 None => Err(err.into()),
463 }
464 }
465 }
466 }
467
468 pub async fn get_currencies(&self) -> Result<Currencies, Error> {
480 let url = self.get_full_url("currencies", None)?;
481 self.send_request(url, Method::GET, None).await
482 }
483
484 pub async fn get_amounts(&self, get_amounts: &GetAmounts) -> Result<Amounts, Error> {
512 let url = self.get_full_url("amounts", Some(get_amounts))?;
513 self.send_request(url, Method::GET, None).await
514 }
515
516 pub async fn get_amounts_best(
545 &self,
546 get_amounts: &GetAmounts,
547 ) -> Result<AmountResponse, Error> {
548 let url = self.get_full_url("amounts/best", Some(get_amounts))?;
549 self.send_request(url, Method::GET, None).await
550 }
551
552 pub async fn get_partners(&self) -> Result<Partners, Error> {
564 let url = self.get_full_url("partners", None)?;
565 self.send_request(url, Method::GET, None).await
566 }
567
568 pub async fn post_exchange(&self, data: ExchangeRequest) -> Result<ExchangeResponse, Error> {
601 let url = self.get_full_url("exchange", None)?;
602 self.send_request(url, Method::POST, Some(data)).await
603 }
604
605 pub async fn get_exchange_status(&self, id: &str) -> Result<ExchangeResponse, Error> {
619 let url = self.get_full_url(&format!("exchange/{}", id), None)?;
620 self.send_request(url, Method::GET, None).await
621 }
622}