1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
//! Redmine API
//!
//! [`Redmine Documentation`](https://www.redmine.org/projects/redmine/wiki/rest_api)
//!
//! - [x] authentication
//! - [x] pagination
//!   - [x] add Pageable instances to all types that need them
//!   - [x] figure out a way to write a general "fetch all pages" function (problem is the different key name in the wrapper)
//! - [x] impersonation
//! - [x] attachments
//! - [x] add all the wrappers I somehow missed
//!   - [x] check if admin and send_information truly are not part of the user hash in Create/UpdateUser or if the wiki docs are wrong (admin is, send_information is not)
//! - [x] test include parameters and add relevant data to the return types
//!
//! Potential breaking changes ahead
//! - [ ] use Enum for sort column
//! - [ ] typed ids
//! - [ ] change project_id_or_name to Enum
//! - [ ] extra filter expressions I overlooked/did not know about
//! - [ ] parameters that are more flexible than they appear
//! - [ ] async support?

pub mod attachments;
pub mod custom_fields;
pub mod enumerations;
pub mod files;
pub mod groups;
pub mod issue_categories;
pub mod issue_relations;
pub mod issue_statuses;
pub mod issues;
pub mod my_account;
pub mod news;
pub mod project_memberships;
pub mod projects;
pub mod queries;
pub mod roles;
pub mod search;
#[cfg(test)]
pub mod test_helpers;
pub mod time_entries;
pub mod trackers;
pub mod uploads;
pub mod users;
pub mod versions;
pub mod wiki_pages;

use std::str::from_utf8;

use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;

use http::Method;
use std::borrow::Cow;

use reqwest::{blocking::Client, Url};
use tracing::{debug, error, trace};

/// main API client object
#[derive(derivative::Derivative)]
#[derivative(Debug)]
pub struct Redmine {
    /// the reqwest client we use to perform our API requests
    client: Client,
    /// the redmine base url
    redmine_url: Url,
    /// a redmine API key, usually 40 hex digits where the letters (a-f) are lower case
    #[derivative(Debug = "ignore")]
    api_key: String,
    /// the user id we want to impersonate, only works if the API key we use has admin privileges
    impersonate_user_id: Option<u64>,
}

/// helper function to parse the redmine URL in the environment variable
fn parse_url<'de, D>(deserializer: D) -> Result<url::Url, D::Error>
where
    D: Deserializer<'de>,
{
    let buf = String::deserialize(deserializer)?;

    url::Url::parse(&buf).map_err(serde::de::Error::custom)
}

/// used to deserialize the required options from the environment
#[derive(Debug, serde::Deserialize)]
struct EnvOptions {
    /// a redmine API key, usually 40 hex digits where the letters (a-f) are lower case
    redmine_api_key: String,

    /// the redmine base url
    #[serde(deserialize_with = "parse_url")]
    redmine_url: url::Url,
}

/// Return value from paged requests, includes the actual value as well as
/// pagination data
#[derive(Debug, Clone)]
pub struct ResponsePage<T> {
    /// The actual value returned by Redmine deserialized into a user provided type
    pub values: Vec<T>,
    /// The total number of values that could be returned by requesting all pages
    pub total_count: u64,
    /// The offset from the start (zero-based)
    pub offset: u64,
    /// How many entries were returned
    pub limit: u64,
}

impl Redmine {
    /// create a [Redmine] object
    ///
    /// # Errors
    ///
    /// Currently this can not fail but it returns a Result for future proofing the API
    pub fn new(redmine_url: url::Url, api_key: &str) -> Result<Self, crate::Error> {
        let client = Client::new();

        Ok(Self {
            client,
            redmine_url,
            api_key: api_key.to_string(),
            impersonate_user_id: None,
        })
    }

    /// create a [Redmine] object from the environment variables
    ///
    /// REDMINE_API_KEY
    /// REDMINE_URL
    ///
    /// # Errors
    ///
    /// This will return an error if the environment variables are
    /// missing or the URL can not be parsed
    pub fn from_env() -> Result<Self, crate::Error> {
        let env_options = envy::from_env::<EnvOptions>()?;

        let redmine_url = env_options.redmine_url;
        let api_key = env_options.redmine_api_key;

        Self::new(redmine_url, &api_key)
    }

