erc_nft_metadata/
lib.rs

1#![deny(missing_docs)]
2
3//! Type definitions for ERC-based non-fungible token (NFT) metadata.
4//!
5//! While [EIP-721](https://eips.ethereum.org/EIPS/eip-721#specification) defines a "ERC-721 Metadata JSON Schema", in practice
6//! it is rarely used. Instead, this crate implements the more popular [OpenSea metadata standard](https://docs.opensea.io/docs/metadata-standards).
7//!
8//! This crate does not attempt to perform validation more than what is strictly necessary. Since every secondary
9//! market will use the fields in the metadata in a different way, it is up to the crate consumer to make sure the fields are appropriately populated.
10
11use rgb::RGB8;
12#[cfg(feature = "serde")]
13use serde::{Deserialize, Serialize};
14use url::Url;
15
16/// Metadata for a token.
17///
18/// While even an empty object is "valid" metadata, this crate takes a more opinionated approach.
19/// The following fields are strictly required: [`name`](Metadata::name), [`description`](Metadata::description), [`image`](Metadata::image).
20#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub struct Metadata {
23    /// URL to image of the item.
24    pub image: Url,
25    /// External URL to another site.
26    pub external_url: Option<Url>,
27    /// Human-readable description of the item.
28    pub description: String,
29    /// Name of the item.
30    pub name: String,
31    /// Attributes for the item.
32    #[cfg_attr(feature = "serde", serde(default))]
33    pub attributes: Vec<AttributeEntry>,
34    /// Background color of the item.
35    /// When serialized, it takes the form of a 6-character hexadecimal string without a `#`.
36    #[cfg_attr(feature = "serde", serde(with = "rgb8_fromhex_opt", default))]
37    pub background_color: Option<RGB8>,
38    /// URL to multi-media attachment for the item.
39    pub animation_url: Option<Url>,
40    /// URL to a YouTube video.
41    pub youtube_url: Option<Url>,
42}
43
44/// A key-value pair of attributes for an item.
45#[cfg_attr(
46    feature = "serde",
47    derive(Serialize, Deserialize),
48    serde(rename_all = "snake_case"),
49    serde(untagged)
50)]
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub enum AttributeEntry {
53    /// Textual attribute.
54    String {
55        /// Name of the trait.
56        trait_type: String,
57        /// Value of the attribute.
58        value: String,
59    },
60    /// Numerical attribute.
61    Number {
62        /// Name of the trait.
63        trait_type: String,
64        /// Value of the attribute.
65        value: u64,
66        /// How the attribute should be displayed.
67        display_type: Option<DisplayType>,
68    },
69}
70
71/// How a numerical attribute should be displayed.
72#[cfg_attr(
73    feature = "serde",
74    derive(Serialize, Deserialize),
75    serde(rename_all = "snake_case")
76)]
77#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
78pub enum DisplayType {
79    /// As a number.
80    Number,
81    /// As a boost percentage.
82    BoostPercentage,
83    /// As a boost number.
84    BoostNumber,
85    /// As a date.
86    Date,
87}
88
89#[cfg(feature = "serde")]
90mod rgb8_fromhex_opt {
91    use rgb::{ComponentSlice, RGB8};
92    use serde::{de::Error, Deserializer, Serialize, Serializer};
93
94    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<RGB8>, D::Error>
95    where
96        D: Deserializer<'de>,
97    {
98        match hex::deserialize(deserializer)
99            .map::<Option<Vec<_>>, _>(Some)
100            .unwrap_or_default()
101        {
102            Some(s) => {
103                if s.len() != 3 {
104                    Err(D::Error::custom("expected color hex string"))
105                } else {
106                    Ok(Some(RGB8 {
107                        r: s[0],
108                        g: s[1],
109                        b: s[2],
110                    }))
111                }
112            }
113            None => Ok(None),
114        }
115    }
116
117    pub fn serialize<S>(value: &Option<RGB8>, serializer: S) -> Result<S::Ok, S::Error>
118    where
119        S: Serializer,
120    {
121        match value {
122            Some(value) => hex::serialize(value.as_slice().to_vec(), serializer),
123            None => None::<()>.serialize(serializer),
124        }
125    }
126
127    #[cfg(test)]
128    mod tests {
129        use rgb::RGB8;
130        use serde::{Deserialize, Serialize};
131
132        #[derive(Serialize, Deserialize, Debug)]
133        struct Target {
134            #[serde(with = "crate::rgb8_fromhex_opt")]
135            color: Option<RGB8>,
136        }
137
138        #[test]
139        fn from_json() {
140            let s = r#"{ "color": "f2f2f2" }"#;
141            let target: Target = serde_json::from_str(s).unwrap();
142            assert_eq!(
143                target.color,
144                Some(RGB8 {
145                    r: 242,
146                    g: 242,
147                    b: 242
148                })
149            );
150        }
151
152        #[test]
153        fn to_json() {
154            let target = Target {
155                color: Some(RGB8 {
156                    r: 242,
157                    g: 242,
158                    b: 242,
159                }),
160            };
161            let s = serde_json::to_string(&target).unwrap();
162            assert_eq!(s, r#"{"color":"f2f2f2"}"#)
163        }
164
165        #[test]
166        fn from_notcolor_json() {
167            let s = r#"{ "color": "f2f2f2f2" }"#;
168            let target = serde_json::from_str::<Target>(s);
169            assert!(target.is_err());
170        }
171
172        #[test]
173        fn from_null_json() {
174            let s = r#"{ "color": null }"#;
175            let target = serde_json::from_str::<Target>(s).unwrap();
176            assert!(target.color.is_none());
177        }
178    }
179}
180
181#[cfg(feature = "serde")]
182#[cfg(test)]
183mod tests {
184    use crate::Metadata;
185
186    const PLANETPASS_ITEM: &str = r#"
187    {
188        "image": "https://assets.wanderers.ai/file/planetpass/vid/0/0.mp4",
189        "description": "Visit this planet and get a free Rocketeer NFT from Alucard.eth!",
190        "name": "Rocketeer X",
191        "attributes": [
192          {
193            "trait_type": "Core",
194            "value": "Vortex"
195          },
196          {
197            "trait_type": "Satellite",
198            "value": "Protoplanets"
199          },
200          {
201            "trait_type": "Feature",
202            "value": "Icy"
203          },
204          {
205            "trait_type": "Ship",
206            "value": "Docking"
207          },
208          {
209            "trait_type": "Space",
210            "value": "Green Sun"
211          },
212          {
213            "trait_type": "Terrain",
214            "value": "Layers"
215          },
216          {
217            "trait_type": "Faction",
218            "value": "Coalition for Uncorrupted Biology"
219          },
220          {
221            "trait_type": "Atmosphere",
222            "value": "Alpen Glow"
223          }
224        ]
225      }
226    "#;
227
228    #[test]
229    pub fn planetpass() {
230        let metadata = serde_json::from_str::<Metadata>(PLANETPASS_ITEM);
231        assert!(metadata.is_ok());
232    }
233}