#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::collections::HashMap;
use chrono::{DateTime, FixedOffset};
use schemars::JsonSchema;
use semver::Version;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Serialize, Deserialize, JsonSchema, PartialEq, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct TokenList {
pub name: String,
pub timestamp: DateTime<FixedOffset>,
#[serde(with = "version")]
#[schemars(with = "Version")]
pub version: Version,
#[serde(rename = "logoURI", skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<Url>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub tags: HashMap<String, Tag>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tokens: Vec<Token>,
}
impl TokenList {
#[cfg(feature = "from-uri")]
pub async fn from_uri<T: reqwest::IntoUrl>(uri: T) -> Result<Self, Error> {
Ok(reqwest::get(uri).await?.error_for_status()?.json().await?)
}
#[cfg(feature = "from-uri-blocking")]
pub fn from_uri_blocking<T: reqwest::IntoUrl>(uri: T) -> Result<Self, Error> {
Ok(reqwest::blocking::get(uri)?.error_for_status()?.json()?)
}
#[cfg(feature = "from-uri-compat")]
pub async fn from_uri_compat<T: reqwest09::IntoUrl>(uri: T) -> Result<Self, Error> {
use futures::compat::Future01CompatExt;
use futures01::Future;
use reqwest09::r#async::{Client, Response};
let fut = Client::new()
.get(uri)
.send()
.and_then(Response::error_for_status)
.and_then(|mut res| res.json())
.compat();
Ok(fut.await?)
}
}
#[derive(Serialize, Deserialize, JsonSchema, PartialEq, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Token {
pub name: String,
pub symbol: String,
pub address: String,
pub chain_id: u32,
pub decimals: u16,
#[serde(rename = "logoURI", skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<Url>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub extensions: HashMap<String, Option<ExtensionValue>>,
}
impl Token {
pub fn polygon_address(&self) -> Option<&str> {
self.extensions
.get("polygonAddress")
.and_then(|val| val.as_ref().and_then(|v| v.as_str()))
}
}
#[derive(Serialize, Deserialize, JsonSchema, PartialEq, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Tag {
pub name: String,
pub description: String,
}
#[derive(Serialize, Deserialize, JsonSchema, PartialEq, Clone, Debug)]
#[serde(untagged)]
#[allow(missing_docs)]
pub enum ExtensionValue {
String(String),
Number(Number),
Boolean(bool),
}
impl ExtensionValue {
pub fn as_str(&self) -> Option<&str> {
match self {
ExtensionValue::String(val) => Some(val),
ExtensionValue::Number(_) => None,
ExtensionValue::Boolean(_) => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
ExtensionValue::String(_) => None,
ExtensionValue::Number(_) => None,
ExtensionValue::Boolean(val) => Some(*val),
}
}
pub fn as_i64(&self) -> Option<i64> {
match self {
ExtensionValue::String(_) => None,
ExtensionValue::Number(val) => val.as_i64(),
ExtensionValue::Boolean(_) => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match self {
ExtensionValue::String(_) => None,
ExtensionValue::Number(val) => val.as_f64(),
ExtensionValue::Boolean(_) => None,
}
}
}
#[derive(Serialize, Deserialize, JsonSchema, PartialEq, Clone, Copy, Debug)]
#[serde(untagged)]
#[allow(missing_docs)]
pub enum Number {
Integer(i64),
Float(f64),
}
impl Number {
pub fn as_i64(&self) -> Option<i64> {
match self {
Number::Integer(val) => Some(*val),
Number::Float(_) => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match self {
Number::Integer(_) => None,
Number::Float(val) => Some(*val),
}
}
}
#[cfg(any(
feature = "from-uri",
feature = "from-uri-blocking",
feature = "from-uri-compat"
))]
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[cfg(feature = "from-uri")]
#[error(transparent)]
Transport(#[from] reqwest::Error),
#[cfg(feature = "from-uri-compat")]
#[error(transparent)]
TransportCompat(#[from] reqwest09::Error),
}
mod version {
use semver::Version;
use serde::{de, ser::SerializeStruct, Deserialize};
pub fn serialize<S>(value: &Version, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut version = serializer.serialize_struct("Version", 3)?;
version.serialize_field("major", &value.major)?;
version.serialize_field("minor", &value.minor)?;
version.serialize_field("patch", &value.patch)?;
version.end()
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Version, D::Error>
where
D: de::Deserializer<'de>,
{
#[derive(Deserialize)]
struct InternalVersion {
major: u64,
minor: u64,
patch: u64,
}
InternalVersion::deserialize(deserializer).map(|v| Version::new(v.major, v.minor, v.patch))
}
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use serde_json::json;
use super::*;
const TELCOINS_TOKEN_LIST_URI: &str =
"https://raw.githubusercontent.com/telcoin/token-lists/e6a4cd7/telcoins.json";
#[cfg(feature = "from-uri")]
#[tokio::test]
async fn from_uri() {
let _token_list = TokenList::from_uri(TELCOINS_TOKEN_LIST_URI).await.unwrap();
}
#[cfg(feature = "from-uri-blocking")]
#[test]
fn from_uri_blocking() {
let _token_list = TokenList::from_uri_blocking(TELCOINS_TOKEN_LIST_URI).unwrap();
}
#[cfg(feature = "from-uri-compat")]
#[test]
fn from_uri_compat() {
use futures::future::{FutureExt, TryFutureExt};
use tokio01::runtime::Runtime;
let mut rt = Runtime::new().unwrap();
rt.block_on(
TokenList::from_uri_compat(TELCOINS_TOKEN_LIST_URI)
.boxed()
.compat(),
)
.unwrap();
}
#[test]
fn can_serialize_deserialize_required_fields() {
let data_json = json!({
"name": "TELcoins",
"timestamp": "2021-07-05T20:25:22Z",
"version": { "major": 0, "minor": 1, "patch": 0 },
"tokens": [
{
"name": "Telcoin",
"symbol": "TEL",
"address": "0x467bccd9d29f223bce8043b84e8c8b282827790f",
"chainId": 1,
"decimals": 2
}
]
});
let data_rs = TokenList {
name: "TELcoins".to_owned(),
timestamp: FixedOffset::west(0).ymd(2021, 7, 5).and_hms(20, 25, 22),
version: Version::new(0, 1, 0),
logo_uri: None,
keywords: vec![],
tags: HashMap::new(),
tokens: vec![Token {
name: "Telcoin".to_owned(),
symbol: "TEL".to_owned(),
address: "0x467bccd9d29f223bce8043b84e8c8b282827790f".to_owned(),
chain_id: 1,
decimals: 2,
logo_uri: None,
tags: vec![],
extensions: HashMap::new(),
}],
};
assert_eq!(serde_json::to_value(&data_rs).unwrap(), data_json);
let token_list: TokenList = serde_json::from_value(data_json).unwrap();
assert_eq!(token_list, data_rs);
}
#[test]
fn can_serialize_deserialize_all_fields() {
let data_json = json!({
"name": "TELcoins",
"timestamp": "2021-07-05T20:25:22Z",
"version": { "major": 0, "minor": 1, "patch": 0 },
"logoURI": "https://raw.githubusercontent.com/telcoin/token-lists/master/assets/logo-telcoin-250x250.png",
"keywords": ["defi", "telcoin"],
"tags": {
"telcoin": {
"description": "Part of the Telcoin ecosystem.",
"name": "telcoin"
}
},
"tokens": [
{
"name": "Telcoin",
"symbol": "TEL",
"address": "0x467bccd9d29f223bce8043b84e8c8b282827790f",
"chainId": 1,
"decimals": 2,
"logoURI": "https://raw.githubusercontent.com/telcoin/token-lists/master/assets/logo-telcoin-250x250.png",
"tags": ["telcoin"],
"extensions": {
"is_mapped_to_polygon": true,
"polygon_address": "0xdf7837de1f2fa4631d716cf2502f8b230f1dcc32",
"polygon_chain_id": 137
}
}
]
});
let logo_uri: Url = "https://raw.githubusercontent.com/telcoin/token-lists/master/assets/logo-telcoin-250x250.png".parse().unwrap();
let data_rs = TokenList {
name: "TELcoins".to_owned(),
timestamp: FixedOffset::west(0).ymd(2021, 7, 5).and_hms(20, 25, 22),
version: Version::new(0, 1, 0),
logo_uri: Some(logo_uri.clone()),
keywords: vec!["defi".to_owned(), "telcoin".to_owned()],
tags: vec![(
"telcoin".to_owned(),
Tag {
name: "telcoin".to_owned(),
description: "Part of the Telcoin ecosystem.".to_owned(),
},
)]
.into_iter()
.collect(),
tokens: vec![Token {
name: "Telcoin".to_owned(),
symbol: "TEL".to_owned(),
address: "0x467bccd9d29f223bce8043b84e8c8b282827790f".to_owned(),
chain_id: 1,
decimals: 2,
logo_uri: Some(logo_uri),
tags: vec!["telcoin".to_owned()],
extensions: vec![
(
"is_mapped_to_polygon".to_owned(),
Some(ExtensionValue::Boolean(true)),
),
(
"polygon_address".to_owned(),
Some(ExtensionValue::String(
"0xdf7837de1f2fa4631d716cf2502f8b230f1dcc32".to_owned(),
)),
),
(
"polygon_chain_id".to_owned(),
Some(ExtensionValue::Number(Number::Integer(137))),
),
]
.into_iter()
.collect(),
}],
};
assert_eq!(serde_json::to_value(&data_rs).unwrap(), data_json,);
let token_list: TokenList = serde_json::from_value(data_json).unwrap();
assert_eq!(token_list, data_rs);
}
}