    /// Sets the user id of a user to impersonate in all future API calls
    ///
    /// this requires Redmine admin privileges
    pub fn impersonate_user(&mut self, id: u64) {
        self.impersonate_user_id = Some(id);
    }

    /// returns the issue URL for a given issue id
    ///
    /// this is mostly for convenience since we are already storing the
    /// redmine URL and it works entirely on the client
    #[must_use]
    #[allow(clippy::missing_panics_doc)]
    pub fn issue_url(&self, issue_id: u64) -> Url {
        let Redmine { redmine_url, .. } = self;
        // we can unwrap here because we know /issues/<number>
        // parses successfully as an url fragment
        redmine_url.join(&format!("/issues/{}", issue_id)).unwrap()
    }

    /// internal method for shared logic between the methods below which
    /// diff in how they parse the response body and how often they call this
    fn rest(
        &self,
        method: http::Method,
        endpoint: &str,
        parameters: QueryParams,
        mime_type_and_body: Option<(&str, Vec<u8>)>,
    ) -> Result<(reqwest::StatusCode, bytes::Bytes), crate::Error> {
        let Redmine {
            client,
            redmine_url,
            api_key,
            impersonate_user_id,
        } = self;
        let mut url = redmine_url.join(endpoint)?;
        parameters.add_to_url(&mut url);
        debug!(%url, %method, "Calling redmine");
        let req = client
            .request(method.clone(), url.clone())
            .header("x-redmine-api-key", api_key);
        let req = if let Some(user_id) = impersonate_user_id {
            req.header("X-Redmine-Switch-User", format!("{}", user_id))
        } else {
            req
        };
        let req = if let Some((mime, data)) = mime_type_and_body {
            if let Ok(request_body) = from_utf8(&data) {
                trace!("Request body (Content-Type: {}):\n{}", mime, request_body);
            } else {
                trace!(
                    "Request body (Content-Type: {}) could not be parsed as UTF-8:\n{:?}",
                    mime,
                    data
                );
            }
            req.body(data).header("Content-Type", mime)
        } else {
            req
        };
        let result = req.send();
        if let Err(ref e) = result {
            error!(%url, %method, "Redmine send error: {:?}", e);
        }
        let result = result?;
        let status = result.status();
        let response_body = result.bytes()?;
        match from_utf8(&response_body) {
            Ok(response_body) => {
                trace!("Response body:\n{}", &response_body);
            }
            Err(e) => {
                trace!(
                    "Response body that could not be parsed as utf8 because of {}:\n{:?}",
                    &e,
                    &response_body
                );
            }
        }
        if status.is_client_error() {
            error!(%url, %method, "Redmine status error (client error): {:?}", status);
        } else if status.is_server_error() {
            error!(%url, %method, "Redmine status error (server error): {:?}", status);
        }
        Ok((status, response_body))
    }

    /// use this with endpoints that have no response body, e.g. those just deleting
    /// a Redmine object
    ///
    /// # Errors
    ///
    /// This can return an error if the endpoint returns an error when creating the request
    /// body or when the web request fails
    pub fn ignore_response_body<E>(&self, endpoint: &E) -> Result<(), crate::Error>
    where
        E: Endpoint,
    {
        let method = endpoint.method();
        let url = endpoint.endpoint();
        let parameters = endpoint.parameters();
        let mime_type_and_body = endpoint.body()?;
        self.rest(method, &url, parameters, mime_type_and_body)?;
        Ok(())
    }

    /// use this with endpoints which return a JSON response but do not support pagination
    ///
    /// you can use it with those that support pagination but they will only return the first page
    ///
    /// # Errors
    ///
    /// This can return an error if the endpoint returns an error when creating the request body,
    /// when the web request fails or when the response can not be parsed as a JSON object
    /// into the result type
    pub fn json_response_body<E, R>(&self, endpoint: &E) -> Result<R, crate::Error>
    where
        E: Endpoint + ReturnsJsonResponse,
        R: DeserializeOwned + std::fmt::Debug,
    {
        let method = endpoint.method();
        let url = endpoint.endpoint();
        let parameters = endpoint.parameters();
        let mime_type_and_body = endpoint.body()?;
        let (status, response_body) = self.rest(method, &url, parameters, mime_type_and_body)?;
        if response_body.is_empty() {
            Err(crate::Error::EmptyResponseBody(status))
        } else {
            let result = serde_json::from_slice::<R>(&response_body);
            if let Ok(ref parsed_response_body) = result {
                trace!("Parsed response body:\n{:#?}", parsed_response_body);
            }
            Ok(result?)
        }
    }

