spaceapi_dezentrale/
status.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::value::Value;
5
6use crate::sensors::Sensors;
7
8type Extensions = BTreeMap<String, Value>;
9
10#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
11pub struct Location {
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub address: Option<String>,
14    /**
15    used for better location handling
16    **/
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub osm_link: Option<String>,
19    pub lat: f64,
20    pub lon: f64,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub timezone: Option<String>,
23}
24
25#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
26pub struct Spacefed {
27    pub spacenet: bool,
28    pub spacesaml: bool,
29    pub spacephone: Option<bool>,
30}
31
32impl Spacefed {
33    fn verify(&self, version: StatusBuilderVersion) -> Result<(), String> {
34        if version == StatusBuilderVersion::V14 {
35            if self.spacephone.is_some() {
36                return Err("spacefed.spacephone key was removed".into());
37            }
38        } else if self.spacephone.is_none() {
39            return Err("spacefed.spacephone must be present".into());
40        }
41        Ok(())
42    }
43}
44
45#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
46pub struct Icon {
47    pub open: String,
48    pub closed: String,
49}
50
51#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
52pub struct State {
53    pub open: Option<bool>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub lastchange: Option<u64>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub trigger_person: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub message: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub icon: Option<Icon>,
62}
63
64impl State {
65    fn verify(&self, _version: StatusBuilderVersion) -> Result<(), String> {
66        Ok(())
67    }
68}
69
70#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
71pub struct Event {
72    pub name: String,
73    #[serde(rename = "type")]
74    pub type_: String,
75    pub timestamp: u64,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub extra: Option<String>,
78}
79
80#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
81pub struct Keymaster {
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub name: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub irc_nick: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub phone: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub email: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub twitter: Option<String>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub xmpp: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub mastodon: Option<String>,
96}
97
98#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
99pub struct GoogleContact {
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub plus: Option<String>,
102}
103
104#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
105pub struct Contact {
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub phone: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub sip: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub keymasters: Option<Vec<Keymaster>>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub irc: Option<String>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub twitter: Option<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub facebook: Option<String>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub google: Option<GoogleContact>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub identica: Option<String>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub foursquare: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub email: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub ml: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub jabber: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub xmpp: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub issue_mail: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub mumble: Option<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub matrix: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub mastodon: Option<String>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub gopher: Option<String>,
142}
143
144#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
145pub struct Feed {
146    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
147    pub type_: Option<String>,
148    pub url: String,
149}
150
151#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
152pub struct Feeds {
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub blog: Option<Feed>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub wiki: Option<Feed>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub calendar: Option<Feed>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub flickr: Option<Feed>,
161}
162
163#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
164pub struct Cache {
165    pub schedule: String,
166}
167
168#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
169pub struct RadioShow {
170    pub name: String,
171    pub url: String,
172    #[serde(rename = "type")]
173    pub type_: String,
174    pub start: String,
175    pub end: String,
176}
177
178#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
179#[serde(rename_all = "snake_case")]
180pub enum IssueReportChannel {
181    Email,
182    IssueMail,
183    Twitter,
184    Ml,
185}
186
187#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
188pub struct Stream {
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub m4: Option<String>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub mjpeg: Option<String>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub ustream: Option<String>,
195}
196
197#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
198pub struct Link {
199    pub name: String,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub description: Option<String>,
202    pub url: String,
203}
204
205#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
206#[serde(rename_all = "snake_case")]
207pub enum BillingInterval {
208    Yearly,
209    Monthly,
210    Weekly,
211    Daily,
212    Hourly,
213    Other,
214}
215
216impl Default for BillingInterval {
217    fn default() -> Self {
218        BillingInterval::Monthly
219    }
220}
221
222#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
223pub struct MembershipPlan {
224    pub name: String,
225    pub value: f64,
226    pub currency: String,
227    pub billing_interval: BillingInterval,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub description: Option<String>,
230}
231
232#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
233pub enum ApiVersion {
234    #[serde(rename = "14")]
235    V14,
236}
237
238/// The main SpaceAPI status object.
239#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
240pub struct Status {
241    // Hackerspace properties
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub api: Option<String>,
244
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub api_compatibility: Option<Vec<ApiVersion>>,
247
248    pub space: String,
249    pub logo: String,
250    pub url: String,
251    pub location: Location,
252    pub contact: Contact,
253
254    // Hackerspace features / projects
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub spacefed: Option<Spacefed>,
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub projects: Option<Vec<String>>,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub cam: Option<Vec<String>>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub stream: Option<Stream>,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub feeds: Option<Feeds>,
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub events: Option<Vec<Event>>,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub radio_show: Option<Vec<RadioShow>>,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub links: Option<Vec<Link>>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub membership_plans: Option<Vec<MembershipPlan>>,
273
274    // SpaceAPI internal usage
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub cache: Option<Cache>,
277    #[serde(skip_serializing_if = "Vec::is_empty", default)]
278    pub issue_report_channels: Vec<IssueReportChannel>,
279
280    // Mutable data
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub state: Option<State>,
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub sensors: Option<Sensors>,
285
286    // Custom extensions are allowed and will be prefixed with `ext_`.
287    #[serde(flatten)]
288    pub extensions: Extensions,
289}
290
291impl Status {
292    /// Create a new Status object with only the absolutely required fields.
293    #[deprecated(
294        since = "0.5.0",
295        note = "Please use the `StatusBuilder` or a struct expression instead"
296    )]
297    pub fn new<S: Into<String>>(
298        space: S,
299        logo: S,
300        url: S,
301        location: Location,
302        contact: Contact,
303        issue_report_channels: Vec<IssueReportChannel>,
304    ) -> Status {
305        Status {
306            api: Some("0.13".into()),
307            space: space.into(),
308            logo: logo.into(),
309            url: url.into(),
310            location,
311            contact,
312            issue_report_channels,
313            ..Default::default()
314        }
315    }
316}
317
318#[derive(Debug, Copy, Clone, Eq, PartialEq)]
319enum StatusBuilderVersion {
320    V0_13,
321    V14,
322    Mixed,
323}
324
325impl Default for StatusBuilderVersion {
326    fn default() -> StatusBuilderVersion {
327        StatusBuilderVersion::V0_13
328    }
329}
330
331/// Builder for the `Status` object.
332#[derive(Default, Debug, Clone)]
333pub struct StatusBuilder {
334    version: StatusBuilderVersion,
335    space: String,
336    logo: Option<String>,
337    url: Option<String>,
338    location: Option<Location>,
339    contact: Option<Contact>,
340    spacefed: Option<Spacefed>,
341    projects: Option<Vec<String>>,
342    cam: Option<Vec<String>>,
343    feeds: Option<Feeds>,
344    events: Option<Vec<Event>>,
345    radio_show: Option<Vec<RadioShow>>,
346    links: Option<Vec<Link>>,
347    membership_plans: Option<Vec<MembershipPlan>>,
348    issue_report_channels: Vec<IssueReportChannel>,
349    extensions: Extensions,
350    state: Option<State>,
351}
352
353impl StatusBuilder {
354    pub fn new<S: Into<String>>(space_name: S) -> StatusBuilder {
355        StatusBuilder {
356            space: space_name.into(),
357            ..Default::default()
358        }
359    }
360
361    pub fn v0_13<S: Into<String>>(space_name: S) -> StatusBuilder {
362        StatusBuilder {
363            space: space_name.into(),
364            version: StatusBuilderVersion::V0_13,
365            ..Default::default()
366        }
367    }
368
369    pub fn v14<S: Into<String>>(space_name: S) -> StatusBuilder {
370        StatusBuilder {
371            space: space_name.into(),
372            version: StatusBuilderVersion::V14,
373            ..Default::default()
374        }
375    }
376
377    pub fn mixed<S: Into<String>>(space_name: S) -> StatusBuilder {
378        StatusBuilder {
379            space: space_name.into(),
380            version: StatusBuilderVersion::Mixed,
381            ..Default::default()
382        }
383    }
384
385    pub fn state(mut self, state: State) -> Self {
386        self.state = Some(state);
387        self
388    }
389
390    pub fn logo<S: Into<String>>(mut self, logo: S) -> Self {
391        self.logo = Some(logo.into());
392        self
393    }
394
395    pub fn url<S: Into<String>>(mut self, url: S) -> Self {
396        self.url = Some(url.into());
397        self
398    }
399
400    pub fn location(mut self, location: Location) -> Self {
401        self.location = Some(location);
402        self
403    }
404
405    pub fn contact(mut self, contact: Contact) -> Self {
406        self.contact = Some(contact);
407        self
408    }
409
410    pub fn spacefed(mut self, spacefed: Spacefed) -> Self {
411        self.spacefed = Some(spacefed);
412        self
413    }
414
415    pub fn add_event(mut self, event: Event) -> Self {
416        self.events.get_or_insert(vec![]).push(event);
417        self
418    }
419
420    pub fn add_cam<S: Into<String>>(mut self, cam: S) -> Self {
421        self.cam.get_or_insert(vec![]).push(cam.into());
422        self
423    }
424
425    pub fn feeds(mut self, feeds: Feeds) -> Self {
426        self.feeds = Some(feeds);
427        self
428    }
429
430    pub fn add_radio_show(mut self, radio_show: RadioShow) -> Self {
431        self.radio_show.get_or_insert(vec![]).push(radio_show);
432        self
433    }
434
435    pub fn add_link(mut self, link: Link) -> Self {
436        self.links.get_or_insert(vec![]).push(link);
437        self
438    }
439
440    pub fn add_membership_plan(mut self, membership_plan: MembershipPlan) -> Self {
441        self.membership_plans.get_or_insert(vec![]).push(membership_plan);
442        self
443    }
444
445    pub fn add_project<S: Into<String>>(mut self, project: S) -> Self {
446        self.projects.get_or_insert(vec![]).push(project.into());
447        self
448    }
449
450    pub fn add_issue_report_channel(mut self, report_channel: IssueReportChannel) -> Self {
451        self.issue_report_channels.push(report_channel);
452        self
453    }
454
455    /// Add an extension to the `Status` object.
456    ///
457    /// The prefix `ext_` will automatically be prepended to the name, if not already present.
458    pub fn add_extension<V: Into<Value>>(mut self, name: &str, value: V) -> Self {
459        if name.starts_with("ext_") {
460            self.extensions.insert(name.to_owned(), value.into());
461        } else {
462            self.extensions.insert(format!("ext_{}", name), value.into());
463        }
464        self
465    }
466
467    pub fn build(self) -> Result<Status, String> {
468        let api = match self.version {
469            StatusBuilderVersion::V0_13 | StatusBuilderVersion::Mixed => Some("0.13".to_owned()),
470            _ => None,
471        };
472        let api_compatibility = match self.version {
473            StatusBuilderVersion::V14 | StatusBuilderVersion::Mixed => Some(vec![ApiVersion::V14]),
474            _ => None,
475        };
476
477        let contact = self.contact.ok_or("contact missing")?;
478        if let Some(spacefed) = &self.spacefed {
479            spacefed.verify(self.version)?;
480        }
481        if let Some(state) = &self.state {
482            state.verify(self.version)?;
483        }
484
485        if self.version == StatusBuilderVersion::V14 {
486            if contact.jabber.is_some() {
487                return Err("jabber key under contact was renamed to xmpp".into());
488            }
489            if contact.google.is_some() {
490                return Err("google key under contact was removed".into());
491            }
492            if self.radio_show.is_some() {
493                return Err("radio_show key was removed".into());
494            }
495
496            if !self.issue_report_channels.is_empty() {
497                return Err("issue_report_channels key was removed".into());
498            }
499        } else {
500            if self.issue_report_channels.is_empty() {
501                return Err("issue_report_channels must not be empty".into());
502            }
503            if self.state.is_none() {
504                return Err("state must be present in v0.13".into());
505            }
506            if let Some(ref location) = self.location {
507                if location.timezone.is_some() {
508                    return Err("location.timezone is only present in v0.14 and above".into());
509                }
510            }
511            if self.links.is_some() {
512                return Err("links is only present in v0.14 and above".into());
513            }
514            if self.membership_plans.is_some() {
515                return Err("membership_plans is only present in v0.14 and above".into());
516            }
517        }
518
519        Ok(Status {
520            api,
521            api_compatibility,
522            space: self.space,
523            logo: self.logo.ok_or("logo missing")?,
524            url: self.url.ok_or("url missing")?,
525            location: self.location.ok_or("location missing")?,
526            contact,
527            spacefed: self.spacefed,
528            projects: self.projects,
529            cam: self.cam,
530            feeds: self.feeds,
531            events: self.events,
532            radio_show: self.radio_show,
533            links: self.links,
534            membership_plans: self.membership_plans,
535            issue_report_channels: self.issue_report_channels,
536            state: self.state,
537            extensions: self.extensions,
538            ..Default::default()
539        })
540    }
541}
542
543#[cfg(test)]
544mod test {
545    use super::*;
546    use serde_json::{from_str, to_string};
547
548    #[test]
549    fn serialize_deserialize_cache() {
550        let a = Cache {
551            schedule: "bla".into(),
552        };
553        let b: Cache = from_str(&to_string(&a).unwrap()).unwrap();
554        assert_eq!(a.schedule, b.schedule);
555    }
556
557    #[test]
558    fn serialize_deserialize_simple_contact() {
559        let a: Contact = Contact {
560            keymasters: Some(vec![Keymaster {
561                name: Some("Joe".into()),
562                email: Some("joe@example.com".into()),
563                ..Keymaster::default()
564            }]),
565            irc: Some("bla".into()),
566            google: Some(GoogleContact {
567                plus: Some("http://gplus/profile".into()),
568            }),
569            email: Some("bli@bla".into()),
570            ..Default::default()
571        };
572        let b: Contact = from_str(&to_string(&a).unwrap()).unwrap();
573
574        assert_eq!(a, b);
575    }
576
577    #[test]
578    fn test_builder_v13_fail_on_location_timezone() {
579        let status = StatusBuilder::v0_13("foo")
580            .logo("bar")
581            .url("foobar")
582            .location(Location {
583                timezone: Some("Europe/London".into()),
584                ..Default::default()
585            })
586            .contact(Contact::default())
587            .add_issue_report_channel(IssueReportChannel::Email)
588            .state(State {
589                open: Some(false),
590                ..State::default()
591            })
592            .build();
593        assert!(status.is_err());
594        assert_eq!(
595            status.err().unwrap(),
596            "location.timezone is only present in v0.14 and above"
597        );
598    }
599
600    #[test]
601    fn test_builder_v13_fail_on_links() {
602        let status = StatusBuilder::v0_13("foo")
603            .logo("bar")
604            .url("foobar")
605            .contact(Contact::default())
606            .add_issue_report_channel(IssueReportChannel::Email)
607            .state(State {
608                open: Some(false),
609                ..State::default()
610            })
611            .add_link(Link::default())
612            .build();
613        assert!(status.is_err());
614        assert_eq!(status.err().unwrap(), "links is only present in v0.14 and above");
615    }
616
617    #[test]
618    fn test_builder_v13_fail_on_membership_plans() {
619        let status = StatusBuilder::v0_13("foo")
620            .logo("bar")
621            .url("foobar")
622            .contact(Contact::default())
623            .add_issue_report_channel(IssueReportChannel::Email)
624            .state(State {
625                open: Some(false),
626                ..State::default()
627            })
628            .add_membership_plan(MembershipPlan::default())
629            .build();
630        assert!(status.is_err());
631        assert_eq!(
632            status.err().unwrap(),
633            "membership_plans is only present in v0.14 and above"
634        );
635    }
636
637    #[test]
638    fn test_builder_v14() {
639        let status = StatusBuilder::v14("foo")
640            .logo("bar")
641            .url("foobar")
642            .location(Location::default())
643            .contact(Contact::default())
644            .add_link(Link::default())
645            .add_membership_plan(MembershipPlan::default())
646            .build()
647            .unwrap();
648        assert_eq!(
649            status,
650            Status {
651                api: None,
652                api_compatibility: Some(vec![ApiVersion::V14]),
653                space: "foo".into(),
654                logo: "bar".into(),
655                url: "foobar".into(),
656                issue_report_channels: vec![],
657                links: Some(vec![Link::default()]),
658                membership_plans: Some(vec![MembershipPlan::default()]),
659                ..Status::default()
660            }
661        );
662    }
663
664    #[test]
665    fn test_builder_v14_fail_on_jabber() {
666        let status = StatusBuilder::v14("foo")
667            .logo("bar")
668            .url("foobar")
669            .location(Location::default())
670            .contact(Contact {
671                jabber: Some("jabber".into()),
672                ..Contact::default()
673            })
674            .build();
675        assert!(status.is_err());
676    }
677
678    #[test]
679    fn test_builder_v14_fail_on_google() {
680        let status = StatusBuilder::v14("foo")
681            .logo("bar")
682            .url("foobar")
683            .location(Location::default())
684            .contact(Contact {
685                google: Some(GoogleContact::default()),
686                ..Contact::default()
687            })
688            .build();
689        assert!(status.is_err());
690    }
691
692    #[test]
693    fn test_builder_v14_fail_on_radio_show() {
694        let status = StatusBuilder::v14("foo")
695            .logo("bar")
696            .url("foobar")
697            .location(Location::default())
698            .contact(Contact::default())
699            .add_radio_show(RadioShow::default())
700            .build();
701        assert!(status.is_err());
702    }
703
704    #[test]
705    fn test_builder_v14_fail_on_spacephone() {
706        let status = StatusBuilder::v14("foo")
707            .logo("bar")
708            .url("foobar")
709            .location(Location::default())
710            .contact(Contact::default())
711            .spacefed(Spacefed {
712                spacephone: Some(true),
713                ..Spacefed::default()
714            })
715            .build();
716        assert!(status.is_err());
717    }
718
719    #[test]
720    fn test_builder_mixed() {
721        let status = StatusBuilder::mixed("foo")
722            .logo("bar")
723            .url("foobar")
724            .state(State::default())
725            .location(Location::default())
726            .contact(Contact::default())
727            .add_issue_report_channel(IssueReportChannel::Email)
728            .build()
729            .unwrap();
730        assert_eq!(
731            status,
732            Status {
733                api: Some("0.13".into()),
734                api_compatibility: Some(vec![ApiVersion::V14]),
735                space: "foo".into(),
736                logo: "bar".into(),
737                url: "foobar".into(),
738                state: Some(State {
739                    open: None,
740                    ..State::default()
741                }),
742                issue_report_channels: vec![IssueReportChannel::Email],
743                ..Status::default()
744            }
745        );
746    }
747
748    #[test]
749    fn test_builder() {
750        let status = StatusBuilder::new("foo")
751            .logo("bar")
752            .url("foobar")
753            .state(State {
754                open: Some(false),
755                ..State::default()
756            })
757            .location(Location::default())
758            .contact(Contact::default())
759            .spacefed(Spacefed {
760                spacephone: Some(false),
761                ..Spacefed::default()
762            })
763            .feeds(Feeds::default())
764            .add_project("spaceapi-rs")
765            .add_cam("cam1")
766            .add_cam("cam2".to_string())
767            .add_event(Event::default())
768            .add_issue_report_channel(IssueReportChannel::Email)
769            .build()
770            .unwrap();
771        assert_eq!(status.api, Some("0.13".into()));
772        assert_eq!(status.space, "foo");
773        assert_eq!(status.logo, "bar");
774        assert_eq!(status.url, "foobar");
775        assert_eq!(status.location, Location::default());
776        assert_eq!(status.contact, Contact::default());
777        assert_eq!(
778            status.spacefed,
779            Some(Spacefed {
780                spacephone: Some(false),
781                ..Spacefed::default()
782            })
783        );
784        assert_eq!(status.feeds, Some(Feeds::default()));
785        assert_eq!(status.projects, Some(vec!["spaceapi-rs".to_string()]));
786        assert_eq!(status.cam, Some(vec!["cam1".to_string(), "cam2".to_string()]));
787        assert_eq!(status.events, Some(vec![Event::default()]));
788        assert_eq!(status.issue_report_channels, vec![IssueReportChannel::Email]);
789    }
790
791    #[test]
792    fn serialize_skip_none() {
793        let f1 = Feed {
794            type_: Some("rss".to_string()),
795            url: "https://some/rss.xml".to_string(),
796        };
797        let f2 = Feed {
798            type_: None,
799            url: "https://some/rss.xml".to_string(),
800        };
801        assert_eq!(
802            to_string(&f1).unwrap(),
803            "{\"type\":\"rss\",\"url\":\"https://some/rss.xml\"}".to_string()
804        );
805        assert_eq!(
806            to_string(&f2).unwrap(),
807            "{\"url\":\"https://some/rss.xml\"}".to_string()
808        );
809    }
810
811    #[test]
812    fn serialize_deserialize_full() {
813        let status = StatusBuilder::new("foo")
814            .logo("bar")
815            .url("foobar")
816            .state(State {
817                open: Some(false),
818                ..State::default()
819            })
820            .location(Location::default())
821            .contact(Contact::default())
822            .add_extension("aaa", Value::Array(vec![Value::Null, Value::from(42)]))
823            .add_issue_report_channel(IssueReportChannel::Email)
824            .build()
825            .unwrap();
826        let serialized = to_string(&status).unwrap();
827        let deserialized = from_str::<Status>(&serialized).unwrap();
828        assert_eq!(status, deserialized);
829    }
830
831    #[test]
832    fn serialize_extension_fields_empty() {
833        let status = StatusBuilder::new("a")
834            .logo("b")
835            .url("c")
836            .state(State {
837                open: Some(false),
838                ..State::default()
839            })
840            .location(Location::default())
841            .contact(Contact::default())
842            .add_issue_report_channel(IssueReportChannel::Email)
843            .build();
844        assert!(status.is_ok());
845        assert_eq!(
846            &to_string(&status.unwrap()).unwrap(),
847            "{\"api\":\"0.13\",\"space\":\"a\",\"logo\":\"b\",\"url\":\"c\",\
848             \"location\":{\"lat\":0.0,\"lon\":0.0},\"contact\":{},\"issue_report_channels\":[\"email\"],\
849             \"state\":{\"open\":false}}"
850        );
851    }
852
853    #[test]
854    fn serialize_extension_fields() {
855        let status = StatusBuilder::new("a")
856            .logo("b")
857            .url("c")
858            .state(State {
859                open: Some(false),
860                ..State::default()
861            })
862            .location(Location::default())
863            .contact(Contact::default())
864            .add_issue_report_channel(IssueReportChannel::Email)
865            .add_extension("aaa", Value::String("xxx".into()))
866            .add_extension("bbb", Value::Array(vec![Value::Null, Value::from(42)]))
867            .build();
868        assert!(status.is_ok());
869        assert_eq!(
870            &to_string(&status.unwrap()).unwrap(),
871            "{\"api\":\"0.13\",\"space\":\"a\",\"logo\":\"b\",\"url\":\"c\",\
872             \"location\":{\"lat\":0.0,\"lon\":0.0},\"contact\":{},\"issue_report_channels\":[\"email\"],\
873             \"state\":{\"open\":false},\"ext_aaa\":\"xxx\",\"ext_bbb\":[null,42]}"
874        );
875    }
876
877    #[test]
878    fn serialize_deserialize_full_with_optional() {
879        let mut status = StatusBuilder::new("foo")
880            .logo("bar")
881            .url("foobar")
882            .state(State {
883                open: Some(false),
884                ..State::default()
885            })
886            .location(Location::default())
887            .contact(Contact::default())
888            .add_issue_report_channel(IssueReportChannel::Email)
889            .build()
890            .unwrap();
891        status.spacefed = Some(Spacefed::default());
892        status.feeds = Some(Feeds::default());
893        status.projects = Some(vec![]);
894        status.cam = Some(vec![]);
895
896        let serialized = to_string(&status).unwrap();
897        let deserialized = from_str::<Status>(&serialized).unwrap();
898        assert_eq!(status, deserialized);
899    }
900
901    /// Extension field names are automatically prepended with `ext_`.
902    /// If two extensions with the same name (before or after prepending) are
903    /// added, then the second call will overwrite the first one (standard Map
904    /// behavior).
905    #[test]
906    fn prepend_ext_to_extension_field_names() {
907        let status = StatusBuilder::new("a")
908            .logo("b")
909            .url("c")
910            .state(State {
911                open: Some(false),
912                ..State::default()
913            })
914            .location(Location::default())
915            .contact(Contact::default())
916            .add_issue_report_channel(IssueReportChannel::Email)
917            .add_extension("aaa", Value::String("xxx".into()))
918            .add_extension("ext_aaa", Value::String("yyy".into()))
919            .add_extension("bbb", Value::Null)
920            .add_extension("ext_ccc", Value::Null)
921            .build();
922        assert!(status.is_ok());
923        let serialized = to_string(&status.unwrap()).unwrap();
924        assert!(serialized.contains("\"ext_aaa\":\"yyy\""));
925        assert!(serialized.contains("\"ext_bbb\":null"));
926        assert!(serialized.contains("\"ext_ccc\":null"));
927    }
928
929    #[test]
930    fn deserialize_status() {
931        let data = r#"{
932            "api": "0.13",
933            "space": "a",
934            "logo": "b",
935            "url": "c",
936            "location": {
937                "lat": 0.0,
938                "lon": 0.0
939            },
940            "contact": {},
941            "issue_report_channels": [],
942            "state": {
943                "open": null,
944                "icon": {
945                    "open": "d",
946                    "closed": "e"
947                }
948            },
949            "ext_aaa": "xxx",
950            "ext_bbb": [null,42]
951        }"#;
952        let deserialized: Status = from_str(data).unwrap();
953        assert_eq!(deserialized.api, Some("0.13".into()));
954        let keys = deserialized.extensions.keys();
955        assert_eq!(keys.len(), 2)
956    }
957
958    mod serialize {
959        use super::*;
960
961        /// Macro to generate serialization test code
962        macro_rules! test_serialize {
963            ($test_name:ident, $value:expr, $expected:expr) => {
964                #[test]
965                fn $test_name() {
966                    let serialized = to_string(&$value).unwrap();
967                    assert_eq!(serialized, $expected);
968                }
969            };
970        }
971
972        test_serialize!(issue_report_channel_email, IssueReportChannel::Email, "\"email\"");
973        test_serialize!(
974            issue_report_channel_issue_mail,
975            IssueReportChannel::IssueMail,
976            "\"issue_mail\""
977        );
978        test_serialize!(
979            issue_report_channel_twitter,
980            IssueReportChannel::Twitter,
981            "\"twitter\""
982        );
983        test_serialize!(issue_report_channel_ml, IssueReportChannel::Ml, "\"ml\"");
984
985        test_serialize!(stream_default, Stream::default(), "{}");
986
987        test_serialize!(
988            stream_m4,
989            Stream {
990                m4: Some("http://example.org/stream.mpg".to_string()),
991                ..Stream::default()
992            },
993            r#"{"m4":"http://example.org/stream.mpg"}"#
994        );
995
996        test_serialize!(
997            stream_mjpeg,
998            Stream {
999                mjpeg: Some("http://example.org/stream.mjpeg".to_string()),
1000                ..Stream::default()
1001            },
1002            r#"{"mjpeg":"http://example.org/stream.mjpeg"}"#
1003        );
1004
1005        test_serialize!(
1006            stream_ustream,
1007            Stream {
1008                ustream: Some("http://www.ustream.tv/channel/hackspsps".to_string()),
1009                ..Stream::default()
1010            },
1011            r#"{"ustream":"http://www.ustream.tv/channel/hackspsps"}"#
1012        );
1013    }
1014
1015    mod deserialize {
1016        use super::*;
1017
1018        /// Macro to generate deserialization test code
1019        macro_rules! test_deserialize {
1020            ($test_name:ident, $value:expr, $type:ty, $expected:expr) => {
1021                #[test]
1022                fn $test_name() {
1023                    let deserialized = from_str::<$type>(&$value).unwrap();
1024                    assert_eq!(deserialized, $expected);
1025                }
1026            };
1027        }
1028
1029        test_deserialize!(
1030            issue_report_channel_email,
1031            "\"email\"",
1032            IssueReportChannel,
1033            IssueReportChannel::Email
1034        );
1035        test_deserialize!(
1036            issue_report_channel_issue_mail,
1037            "\"issue_mail\"",
1038            IssueReportChannel,
1039            IssueReportChannel::IssueMail
1040        );
1041        test_deserialize!(
1042            issue_report_channel_twitter,
1043            "\"twitter\"",
1044            IssueReportChannel,
1045            IssueReportChannel::Twitter
1046        );
1047        test_deserialize!(
1048            issue_report_channel_ml,
1049            "\"ml\"",
1050            IssueReportChannel,
1051            IssueReportChannel::Ml
1052        );
1053    }
1054}