modio/types/
mod.rs

1//! Model types defining the mod.io API.
2
3use std::fmt;
4
5use serde::de::{Deserialize, Deserializer};
6use serde_derive::{Deserialize, Serialize};
7use url::Url;
8
9#[macro_use]
10mod macros;
11mod utils;
12
13pub mod auth;
14pub mod files;
15pub mod games;
16pub mod id;
17pub mod mods;
18
19use utils::{DeserializeField, MissingField};
20
21use self::id::{EventId, GameId, ModId, UserId};
22
23/// See the [Message Object](https://docs.mod.io/restapiref/#message-object) docs for more information.
24#[derive(Debug, Deserialize)]
25#[non_exhaustive]
26pub struct Message {
27    pub code: u16,
28    pub message: String,
29}
30
31/// Result type for editing games, mods and files.
32#[derive(Debug, Deserialize)]
33#[serde(untagged, expecting = "edited object or 'no new data' message")]
34#[non_exhaustive]
35pub enum Editing<T> {
36    Entity(T),
37    /// The request was successful however no new data was submitted.
38    #[serde(deserialize_with = "deserialize_message")]
39    NoChanges,
40}
41
42/// Result type for deleting game tag options, mod media, mod tags and mod dependencies.
43#[derive(Debug, Deserialize)]
44#[serde(untagged, expecting = "no content or 'no new data' message")]
45#[non_exhaustive]
46pub enum Deletion {
47    Success,
48    /// The request was successful however no new data was submitted.
49    #[serde(deserialize_with = "deserialize_message")]
50    NoChanges,
51}
52
53fn deserialize_message<'de, D>(deserializer: D) -> Result<(), D::Error>
54where
55    D: serde::Deserializer<'de>,
56{
57    Message::deserialize(deserializer).map(|_| ())
58}
59
60/// See the [Multiple Item Response](https://docs.mod.io/restapiref/#response-formats) docs for more
61/// information.
62#[derive(Debug, PartialEq, Deserialize)]
63#[non_exhaustive]
64pub struct List<T> {
65    pub data: Vec<T>,
66    #[serde(rename = "result_count")]
67    pub count: u32,
68    #[serde(rename = "result_total")]
69    pub total: u32,
70    #[serde(rename = "result_limit")]
71    pub limit: u32,
72    #[serde(rename = "result_offset")]
73    pub offset: u32,
74}
75
76/// See the [Error Object](https://docs.mod.io/restapiref/#error-object) docs for more information.
77#[derive(Debug, Deserialize)]
78#[non_exhaustive]
79pub struct ErrorResponse {
80    pub error: Error,
81}
82
83/// See the [Error Object](https://docs.mod.io/restapiref/#error-object) docs for more information.
84#[derive(Debug, PartialEq, Deserialize)]
85#[non_exhaustive]
86pub struct Error {
87    pub code: u16,
88    pub error_ref: u16,
89    pub message: String,
90    #[serde(default, deserialize_with = "deserialize_errors")]
91    pub errors: Vec<(String, String)>,
92}
93
94fn deserialize_errors<'de, D: Deserializer<'de>>(
95    deserializer: D,
96) -> Result<Vec<(String, String)>, D::Error> {
97    use serde::de::{MapAccess, Visitor};
98
99    struct MapVisitor;
100    impl<'de> Visitor<'de> for MapVisitor {
101        type Value = Vec<(String, String)>;
102
103        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104            formatter.write_str("errors map")
105        }
106
107        fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
108            let mut errors = map.size_hint().map_or_else(Vec::new, Vec::with_capacity);
109            while let Some(entry) = map.next_entry()? {
110                errors.push(entry);
111            }
112            Ok(errors)
113        }
114    }
115
116    deserializer.deserialize_map(MapVisitor)
117}
118
119/// See the [User Object](https://docs.mod.io/restapiref/#user-object) docs for more information.
120#[derive(Deserialize)]
121#[non_exhaustive]
122pub struct User {
123    pub id: UserId,
124    pub name_id: String,
125    pub username: String,
126    pub date_online: Timestamp,
127    #[serde(default, deserialize_with = "deserialize_empty_object")]
128    pub avatar: Option<Avatar>,
129    #[serde(with = "utils::url")]
130    pub profile_url: Url,
131}
132
133impl fmt::Debug for User {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.debug_struct("User")
136            .field("id", &self.id)
137            .field("name_id", &self.name_id)
138            .field("username", &self.username)
139            .field("date_online", &self.date_online)
140            .field("avatar", &self.avatar)
141            .field("profile_url", &self.profile_url.as_str())
142            .finish()
143    }
144}
145
146/// See the [Avatar Object](https://docs.mod.io/restapiref/#avatar-object) docs for more information.
147#[derive(Deserialize)]
148#[non_exhaustive]
149pub struct Avatar {
150    pub filename: String,
151    #[serde(with = "utils::url")]
152    pub original: Url,
153    #[serde(with = "utils::url")]
154    pub thumb_50x50: Url,
155    #[serde(with = "utils::url")]
156    pub thumb_100x100: Url,
157}
158
159impl fmt::Debug for Avatar {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        f.debug_struct("Avatar")
162            .field("filename", &self.filename)
163            .field("original", &self.original.as_str())
164            .field("thumb_50x50", &self.thumb_50x50.as_str())
165            .field("thumb_100x100", &self.thumb_100x100.as_str())
166            .finish()
167    }
168}
169
170/// See the [Logo Object](https://docs.mod.io/restapiref/#logo-object) docs for more information.
171#[derive(Deserialize)]
172#[non_exhaustive]
173pub struct Logo {
174    pub filename: String,
175    #[serde(with = "utils::url")]
176    pub original: Url,
177    #[serde(with = "utils::url")]
178    pub thumb_320x180: Url,
179    #[serde(with = "utils::url")]
180    pub thumb_640x360: Url,
181    #[serde(with = "utils::url")]
182    pub thumb_1280x720: Url,
183}
184
185impl fmt::Debug for Logo {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        f.debug_struct("Logo")
188            .field("filename", &self.filename)
189            .field("original", &self.original.as_str())
190            .field("thumb_320x180", &self.thumb_320x180.as_str())
191            .field("thumb_640x360", &self.thumb_640x360.as_str())
192            .field("thumb_1280x720", &self.thumb_1280x720.as_str())
193            .finish()
194    }
195}
196
197newtype_enum! {
198    /// See [Status & Visibility](https://docs.mod.io/restapiref/#status-amp-visibility) docs for more information.
199    pub struct Status: u8 {
200        const NOT_ACCEPTED = 0;
201        const ACCEPTED     = 1;
202        const DELETED      = 3;
203    }
204
205    /// See the [mod.io docs](https://docs.mod.io/restapiref/#targeting-a-platform) for more information.
206    #[derive(Deserialize, Serialize)]
207    pub struct TargetPlatform<16> {
208        const ANDROID       = b"android";
209        const IOS           = b"ios";
210        const LINUX         = b"linux";
211        const MAC           = b"mac";
212        const WINDOWS       = b"windows";
213        const PS4           = b"ps4";
214        const PS5           = b"ps5";
215        const SOURCE        = b"source";
216        const SWITCH        = b"switch";
217        const XBOX_ONE      = b"xboxone";
218        const XBOX_SERIES_X = b"xboxseriesx";
219        const OCULUS        = b"oculus";
220    }
221
222    /// See the [mod.io docs](https://docs.mod.io/restapiref/#targeting-a-portal) for more information.
223    pub struct TargetPortal<12> {
224        const STEAM     = b"steam";
225        const GOG       = b"gog";
226        const EGS       = b"egs";
227        const ITCHIO    = b"itchio";
228        const NINTENDO  = b"nintendo";
229        const PSN       = b"psn";
230        const XBOX_LIVE = b"xboxlive";
231        const APPLE     = b"apple";
232        const GOOGLE    = b"google";
233        const FACEBOOK  = b"facebook";
234        const DISCORD   = b"discord";
235    }
236}
237
238impl TargetPlatform {
239    pub fn display_name(&self) -> &str {
240        match *self {
241            Self::ANDROID => "Android",
242            Self::IOS => "iOS",
243            Self::LINUX => "Linux",
244            Self::MAC => "Mac",
245            Self::WINDOWS => "Windows",
246            Self::PS4 => "PlayStation 4",
247            Self::PS5 => "PlayStation 5",
248            Self::SOURCE => "Source",
249            Self::SWITCH => "Nintendo Switch",
250            Self::XBOX_ONE => "Xbox One",
251            Self::XBOX_SERIES_X => "Xbox Series X/S",
252            Self::OCULUS => "Oculus",
253            _ => self.0.as_str(),
254        }
255    }
256}
257
258impl fmt::Display for TargetPlatform {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        f.write_str(self.display_name())
261    }
262}
263
264/// See the [User Event Object](https://docs.mod.io/restapiref/#user-event-object) docs for more information.
265#[derive(Debug, Deserialize)]
266#[non_exhaustive]
267pub struct Event {
268    pub id: EventId,
269    pub game_id: GameId,
270    pub mod_id: ModId,
271    pub user_id: UserId,
272    pub date_added: Timestamp,
273    pub event_type: EventType,
274}
275
276newtype_enum! {
277    /// Type of user event that was triggered.
278    #[derive(Deserialize)]
279    #[serde(transparent)]
280    pub struct EventType<24> {
281        /// User has joined a team.
282        const USER_TEAM_JOIN   = b"USER_TEAM_JOIN";
283        /// User has left a team.
284        const USER_TEAM_LEAVE  = b"USER_TEAM_LEAVE";
285        /// User has subscribed to a mod.
286        const USER_SUBSCRIBE   = b"USER_SUBSCRIBE";
287        /// User has unsubscribed to a mod.
288        const USER_UNSUBSCRIBE = b"USER_UNSUBSCRIBE";
289    }
290}
291
292impl fmt::Display for EventType {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        f.write_str(self.as_str())
295    }
296}
297
298/// Deserialize empty objects for optional properties as `None`.
299///
300/// The mod.io api returns `"field": {}` for some optional properties instead of returning
301/// `"field": null` or omitting the field.
302///
303/// This function supports the following JSON examples as `None`.
304/// ```json
305/// {"id": 1, "field": {}}
306/// {"id": 1, "field": null}
307///
308/// // And missing fields with `#[serde(default)]`
309/// {"id": 1}
310/// ```
311fn deserialize_empty_object<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
312where
313    D: Deserializer<'de>,
314    T: Deserialize<'de>,
315{
316    #[derive(Deserialize)]
317    #[serde(
318        untagged,
319        deny_unknown_fields,
320        expecting = "object, empty object or null"
321    )]
322    enum Helper<T> {
323        Data(T),
324        Empty {},
325        Null,
326    }
327    match Helper::deserialize(deserializer) {
328        Ok(Helper::Data(data)) => Ok(Some(data)),
329        Ok(_) => Ok(None),
330        Err(e) => Err(e),
331    }
332}
333
334/// Repesentation of a Unix timestamp.
335#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
336pub struct Timestamp(i64);
337
338impl Timestamp {
339    /// Get the Unix timestamp in seconds.
340    pub const fn as_secs(self) -> i64 {
341        self.0
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use serde_derive::Deserialize;
348    use serde_test::{assert_de_tokens, assert_tokens, Token};
349
350    use super::{deserialize_empty_object, Error, EventType, TargetPlatform};
351
352    #[test]
353    fn deserialize_error_no_errors_field() {
354        let value = Error {
355            code: 404,
356            error_ref: 11005,
357            message: "foo".to_owned(),
358            errors: vec![],
359        };
360
361        assert_de_tokens(
362            &value,
363            &[
364                Token::Struct {
365                    name: "Error",
366                    len: 3,
367                },
368                Token::Str("code"),
369                Token::U16(404),
370                Token::Str("error_ref"),
371                Token::U16(11005),
372                Token::Str("message"),
373                Token::Str("foo"),
374                Token::StructEnd,
375            ],
376        );
377    }
378
379    #[test]
380    fn deserialize_error_empty_errors() {
381        let value = Error {
382            code: 404,
383            error_ref: 11005,
384            message: "foo".to_owned(),
385            errors: vec![],
386        };
387
388        assert_de_tokens(
389            &value,
390            &[
391                Token::Struct {
392                    name: "Error",
393                    len: 3,
394                },
395                Token::Str("code"),
396                Token::U16(404),
397                Token::Str("error_ref"),
398                Token::U16(11005),
399                Token::Str("message"),
400                Token::Str("foo"),
401                Token::Str("errors"),
402                Token::Map { len: Some(0) },
403                Token::MapEnd,
404                Token::StructEnd,
405            ],
406        );
407    }
408
409    #[test]
410    fn deserialize_error_with_errors() {
411        let value = Error {
412            code: 404,
413            error_ref: 11005,
414            message: "foo".to_owned(),
415            errors: vec![("foo".to_owned(), "bar".to_owned())],
416        };
417
418        assert_de_tokens(
419            &value,
420            &[
421                Token::Struct {
422                    name: "Error",
423                    len: 3,
424                },
425                Token::Str("code"),
426                Token::U16(404),
427                Token::Str("error_ref"),
428                Token::U16(11005),
429                Token::Str("message"),
430                Token::Str("foo"),
431                Token::Str("errors"),
432                Token::Map { len: Some(1) },
433                Token::Str("foo"),
434                Token::Str("bar"),
435                Token::MapEnd,
436                Token::StructEnd,
437            ],
438        );
439    }
440
441    #[derive(Debug, PartialEq, Deserialize)]
442    struct Game {
443        id: u32,
444        #[serde(default, deserialize_with = "deserialize_empty_object")]
445        header: Option<Header>,
446    }
447
448    #[derive(Debug, PartialEq, Deserialize)]
449    struct Header {
450        filename: String,
451    }
452
453    #[test]
454    fn deserialize_empty_object_full() {
455        let value = Game {
456            id: 1,
457            header: Some(Header {
458                filename: "foobar".to_string(),
459            }),
460        };
461        assert_de_tokens(
462            &value,
463            &[
464                Token::Struct {
465                    name: "Game",
466                    len: 2,
467                },
468                Token::Str("id"),
469                Token::U8(1),
470                Token::Str("header"),
471                Token::Struct {
472                    name: "Header",
473                    len: 1,
474                },
475                Token::Str("filename"),
476                Token::Str("foobar"),
477                Token::StructEnd,
478                Token::StructEnd,
479            ],
480        );
481    }
482
483    #[test]
484    fn deserialize_empty_object_empty() {
485        let value = Game {
486            id: 1,
487            header: None,
488        };
489        assert_de_tokens(
490            &value,
491            &[
492                Token::Struct {
493                    name: "Game",
494                    len: 2,
495                },
496                Token::Str("id"),
497                Token::U8(1),
498                Token::Str("header"),
499                Token::Struct {
500                    name: "Header",
501                    len: 0,
502                },
503                Token::StructEnd,
504                Token::StructEnd,
505            ],
506        );
507    }
508
509    #[test]
510    fn deserialize_empty_object_null() {
511        let value = Game {
512            id: 1,
513            header: None,
514        };
515        assert_de_tokens(
516            &value,
517            &[
518                Token::Struct {
519                    name: "Game",
520                    len: 2,
521                },
522                Token::Str("id"),
523                Token::U8(1),
524                Token::Str("header"),
525                Token::None,
526                Token::StructEnd,
527            ],
528        );
529    }
530
531    #[test]
532    fn deserialize_empty_object_absent() {
533        let value = Game {
534            id: 1,
535            header: None,
536        };
537        assert_de_tokens(
538            &value,
539            &[
540                Token::Struct {
541                    name: "Game",
542                    len: 1,
543                },
544                Token::Str("id"),
545                Token::U8(1),
546                Token::StructEnd,
547            ],
548        );
549    }
550
551    #[test]
552    fn deserialize_empty_object_unknown_values() {
553        let value = Game {
554            id: 1,
555            header: Some(Header {
556                filename: "foobar".to_string(),
557            }),
558        };
559        assert_de_tokens(
560            &value,
561            &[
562                Token::Struct {
563                    name: "Game",
564                    len: 2,
565                },
566                Token::Str("id"),
567                Token::U8(1),
568                Token::Str("header"),
569                Token::Struct {
570                    name: "Header",
571                    len: 1,
572                },
573                Token::Str("filename"),
574                Token::Str("foobar"),
575                Token::Str("id"),
576                Token::U8(2),
577                Token::StructEnd,
578                Token::StructEnd,
579            ],
580        );
581    }
582
583    #[test]
584    fn deserialize_empty_object_missing_field() {
585        serde_test::assert_de_tokens_error::<Game>(
586            &[
587                Token::Struct {
588                    name: "Game",
589                    len: 2,
590                },
591                Token::Str("id"),
592                Token::U8(1),
593                Token::Str("header"),
594                Token::Struct {
595                    name: "Header",
596                    len: 1,
597                },
598                Token::Str("id"),
599                Token::U8(2),
600                Token::StructEnd,
601                Token::StructEnd,
602            ],
603            "object, empty object or null",
604        );
605    }
606
607    #[test]
608    fn user_event_type_serde() {
609        assert_de_tokens(&EventType::USER_TEAM_JOIN, &[Token::Str("USER_TEAM_JOIN")]);
610        assert_de_tokens(
611            &EventType::USER_TEAM_LEAVE,
612            &[Token::Str("USER_TEAM_LEAVE")],
613        );
614        assert_de_tokens(&EventType::USER_SUBSCRIBE, &[Token::Str("USER_SUBSCRIBE")]);
615        assert_de_tokens(
616            &EventType::USER_UNSUBSCRIBE,
617            &[Token::Str("USER_UNSUBSCRIBE")],
618        );
619        assert_de_tokens(&EventType::from_bytes(b"foo"), &[Token::Str("foo")]);
620    }
621
622    #[test]
623    fn target_platform_compare() {
624        assert_eq!(TargetPlatform::ANDROID, "ANDROID");
625        assert_eq!("android", TargetPlatform::ANDROID);
626    }
627
628    #[test]
629    fn target_platform_serde() {
630        assert_tokens(
631            &TargetPlatform::ANDROID,
632            &[
633                Token::NewtypeStruct {
634                    name: "TargetPlatform",
635                },
636                Token::Str("android"),
637            ],
638        );
639    }
640}
641
642// vim: fdm=marker