    /// use this to get a single page of a paginated JSON response
    /// # Errors
    ///
    /// This can return an error if the endpoint returns an error when creating the
    /// request body, when the web request fails, when the response can not be parsed
    /// as a JSON object, when any of the pagination keys or the value key are missing
    /// in the JSON object or when the values can not be parsed as the result type.
    pub fn json_response_body_page<E, R>(
        &self,
        endpoint: &E,
        offset: u64,
        limit: u64,
    ) -> Result<ResponsePage<R>, crate::Error>
    where
        E: Endpoint + ReturnsJsonResponse + Pageable,
        R: DeserializeOwned + std::fmt::Debug,
    {
        let method = endpoint.method();
        let url = endpoint.endpoint();
        let mut parameters = endpoint.parameters();
        parameters.push("offset", offset);
        parameters.push("limit", limit);
        let mime_type_and_body = endpoint.body()?;
        let (status, response_body) = self.rest(method, &url, parameters, mime_type_and_body)?;
        if response_body.is_empty() {
            Err(crate::Error::EmptyResponseBody(status))
        } else {
            let json_value_response_body: serde_json::Value =
                serde_json::from_slice(&response_body)?;
            let json_object_response_body = json_value_response_body.as_object();
            if let Some(json_object_response_body) = json_object_response_body {
                let total_count = json_object_response_body
                    .get("total_count")
                    .ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
                    .as_u64()
                    .ok_or_else(|| {
                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
                    })?;
                let offset = json_object_response_body
                    .get("offset")
                    .ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
                    .as_u64()
                    .ok_or_else(|| {
                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
                    })?;
                let limit = json_object_response_body
                    .get("limit")
                    .ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
                    .as_u64()
                    .ok_or_else(|| {
                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
                    })?;
                let response_wrapper_key = endpoint.response_wrapper_key();
                let inner_response_body = json_object_response_body
                    .get(&response_wrapper_key)
                    .ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
                let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
                if let Ok(ref parsed_response_body) = result {
                    trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
                }
                Ok(ResponsePage {
                    values: result?,
                    total_count,
                    offset,
                    limit,
                })
            } else {
                Err(crate::Error::NonObjectResponseBody(status))
            }
        }
    }

    /// use this to get the results for all pages of a paginated JSON response
    ///
    /// # Errors
    ///
    /// This can return an error if the endpoint returns an error when creating the
    /// request body, when any of the web requests fails, when the response can not be
    /// parsed as a JSON object, when any of the pagination keys or the value key are missing
    /// in the JSON object or when the values can not be parsed as the result type.
    ///
    pub fn json_response_body_all_pages<E, R>(&self, endpoint: &E) -> Result<Vec<R>, crate::Error>
    where
        E: Endpoint + ReturnsJsonResponse + Pageable,
        R: DeserializeOwned + std::fmt::Debug,
    {
        let method = endpoint.method();
        let url = endpoint.endpoint();
        let mut offset = 0;
        let limit = 100;
        let mut total_results = vec![];
        loop {
            let mut page_parameters = endpoint.parameters();
            page_parameters.push("offset", offset);
            page_parameters.push("limit", limit);
            let mime_type_and_body = endpoint.body()?;
            let (status, response_body) =
                self.rest(method.clone(), &url, page_parameters, mime_type_and_body)?;
            if response_body.is_empty() {
                return Err(crate::Error::EmptyResponseBody(status));
            }
            let json_value_response_body: serde_json::Value =
                serde_json::from_slice(&response_body)?;
            let json_object_response_body = json_value_response_body.as_object();
            if let Some(json_object_response_body) = json_object_response_body {
                let total_count: u64 = json_object_response_body
                    .get("total_count")
                    .ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
                    .as_u64()
                    .ok_or_else(|| {
                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
                    })?;
                let response_offset: u64 = json_object_response_body
                    .get("offset")
                    .ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
                    .as_u64()
                    .ok_or_else(|| {
                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
                    })?;
                let response_limit: u64 = json_object_response_body
                    .get("limit")
                    .ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
                    .as_u64()
                    .ok_or_else(|| {
                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
                    })?;
                let response_wrapper_key = endpoint.response_wrapper_key();
                let inner_response_body = json_object_response_body
                    .get(&response_wrapper_key)
                    .ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
                let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
                if let Ok(ref parsed_response_body) = result {
                    trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
                }
                total_results.extend(result?);
                if total_count < (response_offset + response_limit) {
                    break;
                }
                offset += limit;
            } else {
                return Err(crate::Error::NonObjectResponseBody(status));
            }
        }
        Ok(total_results)
    }
}

