1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3
4use std::collections::HashMap;
23
24use chrono::{DateTime, FixedOffset};
25use schemars::JsonSchema;
26use semver::Version;
27use serde::{Deserialize, Serialize};
28use url::Url;
29
30#[derive(Serialize, Deserialize, JsonSchema, PartialEq, Clone, Debug)]
34#[serde(rename_all = "camelCase")]
35pub struct TokenList {
36 pub name: String,
38
39 pub timestamp: DateTime<FixedOffset>,
42
43 #[serde(with = "version")]
45 #[schemars(with = "Version")]
46 pub version: Version,
47
48 #[serde(rename = "logoURI", skip_serializing_if = "Option::is_none")]
50 pub logo_uri: Option<Url>,
51
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub keywords: Vec<String>,
56
57 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
59 pub tags: HashMap<String, Tag>,
60
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub tokens: Vec<Token>,
64}
65
66impl TokenList {
67 #[cfg(feature = "from-uri")]
71 pub async fn from_uri<T: reqwest::IntoUrl>(uri: T) -> Result<Self, Error> {
72 Ok(reqwest::get(uri).await?.error_for_status()?.json().await?)
73 }
74
75 #[cfg(feature = "from-uri-blocking")]
77 pub fn from_uri_blocking<T: reqwest::IntoUrl>(uri: T) -> Result<Self, Error> {
78 Ok(reqwest::blocking::get(uri)?.error_for_status()?.json()?)
79 }
80
81 #[cfg(feature = "from-uri-compat")]
85 pub async fn from_uri_compat<T: reqwest09::IntoUrl>(uri: T) -> Result<Self, Error> {
86 use futures::compat::Future01CompatExt;
87 use futures01::Future;
88 use reqwest09::r#async::{Client, Response};
89
90 let fut = Client::new()
91 .get(uri)
92 .send()
93 .and_then(Response::error_for_status)
94 .and_then(|mut res| res.json())
95 .compat();
96
97 Ok(fut.await?)
98 }
99}
100
101#[derive(Serialize, Deserialize, JsonSchema, PartialEq, Clone, Debug)]
103#[serde(rename_all = "camelCase")]
104pub struct Token {
105 pub name: String,
107
108 pub symbol: String,
110
111 pub address: String,
113
114 pub chain_id: u32,
116
117 pub decimals: u16,
119
120 #[serde(rename = "logoURI", skip_serializing_if = "Option::is_none")]
123 pub logo_uri: Option<Url>,
124
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub tags: Vec<String>,
129
130 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
132 pub extensions: HashMap<String, Option<ExtensionValue>>,
133}
134
135impl Token {
136 pub fn polygon_address(&self) -> Option<&str> {
139 self.extensions
140 .get("polygonAddress")
141 .and_then(|val| val.as_ref().and_then(|v| v.as_str()))
142 }
143}
144
145#[derive(Serialize, Deserialize, JsonSchema, PartialEq, Clone, Debug)]
147#[serde(rename_all = "camelCase")]
148pub struct Tag {
149 pub name: String,
151
152 pub description: String,
154}
155
156#[derive(Serialize, Deserialize, JsonSchema, PartialEq, Clone, Debug)]
158#[serde(untagged)]
159#[allow(missing_docs)]
160pub enum ExtensionValue {
161 String(String),
162 Number(Number),
163 Boolean(bool),
164}
165
166impl ExtensionValue {
167 pub fn as_str(&self) -> Option<&str> {
170 match self {
171 ExtensionValue::String(val) => Some(val),
172 ExtensionValue::Number(_) => None,
173 ExtensionValue::Boolean(_) => None,
174 }
175 }
176
177 pub fn as_bool(&self) -> Option<bool> {
180 match self {
181 ExtensionValue::String(_) => None,
182 ExtensionValue::Number(_) => None,
183 ExtensionValue::Boolean(val) => Some(*val),
184 }
185 }
186
187 pub fn as_i64(&self) -> Option<i64> {
190 match self {
191 ExtensionValue::String(_) => None,
192 ExtensionValue::Number(val) => val.as_i64(),
193 ExtensionValue::Boolean(_) => None,
194 }
195 }
196
197 pub fn as_f64(&self) -> Option<f64> {
200 match self {
201 ExtensionValue::String(_) => None,
202 ExtensionValue::Number(val) => val.as_f64(),
203 ExtensionValue::Boolean(_) => None,
204 }
205 }
206}
207
208#[derive(Serialize, Deserialize, JsonSchema, PartialEq, Clone, Copy, Debug)]
210#[serde(untagged)]
211#[allow(missing_docs)]
212pub enum Number {
213 Integer(i64),
214 Float(f64),
215}
216
217impl Number {
218 pub fn as_i64(&self) -> Option<i64> {
221 match self {
222 Number::Integer(val) => Some(*val),
223 Number::Float(_) => None,
224 }
225 }
226
227 pub fn as_f64(&self) -> Option<f64> {
230 match self {
231 Number::Integer(_) => None,
232 Number::Float(val) => Some(*val),
233 }
234 }
235}
236
237#[cfg(any(
239 feature = "from-uri",
240 feature = "from-uri-blocking",
241 feature = "from-uri-compat"
242))]
243#[derive(thiserror::Error, Debug)]
244pub enum Error {
245 #[cfg(feature = "from-uri")]
247 #[error(transparent)]
248 Transport(#[from] reqwest::Error),
249
250 #[cfg(feature = "from-uri-compat")]
252 #[error(transparent)]
253 TransportCompat(#[from] reqwest09::Error),
254}
255
256mod version {
257 use semver::Version;
258 use serde::{de, ser::SerializeStruct, Deserialize};
259
260 pub fn serialize<S>(value: &Version, serializer: S) -> Result<S::Ok, S::Error>
261 where
262 S: serde::Serializer,
263 {
264 let mut version = serializer.serialize_struct("Version", 3)?;
265 version.serialize_field("major", &value.major)?;
266 version.serialize_field("minor", &value.minor)?;
267 version.serialize_field("patch", &value.patch)?;
268 version.end()
269 }
270
271 pub fn deserialize<'de, D>(deserializer: D) -> Result<Version, D::Error>
272 where
273 D: de::Deserializer<'de>,
274 {
275 #[derive(Deserialize)]
276 struct InternalVersion {
277 major: u64,
278 minor: u64,
279 patch: u64,
280 }
281
282 InternalVersion::deserialize(deserializer).map(|v| Version::new(v.major, v.minor, v.patch))
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use chrono::TimeZone;
289 use serde_json::json;
290
291 use super::*;
292
293 const TELCOINS_TOKEN_LIST_URI: &str =
294 "https://raw.githubusercontent.com/telcoin/token-lists/e6a4cd7/telcoins.json";
295
296 #[cfg(feature = "from-uri")]
297 #[tokio::test]
298 async fn from_uri() {
299 let _token_list = TokenList::from_uri(TELCOINS_TOKEN_LIST_URI).await.unwrap();
300 }
301
302 #[cfg(feature = "from-uri-blocking")]
303 #[test]
304 fn from_uri_blocking() {
305 let _token_list = TokenList::from_uri_blocking(TELCOINS_TOKEN_LIST_URI).unwrap();
306 }
307
308 #[cfg(feature = "from-uri-compat")]
309 #[test]
310 fn from_uri_compat() {
311 use futures::future::{FutureExt, TryFutureExt};
312 use tokio01::runtime::Runtime;
313
314 let mut rt = Runtime::new().unwrap();
315
316 rt.block_on(
317 TokenList::from_uri_compat(TELCOINS_TOKEN_LIST_URI)
318 .boxed()
319 .compat(),
320 )
321 .unwrap();
322 }
323
324 #[test]
325 fn can_serialize_deserialize_required_fields() {
326 let data_json = json!({
327 "name": "TELcoins",
328 "timestamp": "2021-07-05T20:25:22Z",
329 "version": { "major": 0, "minor": 1, "patch": 0 },
330 "tokens": [
331 {
332 "name": "Telcoin",
333 "symbol": "TEL",
334 "address": "0x467bccd9d29f223bce8043b84e8c8b282827790f",
335 "chainId": 1,
336 "decimals": 2
337 }
338 ]
339 });
340
341 let data_rs = TokenList {
342 name: "TELcoins".to_owned(),
343 timestamp: FixedOffset::west(0).ymd(2021, 7, 5).and_hms(20, 25, 22),
344 version: Version::new(0, 1, 0),
345 logo_uri: None,
346 keywords: vec![],
347 tags: HashMap::new(),
348 tokens: vec![Token {
349 name: "Telcoin".to_owned(),
350 symbol: "TEL".to_owned(),
351 address: "0x467bccd9d29f223bce8043b84e8c8b282827790f".to_owned(),
352 chain_id: 1,
353 decimals: 2,
354 logo_uri: None,
355 tags: vec![],
356 extensions: HashMap::new(),
357 }],
358 };
359
360 assert_eq!(serde_json::to_value(&data_rs).unwrap(), data_json);
361
362 let token_list: TokenList = serde_json::from_value(data_json).unwrap();
363
364 assert_eq!(token_list, data_rs);
365 }
366
367 #[test]
368 fn can_serialize_deserialize_all_fields() {
369 let data_json = json!({
370 "name": "TELcoins",
371 "timestamp": "2021-07-05T20:25:22Z",
372 "version": { "major": 0, "minor": 1, "patch": 0 },
373 "logoURI": "https://raw.githubusercontent.com/telcoin/token-lists/master/assets/logo-telcoin-250x250.png",
374 "keywords": ["defi", "telcoin"],
375 "tags": {
376 "telcoin": {
377 "description": "Part of the Telcoin ecosystem.",
378 "name": "telcoin"
379 }
380 },
381 "tokens": [
382 {
383 "name": "Telcoin",
384 "symbol": "TEL",
385 "address": "0x467bccd9d29f223bce8043b84e8c8b282827790f",
386 "chainId": 1,
387 "decimals": 2,
388 "logoURI": "https://raw.githubusercontent.com/telcoin/token-lists/master/assets/logo-telcoin-250x250.png",
389 "tags": ["telcoin"],
390 "extensions": {
391 "is_mapped_to_polygon": true,
392 "polygon_address": "0xdf7837de1f2fa4631d716cf2502f8b230f1dcc32",
393 "polygon_chain_id": 137
394 }
395 }
396 ]
397 });
398
399 let logo_uri: Url = "https://raw.githubusercontent.com/telcoin/token-lists/master/assets/logo-telcoin-250x250.png".parse().unwrap();
400 let data_rs = TokenList {
401 name: "TELcoins".to_owned(),
402 timestamp: FixedOffset::west(0).ymd(2021, 7, 5).and_hms(20, 25, 22),
403 version: Version::new(0, 1, 0),
404 logo_uri: Some(logo_uri.clone()),
405 keywords: vec!["defi".to_owned(), "telcoin".to_owned()],
406 tags: vec![(
407 "telcoin".to_owned(),
408 Tag {
409 name: "telcoin".to_owned(),
410 description: "Part of the Telcoin ecosystem.".to_owned(),
411 },
412 )]
413 .into_iter()
414 .collect(),
415 tokens: vec![Token {
416 name: "Telcoin".to_owned(),
417 symbol: "TEL".to_owned(),
418 address: "0x467bccd9d29f223bce8043b84e8c8b282827790f".to_owned(),
419 chain_id: 1,
420 decimals: 2,
421 logo_uri: Some(logo_uri),
422 tags: vec!["telcoin".to_owned()],
423 extensions: vec![
424 (
425 "is_mapped_to_polygon".to_owned(),
426 Some(ExtensionValue::Boolean(true)),
427 ),
428 (
429 "polygon_address".to_owned(),
430 Some(ExtensionValue::String(
431 "0xdf7837de1f2fa4631d716cf2502f8b230f1dcc32".to_owned(),
432 )),
433 ),
434 (
435 "polygon_chain_id".to_owned(),
436 Some(ExtensionValue::Number(Number::Integer(137))),
437 ),
438 ]
439 .into_iter()
440 .collect(),
441 }],
442 };
443
444 assert_eq!(serde_json::to_value(&data_rs).unwrap(), data_json,);
445
446 let token_list: TokenList = serde_json::from_value(data_json).unwrap();
447
448 assert_eq!(token_list, data_rs);
449 }
450}