modio/types/
mods.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use serde::de::{Deserialize, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor};
5use serde_derive::Deserialize;
6use url::Url;
7
8use super::files::File;
9use super::id::{CommentId, EventId, FileId, GameId, MemberId, ModId, ResourceId, UserId};
10use super::{deserialize_empty_object, utils, DeserializeField, MissingField, TargetPlatform};
11use super::{Logo, Status, Timestamp, User};
12
13/// See the [Mod Object](https://docs.mod.io/restapiref/#mod-object) docs for more information.
14#[derive(Debug, Deserialize)]
15#[non_exhaustive]
16pub struct Mod {
17    pub id: ModId,
18    pub game_id: GameId,
19    pub status: Status,
20    pub visible: Visibility,
21    pub submitted_by: User,
22    pub date_added: Timestamp,
23    pub date_updated: Timestamp,
24    pub date_live: Timestamp,
25    pub maturity_option: MaturityOption,
26    pub community_options: CommunityOptions,
27    pub price: f32,
28    pub tax: u32,
29    pub logo: Logo,
30    #[serde(with = "utils::url::opt")]
31    pub homepage_url: Option<Url>,
32    pub name: String,
33    pub name_id: String,
34    pub summary: String,
35    pub description: Option<String>,
36    pub description_plaintext: Option<String>,
37    pub metadata_blob: Option<String>,
38    #[serde(with = "utils::url")]
39    pub profile_url: Url,
40    #[serde(default, deserialize_with = "deserialize_empty_object")]
41    pub modfile: Option<File>,
42    pub media: Media,
43    #[serde(rename = "metadata_kvp")]
44    pub metadata: MetadataMap,
45    pub tags: Vec<Tag>,
46    pub dependencies: bool,
47    pub stats: Statistics,
48    pub platforms: Vec<Platform>,
49}
50
51newtype_enum! {
52    /// See [Status & Visibility](https://docs.mod.io/restapiref/#status-amp-visibility) docs for more information.
53    pub struct Visibility: u8 {
54        const HIDDEN = 0;
55        const PUBLIC = 1;
56    }
57}
58
59bitflags! {
60    /// Community options a mod can enable.
61    pub struct CommunityOptions: u16 {
62        /// Comments enabled.
63        const COMMENTS = 1;
64        /// Previews enabled.
65        const PREVIEWS = 64;
66        /// Preview URLs enabled.
67        const PREVIEW_URLS = 128;
68        /// Allow mod dependencies
69        const ALLOW_DEPENDENCIES = 1024;
70    }
71
72    /// Maturity options a mod can be flagged.
73    ///
74    /// This is only relevant if the parent game allows mods to be labelled as mature.
75    pub struct MaturityOption: u8 {
76        const ALCOHOL   = 1;
77        const DRUGS     = 2;
78        const VIOLENCE  = 4;
79        const EXPLICIT  = 8;
80    }
81}
82
83/// See the [Mod Event Object](https://docs.mod.io/restapiref/#mod-event-object) docs for more information.
84#[derive(Debug, Deserialize)]
85#[non_exhaustive]
86pub struct Event {
87    pub id: EventId,
88    pub mod_id: ModId,
89    pub user_id: UserId,
90    pub date_added: Timestamp,
91    pub event_type: EventType,
92}
93
94newtype_enum! {
95    /// Type of mod event that was triggered.
96    #[derive(Deserialize)]
97    #[serde(transparent)]
98    pub struct EventType<24> {
99        /// Primary file changed, the mod should be updated.
100        const MODFILE_CHANGED     = b"MODFILE_CHANGED";
101        /// Mod is marked as accepted and public.
102        const MOD_AVAILABLE       = b"MOD_AVAILABLE";
103        /// Mod is marked as not accepted, deleted or hidden.
104        const MOD_UNAVAILABLE     = b"MOD_UNAVAILABLE";
105        /// Mod has been updated.
106        const MOD_EDITED          = b"MOD_EDITED";
107        /// Mod has been permanently deleted.
108        const MOD_DELETED         = b"MOD_DELETED";
109        /// User has joined or left the mod team.
110        const MOD_TEAM_CHANGED    = b"MOD_TEAM_CHANGED";
111        /// A comment has been published for a mod.
112        const MOD_COMMENT_ADDED   = b"MOD_COMMENT_ADDED";
113        /// A comment has been deleted from a mod.
114        const MOD_COMMENT_DELETED = b"MOD_COMMENT_DELETED";
115    }
116}
117
118impl fmt::Display for EventType {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str(self.as_str())
121    }
122}
123
124/// See the [Mod Dependency Object](https://docs.mod.io/restapiref/#mod-dependencies-object) docs for more
125/// information.
126#[derive(Debug, Deserialize)]
127#[non_exhaustive]
128pub struct Dependency {
129    pub mod_id: ModId,
130    pub date_added: Timestamp,
131}
132
133/// See the [Mod Media Object](https://docs.mod.io/restapiref/#mod-media-object) docs for more
134/// information.
135#[derive(Debug, Deserialize)]
136#[non_exhaustive]
137pub struct Media {
138    #[serde(default = "Vec::new")]
139    pub youtube: Vec<String>,
140    #[serde(default = "Vec::new")]
141    pub sketchfab: Vec<String>,
142    #[serde(default = "Vec::new")]
143    pub images: Vec<Image>,
144}
145
146/// See the [Image Object](https://docs.mod.io/restapiref/#image-object) docs for more information.
147#[derive(Deserialize)]
148#[non_exhaustive]
149pub struct Image {
150    pub filename: String,
151    #[serde(with = "utils::url")]
152    pub original: Url,
153    #[serde(with = "utils::url")]
154    pub thumb_320x180: Url,
155}
156
157impl fmt::Debug for Image {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        f.debug_struct("Image")
160            .field("filename", &self.filename)
161            .field("original", &self.original.as_str())
162            .field("thumb_320x180", &self.thumb_320x180.as_str())
163            .finish()
164    }
165}
166
167/// See the [Statistics Object](https://docs.mod.io/restapiref/#mod-stats-object) docs for more
168/// information.
169#[derive(Debug)]
170#[non_exhaustive]
171pub struct Statistics {
172    pub mod_id: ModId,
173    pub downloads_today: u32,
174    pub downloads_total: u32,
175    pub subscribers_total: u32,
176    pub popularity: Popularity,
177    pub ratings: Ratings,
178    pub date_expires: Timestamp,
179}
180
181impl<'de> Deserialize<'de> for Statistics {
182    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
183        #[derive(Deserialize)]
184        #[serde(field_identifier, rename_all = "snake_case")]
185        enum Field {
186            ModId,
187            DownloadsToday,
188            DownloadsTotal,
189            SubscribersTotal,
190            PopularityRankPosition,
191            PopularityRankTotalMods,
192            RatingsTotal,
193            RatingsPositive,
194            RatingsNegative,
195            RatingsPercentagePositive,
196            RatingsWeightedAggregate,
197            RatingsDisplayText,
198            DateExpires,
199            #[allow(dead_code)]
200            Other(String),
201        }
202
203        struct StatisticsVisitor;
204
205        impl<'de> Visitor<'de> for StatisticsVisitor {
206            type Value = Statistics;
207
208            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
209                formatter.write_str("struct Statistics")
210            }
211
212            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
213                let mut mod_id = None;
214                let mut downloads_today = None;
215                let mut downloads_total = None;
216                let mut subscribers_total = None;
217                let mut rank_position = None;
218                let mut rank_total = None;
219                let mut ratings_total = None;
220                let mut ratings_positive = None;
221                let mut ratings_negative = None;
222                let mut ratings_percentage_positive = None;
223                let mut ratings_weighted_aggregate = None;
224                let mut ratings_display_text = None;
225                let mut date_expires = None;
226
227                while let Some(key) = map.next_key()? {
228                    match key {
229                        Field::ModId => {
230                            mod_id.deserialize_value("mod_id", &mut map)?;
231                        }
232                        Field::DownloadsToday => {
233                            downloads_today.deserialize_value("downloads_today", &mut map)?;
234                        }
235                        Field::DownloadsTotal => {
236                            downloads_total.deserialize_value("downloads_total", &mut map)?;
237                        }
238                        Field::SubscribersTotal => {
239                            subscribers_total.deserialize_value("subscribers_total", &mut map)?;
240                        }
241                        Field::PopularityRankPosition => {
242                            rank_position
243                                .deserialize_value("popularity_rank_position", &mut map)?;
244                        }
245                        Field::PopularityRankTotalMods => {
246                            rank_total.deserialize_value("popularity_rank_total_mods", &mut map)?;
247                        }
248                        Field::RatingsTotal => {
249                            ratings_total.deserialize_value("ratings_total", &mut map)?;
250                        }
251                        Field::RatingsPositive => {
252                            ratings_positive.deserialize_value("ratings_positive", &mut map)?;
253                        }
254                        Field::RatingsNegative => {
255                            ratings_negative.deserialize_value("ratings_negative", &mut map)?;
256                        }
257                        Field::RatingsPercentagePositive => {
258                            ratings_percentage_positive
259                                .deserialize_value("ratings_percentage_positive", &mut map)?;
260                        }
261                        Field::RatingsWeightedAggregate => {
262                            ratings_weighted_aggregate
263                                .deserialize_value("ratings_weighted_aggregate", &mut map)?;
264                        }
265                        Field::RatingsDisplayText => {
266                            ratings_display_text
267                                .deserialize_value("ratings_display_text", &mut map)?;
268                        }
269                        Field::DateExpires => {
270                            date_expires.deserialize_value("date_expires", &mut map)?;
271                        }
272                        Field::Other(_) => {
273                            map.next_value::<IgnoredAny>()?;
274                        }
275                    }
276                }
277
278                let mod_id = mod_id.missing_field("mod_id")?;
279                let downloads_today = downloads_today.missing_field("downloads_today")?;
280                let downloads_total = downloads_total.missing_field("downloads_total")?;
281                let subscribers_total = subscribers_total.missing_field("subscribers_total")?;
282                let rank_position = rank_position.missing_field("popularity_rank_position")?;
283                let rank_total = rank_total.missing_field("popularity_rank_total_mods")?;
284                let ratings_total = ratings_total.missing_field("ratings_total")?;
285                let ratings_positive = ratings_positive.missing_field("ratings_positive")?;
286                let ratings_negative = ratings_negative.missing_field("ratings_negative")?;
287                let ratings_percentage_positive =
288                    ratings_percentage_positive.missing_field("ratings_percentage_positive")?;
289                let ratings_weighted_aggregate =
290                    ratings_weighted_aggregate.missing_field("ratings_weighted_aggregate")?;
291                let ratings_display_text =
292                    ratings_display_text.missing_field("ratings_display_text")?;
293                let date_expires = date_expires.missing_field("date_expires")?;
294
295                Ok(Statistics {
296                    mod_id,
297                    downloads_today,
298                    downloads_total,
299                    subscribers_total,
300                    popularity: Popularity {
301                        rank_position,
302                        rank_total,
303                    },
304                    ratings: Ratings {
305                        total: ratings_total,
306                        positive: ratings_positive,
307                        negative: ratings_negative,
308                        percentage_positive: ratings_percentage_positive,
309                        weighted_aggregate: ratings_weighted_aggregate,
310                        display_text: ratings_display_text,
311                    },
312                    date_expires,
313                })
314            }
315        }
316
317        deserializer.deserialize_map(StatisticsVisitor)
318    }
319}
320
321/// Part of [`Statistics`]
322#[derive(Debug)]
323#[non_exhaustive]
324pub struct Popularity {
325    pub rank_position: u32,
326    pub rank_total: u32,
327}
328
329/// Part of [`Statistics`]
330#[derive(Debug)]
331#[non_exhaustive]
332pub struct Ratings {
333    pub total: u32,
334    pub positive: u32,
335    pub negative: u32,
336    pub percentage_positive: u32,
337    pub weighted_aggregate: f32,
338    pub display_text: String,
339}
340
341/// See the [Rating Object](https://docs.mod.io/restapiref/#rating-object) docs for more information.
342#[derive(Debug)]
343#[non_exhaustive]
344pub enum Rating {
345    Positive {
346        game_id: GameId,
347        mod_id: ModId,
348        date_added: Timestamp,
349    },
350    Negative {
351        game_id: GameId,
352        mod_id: ModId,
353        date_added: Timestamp,
354    },
355}
356
357impl<'de> Deserialize<'de> for Rating {
358    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
359    where
360        D: Deserializer<'de>,
361    {
362        use serde::de::Error;
363
364        #[derive(Deserialize)]
365        struct R {
366            game_id: GameId,
367            mod_id: ModId,
368            rating: i8,
369            date_added: Timestamp,
370        }
371
372        match R::deserialize(deserializer) {
373            Ok(R {
374                game_id,
375                mod_id,
376                rating: 1,
377                date_added,
378            }) => Ok(Self::Positive {
379                game_id,
380                mod_id,
381                date_added,
382            }),
383            Ok(R {
384                game_id,
385                mod_id,
386                rating: -1,
387                date_added,
388            }) => Ok(Self::Negative {
389                game_id,
390                mod_id,
391                date_added,
392            }),
393            Ok(R { rating, .. }) => {
394                Err(D::Error::custom(format!("invalid rating value: {rating}")))
395            }
396            Err(e) => Err(e),
397        }
398    }
399}
400
401/// See the [Mod Platforms Object](https://docs.mod.io/restapiref/#mod-platforms-object) docs for more information.
402#[derive(Debug, Deserialize)]
403#[non_exhaustive]
404pub struct Platform {
405    #[serde(rename = "platform")]
406    pub target: TargetPlatform,
407    /// The unique id of the modfile that is currently live on the platform specified in the
408    /// `target` field.
409    #[serde(rename = "modfile_live")]
410    pub modfile_id: FileId,
411}
412
413/// See the [Mod Tag Object](https://docs.mod.io/restapiref/#mod-tag-object) docs for more information.
414#[derive(Debug, Deserialize)]
415#[non_exhaustive]
416pub struct Tag {
417    pub name: String,
418    pub date_added: Timestamp,
419}
420
421impl fmt::Display for Tag {
422    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
423        self.name.fmt(fmt)
424    }
425}
426
427/// See the [Metadata KVP Object](https://docs.mod.io/restapiref/#metadata-kvp-object) docs for more
428/// information.
429#[derive(Debug, Clone, Default, PartialEq)]
430pub struct MetadataMap(HashMap<String, Vec<String>>);
431
432impl MetadataMap {
433    pub fn new() -> Self {
434        Self::default()
435    }
436
437    pub fn with_capacity(capacity: usize) -> Self {
438        Self(HashMap::with_capacity(capacity))
439    }
440}
441
442impl std::ops::Deref for MetadataMap {
443    type Target = HashMap<String, Vec<String>>;
444
445    fn deref(&self) -> &Self::Target {
446        &self.0
447    }
448}
449
450impl std::ops::DerefMut for MetadataMap {
451    fn deref_mut(&mut self) -> &mut Self::Target {
452        &mut self.0
453    }
454}
455
456/// Deserialize a sequence of key-value objects to a `MetadataMap`.
457///
458/// ## Input
459///
460/// ```json
461/// [
462///     {"metakey": "pistol-dmg", "metavalue": "800"},
463///     {"metakey": "smg-dmg", "metavalue": "1200"},
464///     {"metakey": "pistol-dmg", "metavalue": "850"}
465/// ]
466/// ```
467///
468/// ## Result
469///
470/// ```text
471/// MetadataMap({
472///     "pistol-dmg": [
473///         "800",
474///         "850",
475///     ],
476///     "smg-dmg": [
477///         "1200",
478///     ],
479/// })
480/// ```
481impl<'de> Deserialize<'de> for MetadataMap {
482    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
483        #[derive(Debug)]
484        enum ListField {
485            Data,
486            Other,
487        }
488
489        impl<'de> Deserialize<'de> for ListField {
490            fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
491                struct FieldVisitor;
492
493                impl Visitor<'_> for FieldVisitor {
494                    type Value = ListField;
495
496                    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
497                        formatter.write_str("`data` field")
498                    }
499
500                    fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
501                        match value {
502                            "data" => Ok(ListField::Data),
503                            _ => Ok(ListField::Other),
504                        }
505                    }
506                }
507
508                deserializer.deserialize_identifier(FieldVisitor)
509            }
510        }
511
512        struct MetadataVisitor;
513
514        impl<'de> Visitor<'de> for MetadataVisitor {
515            type Value = MetadataMap;
516
517            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
518                formatter.write_str("metadata kvp")
519            }
520
521            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
522                let mut data = None;
523
524                while let Some(key) = map.next_key()? {
525                    match key {
526                        ListField::Data => {
527                            data.deserialize_value("data", &mut map)?;
528                        }
529                        ListField::Other => {
530                            map.next_value::<IgnoredAny>()?;
531                        }
532                    }
533                }
534
535                let map = data.missing_field("data")?;
536                Ok(map)
537            }
538
539            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
540                #[derive(Deserialize)]
541                struct Element {
542                    metakey: String,
543                    metavalue: String,
544                }
545
546                let mut map = seq
547                    .size_hint()
548                    .map_or_else(MetadataMap::new, MetadataMap::with_capacity);
549
550                while let Some(Element { metakey, metavalue }) = seq.next_element()? {
551                    map.entry(metakey).or_default().push(metavalue);
552                }
553
554                Ok(map)
555            }
556        }
557
558        deserializer.deserialize_any(MetadataVisitor)
559    }
560}
561
562/// See the [Comment Object](https://docs.mod.io/restapiref/#comment-object) docs for more information.
563#[derive(Debug, Deserialize)]
564#[non_exhaustive]
565pub struct Comment {
566    pub id: CommentId,
567    pub resource_id: ResourceId,
568    pub user: User,
569    pub date_added: Timestamp,
570    pub reply_id: CommentId,
571    pub thread_position: String,
572    pub karma: i32,
573    pub content: String,
574}
575
576/// See the [Team Member Object](https://docs.mod.io/restapiref/#team-member-object) docs for more
577/// information.
578#[derive(Debug, Deserialize)]
579#[non_exhaustive]
580pub struct TeamMember {
581    pub id: MemberId,
582    pub user: User,
583    pub level: TeamLevel,
584    pub date_added: Timestamp,
585    pub position: String,
586}
587
588newtype_enum! {
589    /// Defines the role of a team member.
590    pub struct TeamLevel: u8 {
591        const MODERATOR = 1;
592        const CREATOR   = 4;
593        const ADMIN     = 8;
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use serde_test::{assert_de_tokens, Token};
600
601    use super::{EventType, MetadataMap};
602    use crate::types::List;
603
604    #[test]
605    fn metadata_from_result_list_serde() {
606        #[derive(Debug, PartialEq, serde_derive::Deserialize)]
607        struct Entry {
608            metakey: String,
609            metavalue: String,
610        }
611
612        let list = List {
613            data: vec![
614                Entry {
615                    metakey: "foo".to_owned(),
616                    metavalue: "bar".to_owned(),
617                },
618                Entry {
619                    metakey: "foo".to_owned(),
620                    metavalue: "baz".to_owned(),
621                },
622            ],
623            count: 2,
624            offset: 0,
625            limit: 100,
626            total: 2,
627        };
628
629        let mut map = MetadataMap::new();
630        map.entry("foo".to_owned())
631            .or_insert(vec!["bar".to_owned(), "baz".to_owned()]);
632
633        let tokens = &[
634            Token::Struct {
635                name: "List",
636                len: 5,
637            },
638            Token::Str("data"),
639            Token::Seq { len: Some(2) },
640            Token::Map { len: Some(2) },
641            Token::Str("metakey"),
642            Token::Str("foo"),
643            Token::Str("metavalue"),
644            Token::Str("bar"),
645            Token::MapEnd,
646            Token::Map { len: Some(2) },
647            Token::Str("metakey"),
648            Token::Str("foo"),
649            Token::Str("metavalue"),
650            Token::Str("baz"),
651            Token::MapEnd,
652            Token::SeqEnd,
653            Token::Str("result_count"),
654            Token::U64(2),
655            Token::Str("result_offset"),
656            Token::U64(0),
657            Token::Str("result_limit"),
658            Token::U64(100),
659            Token::Str("result_total"),
660            Token::U64(2),
661            Token::StructEnd,
662        ];
663
664        assert_de_tokens(&list, tokens);
665        assert_de_tokens(&map, tokens);
666    }
667
668    #[test]
669    fn metadata_from_mod_serde() {
670        #[derive(Debug, PartialEq, serde_derive::Deserialize)]
671        struct Mod {
672            id: u32,
673            #[serde(rename = "metadata_kvp")]
674            metadata: MetadataMap,
675        }
676
677        let mut map = MetadataMap::new();
678        map.entry("foo".to_owned())
679            .or_insert(vec!["bar".to_owned(), "baz".to_owned()]);
680
681        let mod_ = Mod {
682            id: 2,
683            metadata: map,
684        };
685        assert_de_tokens(
686            &mod_,
687            &[
688                Token::Struct {
689                    name: "Mod",
690                    len: 1,
691                },
692                Token::Str("id"),
693                Token::U32(2),
694                Token::Str("metadata_kvp"),
695                Token::Seq { len: Some(2) },
696                Token::Map { len: Some(2) },
697                Token::Str("metakey"),
698                Token::Str("foo"),
699                Token::Str("metavalue"),
700                Token::Str("bar"),
701                Token::MapEnd,
702                Token::Map { len: Some(2) },
703                Token::Str("metakey"),
704                Token::Str("foo"),
705                Token::Str("metavalue"),
706                Token::Str("baz"),
707                Token::MapEnd,
708                Token::SeqEnd,
709                Token::StructEnd,
710            ],
711        );
712    }
713
714    #[test]
715    fn mod_event_type_serde() {
716        assert_de_tokens(
717            &EventType::MODFILE_CHANGED,
718            &[Token::Str("MODFILE_CHANGED")],
719        );
720        assert_de_tokens(&EventType::MOD_AVAILABLE, &[Token::Str("MOD_AVAILABLE")]);
721        assert_de_tokens(
722            &EventType::MOD_UNAVAILABLE,
723            &[Token::Str("MOD_UNAVAILABLE")],
724        );
725        assert_de_tokens(&EventType::MOD_EDITED, &[Token::Str("MOD_EDITED")]);
726        assert_de_tokens(&EventType::MOD_DELETED, &[Token::Str("MOD_DELETED")]);
727        assert_de_tokens(
728            &EventType::MOD_TEAM_CHANGED,
729            &[Token::Str("MOD_TEAM_CHANGED")],
730        );
731        assert_de_tokens(
732            &EventType::MOD_COMMENT_ADDED,
733            &[Token::Str("MOD_COMMENT_ADDED")],
734        );
735        assert_de_tokens(
736            &EventType::MOD_COMMENT_DELETED,
737            &[Token::Str("MOD_COMMENT_DELETED")],
738        );
739        assert_de_tokens(&EventType::from_bytes(b"foo"), &[Token::Str("foo")]);
740    }
741}