/// A trait representing a parameter value.
pub trait ParamValue<'a> {
    #[allow(clippy::wrong_self_convention)]
    /// The parameter value as a string.
    fn as_value(&self) -> Cow<'a, str>;
}

impl ParamValue<'static> for bool {
    fn as_value(&self) -> Cow<'static, str> {
        if *self {
            "true".into()
        } else {
            "false".into()
        }
    }
}

impl<'a> ParamValue<'a> for &'a str {
    fn as_value(&self) -> Cow<'a, str> {
        (*self).into()
    }
}

impl ParamValue<'static> for String {
    fn as_value(&self) -> Cow<'static, str> {
        self.clone().into()
    }
}

impl<'a> ParamValue<'a> for &'a String {
    fn as_value(&self) -> Cow<'a, str> {
        (*self).into()
    }
}

/// serialize a [Vec<T>] where T implements [ToString] as a string
/// of comma-seperated values
impl<T> ParamValue<'static> for Vec<T>
where
    T: ToString,
{
    fn as_value(&self) -> Cow<'static, str> {
        self.iter()
            .map(|e| e.to_string())
            .collect::<Vec<_>>()
            .join(",")
            .into()
    }
}

/// serialize a [`&Vec<T>`](Vec<T>) where T implements [ToString] as a string
/// of comma-seperated values
impl<'a, T> ParamValue<'a> for &'a Vec<T>
where
    T: ToString,
{
    fn as_value(&self) -> Cow<'a, str> {
        self.iter()
            .map(|e| e.to_string())
            .collect::<Vec<_>>()
            .join(",")
            .into()
    }
}

impl<'a> ParamValue<'a> for Cow<'a, str> {
    fn as_value(&self) -> Cow<'a, str> {
        self.clone()
    }
}

impl<'a, 'b: 'a> ParamValue<'a> for &'b Cow<'a, str> {
    fn as_value(&self) -> Cow<'a, str> {
        (*self).clone()
    }
}

impl ParamValue<'static> for u64 {
    fn as_value(&self) -> Cow<'static, str> {
        format!("{}", self).into()
    }
}

impl ParamValue<'static> for f64 {
    fn as_value(&self) -> Cow<'static, str> {
        format!("{}", self).into()
    }
}

impl ParamValue<'static> for time::OffsetDateTime {
    fn as_value(&self) -> Cow<'static, str> {
        self.format(&time::format_description::well_known::Rfc3339)
            .unwrap()
            .into()
    }
}

impl ParamValue<'static> for time::Date {
    fn as_value(&self) -> Cow<'static, str> {
        let format = time::format_description::parse("[year]-[month]-[day]").unwrap();
        self.format(&format).unwrap().into()
    }
}

/// A structure for query parameters.
#[derive(Debug, Default, Clone)]
pub struct QueryParams<'a> {
    /// the actual parameters
    params: Vec<(Cow<'a, str>, Cow<'a, str>)>,
}

impl<'a> QueryParams<'a> {
    /// Push a single parameter.
    pub fn push<'b, K, V>(&mut self, key: K, value: V) -> &mut Self
    where
        K: Into<Cow<'a, str>>,
        V: ParamValue<'b>,
        'b: 'a,
    {
        self.params.push((key.into(), value.as_value()));
        self
    }

