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