    /// Push a single parameter.
    pub fn push_opt<'b, K, V>(&mut self, key: K, value: Option<V>) -> &mut Self
    where
        K: Into<Cow<'a, str>>,
        V: ParamValue<'b>,
        'b: 'a,
    {
        if let Some(value) = value {
            self.params.push((key.into(), value.as_value()));
        }
        self
    }

    /// Push a set of parameters.
    pub fn extend<'b, I, K, V>(&mut self, iter: I) -> &mut Self
    where
        I: Iterator<Item = (K, V)>,
        K: Into<Cow<'a, str>>,
        V: ParamValue<'b>,
        'b: 'a,
    {
        self.params
            .extend(iter.map(|(key, value)| (key.into(), value.as_value())));
        self
    }

    /// Add the parameters to a URL.
    pub fn add_to_url(&self, url: &mut Url) {
        let mut pairs = url.query_pairs_mut();
        pairs.extend_pairs(self.params.iter());
    }
}

/// A trait for providing the necessary information for a single REST API endpoint.
pub trait Endpoint {
    /// The HTTP method to use for the endpoint.
    fn method(&self) -> Method;
    /// The path to the endpoint.
    fn endpoint(&self) -> Cow<'static, str>;

    /// Query parameters for the endpoint.
    fn parameters(&self) -> QueryParams {
        QueryParams::default()
    }

    /// The body for the endpoint.
    ///
    /// Returns the `Content-Encoding` header for the data as well as the data itself.
    ///
    /// # Errors
    ///
    /// The default implementation will never return an error
    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
        Ok(None)
    }
}

/// A trait to indicate that an endpoint is expected to return a JSON result
pub trait ReturnsJsonResponse {}

/// A trait to indicate that an endpoint is pageable.
pub trait Pageable {
    /// returns the name of the key in the response that contains the list of results
    fn response_wrapper_key(&self) -> String;
}

/// helper to parse created_on and updated_on in the correct format
/// (default time serde implementation seems to use a different format)
///
/// # Errors
///
/// This will return an error if the underlying string can not be deserialized or
/// can not be parsed as an RFC3339 date and time
pub fn deserialize_rfc3339<'de, D>(deserializer: D) -> Result<time::OffsetDateTime, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;

    time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
        .map_err(serde::de::Error::custom)
}

/// helper to serialize created_on and updated_on in the correct format
/// (default time serde implementation seems to use a different format)
///
/// # Errors
///
/// This will return an error if the date time can not be formatted as an RFC3339
/// date time or the resulting string can not be serialized
pub fn serialize_rfc3339<S>(t: &time::OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    let s = t
        .format(&time::format_description::well_known::Rfc3339)
        .map_err(serde::ser::Error::custom)?;

    s.serialize(serializer)
}

/// helper to parse created_on and updated_on in the correct format
/// (default time serde implementation seems to use a different format)
///
/// # Errors
///
/// This will return an error if the underlying string can not be deserialized
/// or it can not be parsed as an RFC3339 date and time
pub fn deserialize_optional_rfc3339<'de, D>(
    deserializer: D,
) -> Result<Option<time::OffsetDateTime>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let s = <Option<String> as Deserialize<'de>>::deserialize(deserializer)?;

    if let Some(s) = s {
        Ok(Some(
            time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
                .map_err(serde::de::Error::custom)?,
        ))
    } else {
        Ok(None)
    }
}

/// helper to serialize created_on and updated_on in the correct format
/// (default time serde implementation seems to use a different format)
///
/// # Errors
///
/// This will return an error if the parameter can not be formatted as RFC3339
/// or the resulting string can not be serialized
pub fn serialize_optional_rfc3339<S>(
    t: &Option<time::OffsetDateTime>,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    if let Some(t) = t {
        let s = t
            .format(&time::format_description::well_known::Rfc3339)
            .map_err(serde::ser::Error::custom)?;

        s.serialize(serializer)
    } else {
        let n: Option<String> = None;
        n.serialize(serializer)
    }
}