Skip to main content

redmine_api/
api.rs

1//! Redmine API
2//!
3//! [`Redmine Documentation`](https://www.redmine.org/projects/redmine/wiki/rest_api)
4//!
5//! - [x] authentication
6//! - [x] pagination
7//!   - [x] add Pageable instances to all types that need them
8//!   - [x] figure out a way to write a general "fetch all pages" function (problem is the different key name in the wrapper)
9//! - [x] impersonation
10//! - [x] attachments
11//! - [x] add all the wrappers I somehow missed
12//!   - [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)
13//! - [x] test include parameters and add relevant data to the return types
14//! - [x] async support
15//!
16//! Potential breaking changes ahead
17//! - [ ] use Enum for sort column
18//! - [ ] typed ids
19//! - [ ] change project_id_or_name to Enum
20//! - [ ] extra filter expressions I overlooked/did not know about
21//! - [ ] parameters that are more flexible than they appear
22
23pub mod attachments;
24pub mod custom_fields;
25pub mod enumerations;
26pub mod files;
27pub mod groups;
28pub mod issue_categories;
29pub mod issue_relations;
30pub mod issue_statuses;
31pub mod issues;
32pub mod my_account;
33pub mod news;
34pub mod project_memberships;
35pub mod projects;
36pub mod queries;
37pub mod roles;
38pub mod search;
39#[cfg(test)]
40pub mod test_helpers;
41pub mod time_entries;
42pub mod trackers;
43pub mod uploads;
44pub mod users;
45pub mod versions;
46pub mod wiki_pages;
47
48use futures::future::FutureExt as _;
49
50use std::str::from_utf8;
51
52use serde::Deserialize;
53use serde::Deserializer;
54use serde::Serialize as _;
55use serde::de::DeserializeOwned;
56
57use reqwest::Method;
58use std::borrow::Cow;
59
60use reqwest::Url;
61use tracing::{debug, error, trace};
62
63/// main API client object (sync)
64#[derive(derive_more::Debug)]
65#[expect(
66    clippy::struct_field_names,
67    reason = "redmine_url disambiguates from the impersonate_user_id and matches public accessor naming"
68)]
69pub struct Redmine {
70    /// the reqwest client we use to perform our API requests
71    client: reqwest::blocking::Client,
72    /// the redmine base url
73    redmine_url: Url,
74    /// a redmine API key, usually 40 hex digits where the letters (a-f) are lower case
75    #[debug(skip)]
76    api_key: String,
77    /// the user id we want to impersonate, only works if the API key we use has admin privileges
78    impersonate_user_id: Option<u64>,
79}
80
81/// main API client object (async)
82#[derive(derive_more::Debug)]
83pub struct RedmineAsync {
84    /// the reqwest client we use to perform our API requests
85    client: reqwest::Client,
86    /// the redmine base url
87    redmine_url: Url,
88    /// a redmine API key, usually 40 hex digits where the letters (a-f) are lower case
89    #[debug(skip)]
90    api_key: String,
91    /// the user id we want to impersonate, only works if the API key we use has admin privileges
92    impersonate_user_id: Option<u64>,
93}
94
95/// helper function to parse the redmine URL in the environment variable
96fn parse_url<'de, D>(deserializer: D) -> Result<url::Url, D::Error>
97where
98    D: Deserializer<'de>,
99{
100    let buf = String::deserialize(deserializer)?;
101
102    url::Url::parse(&buf).map_err(serde::de::Error::custom)
103}
104
105/// used to deserialize the required options from the environment
106#[derive(Debug, Clone, serde::Deserialize)]
107struct EnvOptions {
108    /// a redmine API key, usually 40 hex digits where the letters (a-f) are lower case
109    redmine_api_key: String,
110
111    /// the redmine base url
112    #[serde(deserialize_with = "parse_url")]
113    redmine_url: url::Url,
114}
115
116/// Return value from paged requests, includes the actual value as well as
117/// pagination data
118#[derive(Debug, Clone)]
119pub struct ResponsePage<T> {
120    /// The actual value returned by Redmine deserialized into a user provided type
121    pub values: Vec<T>,
122    /// The total number of values that could be returned by requesting all pages
123    pub total_count: u64,
124    /// The offset from the start (zero-based)
125    pub offset: u64,
126    /// How many entries were returned
127    pub limit: u64,
128}
129
130impl Redmine {
131    /// create a [Redmine] object
132    ///
133    /// # Errors
134    ///
135    /// This will return [`crate::Error::ReqwestError`] if initialization of Reqwest client is failed.
136    pub fn new(
137        client: reqwest::blocking::Client,
138        redmine_url: url::Url,
139        api_key: &str,
140    ) -> Result<Self, crate::Error> {
141        Ok(Self {
142            client,
143            redmine_url,
144            api_key: api_key.to_string(),
145            impersonate_user_id: None,
146        })
147    }
148
149    /// create a [Redmine] object from the environment variables
150    ///
151    /// REDMINE_API_KEY
152    /// REDMINE_URL
153    ///
154    /// # Errors
155    ///
156    /// This will return an error if the environment variables are
157    /// missing or the URL can not be parsed
158    pub fn from_env(client: reqwest::blocking::Client) -> Result<Self, crate::Error> {
159        let env_options = envy::from_env::<EnvOptions>()?;
160
161        let redmine_url = env_options.redmine_url;
162        let api_key = env_options.redmine_api_key;
163
164        Self::new(client, redmine_url, &api_key)
165    }
166
167    /// Sets the user id of a user to impersonate in all future API calls
168    ///
169    /// this requires Redmine admin privileges
170    pub const fn impersonate_user(&mut self, id: u64) {
171        self.impersonate_user_id = Some(id);
172    }
173
174    /// returns the redmine base url
175    #[must_use]
176    pub const fn redmine_url(&self) -> &Url {
177        &self.redmine_url
178    }
179
180    /// returns the issue URL for a given issue id
181    ///
182    /// this is mostly for convenience since we are already storing the
183    /// redmine URL and it works entirely on the client
184    #[must_use]
185    #[expect(
186        clippy::missing_panics_doc,
187        clippy::unwrap_used,
188        reason = "join cannot fail for a constant relative URL"
189    )]
190    pub fn issue_url(&self, issue_id: u64) -> Url {
191        let Self { redmine_url, .. } = self;
192        // we can unwrap here because we know /issues/<number>
193        // parses successfully as an url fragment
194        redmine_url.join(&format!("/issues/{issue_id}")).unwrap()
195    }
196
197    /// internal method for shared logic between the methods below which
198    /// diff in how they parse the response body and how often they call this
199    fn rest(
200        &self,
201        method: reqwest::Method,
202        endpoint: &str,
203        parameters: QueryParams,
204        mime_type_and_body: Option<(&str, Vec<u8>)>,
205    ) -> Result<(reqwest::StatusCode, bytes::Bytes), crate::Error> {
206        let Self {
207            client,
208            redmine_url,
209            api_key,
210            impersonate_user_id,
211        } = self;
212        let mut url = redmine_url.join(endpoint)?;
213        parameters.add_to_url(&mut url);
214        debug!(%url, %method, "Calling redmine");
215        let req = client
216            .request(method.clone(), url.clone())
217            .header("x-redmine-api-key", api_key);
218        let req = if let Some(user_id) = impersonate_user_id {
219            req.header("X-Redmine-Switch-User", format!("{user_id}"))
220        } else {
221            req
222        };
223        let req = if let Some((mime, data)) = mime_type_and_body {
224            if let Ok(request_body) = from_utf8(&data) {
225                trace!("Request body (Content-Type: {}):\n{}", mime, request_body);
226            } else {
227                trace!(
228                    "Request body (Content-Type: {}) could not be parsed as UTF-8:\n{:?}",
229                    mime, data
230                );
231            }
232            req.body(data).header("Content-Type", mime)
233        } else {
234            req
235        };
236        let result = req.send();
237        if let Err(ref e) = result {
238            error!(%url, %method, "Redmine send error: {:?}", e);
239        }
240        let result = result?;
241        let status = result.status();
242        let response_body = result.bytes()?;
243        match from_utf8(&response_body) {
244            Ok(response_body) => {
245                trace!("Response body:\n{}", &response_body);
246            }
247            Err(e) => {
248                trace!(
249                    "Response body that could not be parsed as utf8 because of {}:\n{:?}",
250                    &e, &response_body
251                );
252            }
253        }
254        if status.is_client_error() {
255            error!(%url, %method, "Redmine status error (client error): {:?} response: {:?}", status, from_utf8(&response_body));
256            return Err(crate::Error::HttpErrorResponse(status));
257        } else if status.is_server_error() {
258            error!(%url, %method, "Redmine status error (server error): {:?} response: {:?}", status, from_utf8(&response_body));
259            return Err(crate::Error::HttpErrorResponse(status));
260        }
261        Ok((status, response_body))
262    }
263
264    /// use this with endpoints that have no response body, e.g. those just deleting
265    /// a Redmine object
266    ///
267    /// # Errors
268    ///
269    /// This can return an error if the endpoint returns an error when creating the request
270    /// body or when the web request fails
271    pub fn ignore_response_body<E>(&self, endpoint: &E) -> Result<(), crate::Error>
272    where
273        E: Endpoint,
274    {
275        let method = endpoint.method();
276        let url = endpoint.endpoint();
277        let parameters = endpoint.parameters();
278        let mime_type_and_body = endpoint.body()?;
279        self.rest(method, &url, parameters, mime_type_and_body)?;
280        Ok(())
281    }
282
283    /// use this with endpoints which return a JSON response but do not support pagination
284    ///
285    /// # Errors
286    ///
287    /// This can return an error if the endpoint returns an error when creating the request body,
288    /// when the web request fails or when the response can not be parsed as a JSON object
289    /// into the result type
290    pub fn json_response_body<E, R>(&self, endpoint: &E) -> Result<R, crate::Error>
291    where
292        E: Endpoint + ReturnsJsonResponse + NoPagination,
293        R: DeserializeOwned + std::fmt::Debug,
294    {
295        let method = endpoint.method();
296        let url = endpoint.endpoint();
297        let parameters = endpoint.parameters();
298        let mime_type_and_body = endpoint.body()?;
299        let (status, response_body) = self.rest(method, &url, parameters, mime_type_and_body)?;
300        if response_body.is_empty() {
301            Err(crate::Error::EmptyResponseBody(status))
302        } else {
303            let result = serde_json::from_slice::<R>(&response_body);
304            if let Ok(ref parsed_response_body) = result {
305                trace!("Parsed response body:\n{:#?}", parsed_response_body);
306            }
307            Ok(result?)
308        }
309    }
310
311    /// use this to get a single page of a paginated JSON response
312    /// # Errors
313    ///
314    /// This can return an error if the endpoint returns an error when creating the
315    /// request body, when the web request fails, when the response can not be parsed
316    /// as a JSON object, when any of the pagination keys or the value key are missing
317    /// in the JSON object or when the values can not be parsed as the result type.
318    pub fn json_response_body_page<E, R>(
319        &self,
320        endpoint: &E,
321        offset: u64,
322        limit: u64,
323    ) -> Result<ResponsePage<R>, crate::Error>
324    where
325        E: Endpoint + ReturnsJsonResponse + Pageable,
326        R: DeserializeOwned + std::fmt::Debug,
327    {
328        let method = endpoint.method();
329        let url = endpoint.endpoint();
330        let mut parameters = endpoint.parameters();
331        parameters.push("offset", offset);
332        parameters.push("limit", limit);
333        let mime_type_and_body = endpoint.body()?;
334        let (status, response_body) = self.rest(method, &url, parameters, mime_type_and_body)?;
335        if response_body.is_empty() {
336            Err(crate::Error::EmptyResponseBody(status))
337        } else {
338            let json_value_response_body: serde_json::Value =
339                serde_json::from_slice(&response_body)?;
340            let json_object_response_body = json_value_response_body.as_object();
341            if let Some(json_object_response_body) = json_object_response_body {
342                let total_count = json_object_response_body
343                    .get("total_count")
344                    .ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
345                    .as_u64()
346                    .ok_or_else(|| {
347                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
348                    })?;
349                let offset = json_object_response_body
350                    .get("offset")
351                    .ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
352                    .as_u64()
353                    .ok_or_else(|| {
354                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
355                    })?;
356                let limit = json_object_response_body
357                    .get("limit")
358                    .ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
359                    .as_u64()
360                    .ok_or_else(|| {
361                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
362                    })?;
363                let response_wrapper_key = endpoint.response_wrapper_key();
364                let inner_response_body = json_object_response_body
365                    .get(&response_wrapper_key)
366                    .ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
367                let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
368                if let Ok(ref parsed_response_body) = result {
369                    trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
370                }
371                Ok(ResponsePage {
372                    values: result?,
373                    total_count,
374                    offset,
375                    limit,
376                })
377            } else {
378                Err(crate::Error::NonObjectResponseBody(status))
379            }
380        }
381    }
382
383    /// use this to get the results for all pages of a paginated JSON response
384    ///
385    /// # Errors
386    ///
387    /// This can return an error if the endpoint returns an error when creating the
388    /// request body, when any of the web requests fails, when the response can not be
389    /// parsed as a JSON object, when any of the pagination keys or the value key are missing
390    /// in the JSON object or when the values can not be parsed as the result type.
391    ///
392    #[expect(
393        clippy::arithmetic_side_effects,
394        reason = "u64 pagination counters; overflow requires impossibly many results"
395    )]
396    pub fn json_response_body_all_pages<E, R>(&self, endpoint: &E) -> Result<Vec<R>, crate::Error>
397    where
398        E: Endpoint + ReturnsJsonResponse + Pageable,
399        R: DeserializeOwned + std::fmt::Debug,
400    {
401        let method = endpoint.method();
402        let url = endpoint.endpoint();
403        let mut offset = 0;
404        let limit = 100;
405        let mut total_results = vec![];
406        loop {
407            let mut page_parameters = endpoint.parameters();
408            page_parameters.push("offset", offset);
409            page_parameters.push("limit", limit);
410            let mime_type_and_body = endpoint.body()?;
411            let (status, response_body) =
412                self.rest(method.clone(), &url, page_parameters, mime_type_and_body)?;
413            if response_body.is_empty() {
414                return Err(crate::Error::EmptyResponseBody(status));
415            }
416            let json_value_response_body: serde_json::Value =
417                serde_json::from_slice(&response_body)?;
418            let json_object_response_body = json_value_response_body.as_object();
419            if let Some(json_object_response_body) = json_object_response_body {
420                let total_count: u64 = json_object_response_body
421                    .get("total_count")
422                    .ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
423                    .as_u64()
424                    .ok_or_else(|| {
425                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
426                    })?;
427                let response_offset: u64 = json_object_response_body
428                    .get("offset")
429                    .ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
430                    .as_u64()
431                    .ok_or_else(|| {
432                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
433                    })?;
434                let response_limit: u64 = json_object_response_body
435                    .get("limit")
436                    .ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
437                    .as_u64()
438                    .ok_or_else(|| {
439                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
440                    })?;
441                let response_wrapper_key = endpoint.response_wrapper_key();
442                let inner_response_body = json_object_response_body
443                    .get(&response_wrapper_key)
444                    .ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
445                let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
446                if let Ok(ref parsed_response_body) = result {
447                    trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
448                }
449                total_results.extend(result?);
450                if total_count < (response_offset + response_limit) {
451                    break;
452                }
453                offset += limit;
454            } else {
455                return Err(crate::Error::NonObjectResponseBody(status));
456            }
457        }
458        Ok(total_results)
459    }
460
461    /// use this to get the results for all pages of a paginated JSON response
462    /// as an Iterator
463    pub const fn json_response_body_all_pages_iter<'a, 'e, 'i, E, R>(
464        &'a self,
465        endpoint: &'e E,
466    ) -> AllPages<'i, E, R>
467    where
468        E: Endpoint + ReturnsJsonResponse + Pageable,
469        R: DeserializeOwned + std::fmt::Debug,
470        'a: 'i,
471        'e: 'i,
472    {
473        AllPages::new(self, endpoint)
474    }
475}
476
477impl RedmineAsync {
478    /// create a [RedmineAsync] object
479    ///
480    /// # Errors
481    ///
482    /// This will return [`crate::Error::ReqwestError`] if initialization of Reqwest client is failed.
483    pub fn new(
484        client: reqwest::Client,
485        redmine_url: url::Url,
486        api_key: &str,
487    ) -> Result<std::sync::Arc<Self>, crate::Error> {
488        Ok(std::sync::Arc::new(Self {
489            client,
490            redmine_url,
491            api_key: api_key.to_string(),
492            impersonate_user_id: None,
493        }))
494    }
495
496    /// create a [RedmineAsync] object from the environment variables
497    ///
498    /// REDMINE_API_KEY
499    /// REDMINE_URL
500    ///
501    /// # Errors
502    ///
503    /// This will return an error if the environment variables are
504    /// missing or the URL can not be parsed
505    pub fn from_env(client: reqwest::Client) -> Result<std::sync::Arc<Self>, crate::Error> {
506        let env_options = envy::from_env::<EnvOptions>()?;
507
508        let redmine_url = env_options.redmine_url;
509        let api_key = env_options.redmine_api_key;
510
511        Self::new(client, redmine_url, &api_key)
512    }
513
514    /// Sets the user id of a user to impersonate in all future API calls
515    ///
516    /// this requires Redmine admin privileges
517    pub const fn impersonate_user(&mut self, id: u64) {
518        self.impersonate_user_id = Some(id);
519    }
520
521    /// returns the redmine base url
522    #[must_use]
523    pub const fn redmine_url(&self) -> &Url {
524        &self.redmine_url
525    }
526
527    /// returns the issue URL for a given issue id
528    ///
529    /// this is mostly for convenience since we are already storing the
530    /// redmine URL and it works entirely on the client
531    #[must_use]
532    #[expect(
533        clippy::missing_panics_doc,
534        clippy::unwrap_used,
535        reason = "join cannot fail for a constant relative URL"
536    )]
537    pub fn issue_url(&self, issue_id: u64) -> Url {
538        let Self { redmine_url, .. } = self;
539        // we can unwrap here because we know /issues/<number>
540        // parses successfully as an url fragment
541        redmine_url.join(&format!("/issues/{issue_id}")).unwrap()
542    }
543
544    /// internal method for shared logic between the methods below which
545    /// diff in how they parse the response body and how often they call this
546    async fn rest(
547        self: std::sync::Arc<Self>,
548        method: reqwest::Method,
549        endpoint: &str,
550        parameters: QueryParams<'_>,
551        mime_type_and_body: Option<(&str, Vec<u8>)>,
552    ) -> Result<(reqwest::StatusCode, bytes::Bytes), crate::Error> {
553        let Self {
554            client,
555            redmine_url,
556            api_key,
557            impersonate_user_id,
558        } = self.as_ref();
559        let mut url = redmine_url.join(endpoint)?;
560        parameters.add_to_url(&mut url);
561        debug!(%url, %method, "Calling redmine");
562        let req = client
563            .request(method.clone(), url.clone())
564            .header("x-redmine-api-key", api_key);
565        let req = if let Some(user_id) = impersonate_user_id {
566            req.header("X-Redmine-Switch-User", format!("{user_id}"))
567        } else {
568            req
569        };
570        let req = if let Some((mime, data)) = mime_type_and_body {
571            if let Ok(request_body) = from_utf8(&data) {
572                trace!("Request body (Content-Type: {}):\n{}", mime, request_body);
573            } else {
574                trace!(
575                    "Request body (Content-Type: {}) could not be parsed as UTF-8:\n{:?}",
576                    mime, data
577                );
578            }
579            req.body(data).header("Content-Type", mime)
580        } else {
581            req
582        };
583        let result = req.send().await;
584        if let Err(ref e) = result {
585            error!(%url, %method, "Redmine send error: {:?}", e);
586        }
587        let result = result?;
588        let status = result.status();
589        let response_body = result.bytes().await?;
590        match from_utf8(&response_body) {
591            Ok(response_body) => {
592                trace!("Response body:\n{}", &response_body);
593            }
594            Err(e) => {
595                trace!(
596                    "Response body that could not be parsed as utf8 because of {}:\n{:?}",
597                    &e, &response_body
598                );
599            }
600        }
601        if status.is_client_error() {
602            error!(%url, %method, "Redmine status error (client error): {:?} response: {:?}", status, from_utf8(&response_body));
603        } else if status.is_server_error() {
604            error!(%url, %method, "Redmine status error (server error): {:?} response: {:?}", status, from_utf8(&response_body));
605        }
606        Ok((status, response_body))
607    }
608
609    /// use this with endpoints that have no response body, e.g. those just deleting
610    /// a Redmine object
611    ///
612    /// # Errors
613    ///
614    /// This can return an error if the endpoint returns an error when creating the request
615    /// body or when the web request fails
616    pub async fn ignore_response_body<E>(
617        self: std::sync::Arc<Self>,
618        endpoint: impl EndpointParameter<E>,
619    ) -> Result<(), crate::Error>
620    where
621        E: Endpoint,
622    {
623        let endpoint: std::sync::Arc<E> = endpoint.into_arc();
624        let method = endpoint.method();
625        let url = endpoint.endpoint();
626        let parameters = endpoint.parameters();
627        let mime_type_and_body = endpoint.body()?;
628        self.rest(method, &url, parameters, mime_type_and_body)
629            .await?;
630        Ok(())
631    }
632
633    /// use this with endpoints which return a JSON response but do not support pagination
634    ///
635    /// you can use it with those that support pagination but they will only return the first page
636    ///
637    /// # Errors
638    ///
639    /// This can return an error if the endpoint returns an error when creating the request body,
640    /// when the web request fails or when the response can not be parsed as a JSON object
641    /// into the result type
642    pub async fn json_response_body<E, R>(
643        self: std::sync::Arc<Self>,
644        endpoint: impl EndpointParameter<E>,
645    ) -> Result<R, crate::Error>
646    where
647        E: Endpoint + ReturnsJsonResponse + NoPagination,
648        R: DeserializeOwned + std::fmt::Debug,
649    {
650        let endpoint: std::sync::Arc<E> = endpoint.into_arc();
651        let method = endpoint.method();
652        let url = endpoint.endpoint();
653        let parameters = endpoint.parameters();
654        let mime_type_and_body = endpoint.body()?;
655        let (status, response_body) = self
656            .rest(method, &url, parameters, mime_type_and_body)
657            .await?;
658        if response_body.is_empty() {
659            Err(crate::Error::EmptyResponseBody(status))
660        } else {
661            let result = serde_json::from_slice::<R>(&response_body);
662            if let Ok(ref parsed_response_body) = result {
663                trace!("Parsed response body:\n{:#?}", parsed_response_body);
664            }
665            Ok(result?)
666        }
667    }
668
669    /// use this to get a single page of a paginated JSON response
670    /// # Errors
671    ///
672    /// This can return an error if the endpoint returns an error when creating the
673    /// request body, when the web request fails, when the response can not be parsed
674    /// as a JSON object, when any of the pagination keys or the value key are missing
675    /// in the JSON object or when the values can not be parsed as the result type.
676    pub async fn json_response_body_page<E, R>(
677        self: std::sync::Arc<Self>,
678        endpoint: impl EndpointParameter<E>,
679        offset: u64,
680        limit: u64,
681    ) -> Result<ResponsePage<R>, crate::Error>
682    where
683        E: Endpoint + ReturnsJsonResponse + Pageable,
684        R: DeserializeOwned + std::fmt::Debug,
685    {
686        let endpoint: std::sync::Arc<E> = endpoint.into_arc();
687        let method = endpoint.method();
688        let url = endpoint.endpoint();
689        let mut parameters = endpoint.parameters();
690        parameters.push("offset", offset);
691        parameters.push("limit", limit);
692        let mime_type_and_body = endpoint.body()?;
693        let (status, response_body) = self
694            .rest(method, &url, parameters, mime_type_and_body)
695            .await?;
696        if response_body.is_empty() {
697            Err(crate::Error::EmptyResponseBody(status))
698        } else {
699            let json_value_response_body: serde_json::Value =
700                serde_json::from_slice(&response_body)?;
701            let json_object_response_body = json_value_response_body.as_object();
702            if let Some(json_object_response_body) = json_object_response_body {
703                let total_count = json_object_response_body
704                    .get("total_count")
705                    .ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
706                    .as_u64()
707                    .ok_or_else(|| {
708                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
709                    })?;
710                let offset = json_object_response_body
711                    .get("offset")
712                    .ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
713                    .as_u64()
714                    .ok_or_else(|| {
715                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
716                    })?;
717                let limit = json_object_response_body
718                    .get("limit")
719                    .ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
720                    .as_u64()
721                    .ok_or_else(|| {
722                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
723                    })?;
724                let response_wrapper_key = endpoint.response_wrapper_key();
725                let inner_response_body = json_object_response_body
726                    .get(&response_wrapper_key)
727                    .ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
728                let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
729                if let Ok(ref parsed_response_body) = result {
730                    trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
731                }
732                Ok(ResponsePage {
733                    values: result?,
734                    total_count,
735                    offset,
736                    limit,
737                })
738            } else {
739                Err(crate::Error::NonObjectResponseBody(status))
740            }
741        }
742    }
743
744    /// use this to get the results for all pages of a paginated JSON response
745    ///
746    /// # Errors
747    ///
748    /// This can return an error if the endpoint returns an error when creating the
749    /// request body, when any of the web requests fails, when the response can not be
750    /// parsed as a JSON object, when any of the pagination keys or the value key are missing
751    /// in the JSON object or when the values can not be parsed as the result type.
752    ///
753    #[expect(
754        clippy::arithmetic_side_effects,
755        clippy::clone_on_ref_ptr,
756        reason = "u64 pagination counters; cloning an Arc<Self> is intentional to keep the Arc alive across the await"
757    )]
758    pub async fn json_response_body_all_pages<E, R>(
759        self: std::sync::Arc<Self>,
760        endpoint: impl EndpointParameter<E>,
761    ) -> Result<Vec<R>, crate::Error>
762    where
763        E: Endpoint + ReturnsJsonResponse + Pageable,
764        R: DeserializeOwned + std::fmt::Debug,
765    {
766        let endpoint: std::sync::Arc<E> = endpoint.into_arc();
767        let method = endpoint.method();
768        let url = endpoint.endpoint();
769        let mut offset = 0;
770        let limit = 100;
771        let mut total_results = vec![];
772        loop {
773            let mut page_parameters = endpoint.parameters();
774            page_parameters.push("offset", offset);
775            page_parameters.push("limit", limit);
776            let mime_type_and_body = endpoint.body()?;
777            let (status, response_body) = self
778                .clone()
779                .rest(method.clone(), &url, page_parameters, mime_type_and_body)
780                .await?;
781            if response_body.is_empty() {
782                return Err(crate::Error::EmptyResponseBody(status));
783            }
784            let json_value_response_body: serde_json::Value =
785                serde_json::from_slice(&response_body)?;
786            let json_object_response_body = json_value_response_body.as_object();
787            if let Some(json_object_response_body) = json_object_response_body {
788                let total_count: u64 = json_object_response_body
789                    .get("total_count")
790                    .ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
791                    .as_u64()
792                    .ok_or_else(|| {
793                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
794                    })?;
795                let response_offset: u64 = json_object_response_body
796                    .get("offset")
797                    .ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
798                    .as_u64()
799                    .ok_or_else(|| {
800                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
801                    })?;
802                let response_limit: u64 = json_object_response_body
803                    .get("limit")
804                    .ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
805                    .as_u64()
806                    .ok_or_else(|| {
807                        crate::Error::PaginationKeyHasWrongType("total_count".to_string())
808                    })?;
809                let response_wrapper_key = endpoint.response_wrapper_key();
810                let inner_response_body = json_object_response_body
811                    .get(&response_wrapper_key)
812                    .ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
813                let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
814                if let Ok(ref parsed_response_body) = result {
815                    trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
816                }
817                total_results.extend(result?);
818                if total_count < (response_offset + response_limit) {
819                    break;
820                }
821                offset += limit;
822            } else {
823                return Err(crate::Error::NonObjectResponseBody(status));
824            }
825        }
826        Ok(total_results)
827    }
828
829    /// use this to get the results for all pages of a paginated JSON response
830    /// as a Stream
831    pub fn json_response_body_all_pages_stream<E, R>(
832        self: std::sync::Arc<Self>,
833        endpoint: impl EndpointParameter<E>,
834    ) -> AllPagesAsync<E, R>
835    where
836        E: Endpoint + ReturnsJsonResponse + Pageable,
837        R: DeserializeOwned + std::fmt::Debug,
838    {
839        let endpoint: std::sync::Arc<E> = endpoint.into_arc();
840        AllPagesAsync::new(self, endpoint)
841    }
842}
843
844/// A trait representing a parameter value.
845pub trait ParamValue<'a> {
846    /// The parameter value as a string.
847    fn as_value(&self) -> Cow<'a, str>;
848}
849
850impl ParamValue<'static> for bool {
851    fn as_value(&self) -> Cow<'static, str> {
852        if *self { "true".into() } else { "false".into() }
853    }
854}
855
856impl<'a> ParamValue<'a> for &'a str {
857    fn as_value(&self) -> Cow<'a, str> {
858        (*self).into()
859    }
860}
861
862impl ParamValue<'static> for String {
863    fn as_value(&self) -> Cow<'static, str> {
864        self.clone().into()
865    }
866}
867
868impl<'a> ParamValue<'a> for &'a String {
869    fn as_value(&self) -> Cow<'a, str> {
870        (*self).into()
871    }
872}
873
874/// serialize a [`Vec<T>`] where T implements [ToString] as a string
875/// of comma-separated values
876impl<T> ParamValue<'static> for Vec<T>
877where
878    T: ToString,
879{
880    fn as_value(&self) -> Cow<'static, str> {
881        self.iter()
882            .map(|e| e.to_string())
883            .collect::<Vec<_>>()
884            .join(",")
885            .into()
886    }
887}
888
889/// serialize a [`&Vec<T>`](Vec<T>) where T implements [ToString] as a string
890/// of comma-separated values
891impl<'a, T> ParamValue<'a> for &'a Vec<T>
892where
893    T: ToString,
894{
895    fn as_value(&self) -> Cow<'a, str> {
896        self.iter()
897            .map(|e| e.to_string())
898            .collect::<Vec<_>>()
899            .join(",")
900            .into()
901    }
902}
903
904impl<'a> ParamValue<'a> for Cow<'a, str> {
905    fn as_value(&self) -> Self {
906        self.clone()
907    }
908}
909
910impl<'a> ParamValue<'a> for &'a Cow<'a, str> {
911    fn as_value(&self) -> Cow<'a, str> {
912        (*self).clone()
913    }
914}
915
916impl ParamValue<'static> for u64 {
917    fn as_value(&self) -> Cow<'static, str> {
918        format!("{self}").into()
919    }
920}
921
922impl ParamValue<'static> for f64 {
923    fn as_value(&self) -> Cow<'static, str> {
924        format!("{self}").into()
925    }
926}
927
928impl ParamValue<'static> for time::OffsetDateTime {
929    #[expect(
930        clippy::unwrap_used,
931        reason = "RFC 3339 formatting cannot fail for a fully-populated OffsetDateTime"
932    )]
933    fn as_value(&self) -> Cow<'static, str> {
934        self.format(&time::format_description::well_known::Rfc3339)
935            .unwrap()
936            .into()
937    }
938}
939
940impl ParamValue<'static> for time::Date {
941    #[expect(
942        clippy::unwrap_used,
943        reason = "the format description literal is a constant valid format"
944    )]
945    fn as_value(&self) -> Cow<'static, str> {
946        let format = time::format_description::parse("[year]-[month]-[day]").unwrap();
947        self.format(&format).unwrap().into()
948    }
949}
950
951/// Filter for a comparable date time filters for past
952/// used for filters on created_on, updated_on fields
953#[derive(Debug, Clone)]
954pub enum DateTimeFilterPast {
955    /// an exact match
956    ExactMatch(time::OffsetDateTime),
957    /// a range match (inclusive)
958    Range(time::OffsetDateTime, time::OffsetDateTime),
959    /// we only want values less than or equal to the parameter
960    LessThanOrEqual(time::OffsetDateTime),
961    /// we only want values greater than or equal to the parameter
962    GreaterThanOrEqual(time::OffsetDateTime),
963    /// less than n days ago
964    LessThanDaysAgo(u32),
965    /// more than n days ago
966    MoreThanDaysAgo(u32),
967    /// within the past n days
968    WithinPastDays(u32),
969    /// exactly n days ago
970    ExactDaysAgo(u32),
971    /// today
972    Today,
973    /// yesterday
974    Yesterday,
975    /// this week
976    ThisWeek,
977    /// last week
978    LastWeek,
979    /// last 2 weeks
980    LastTwoWeeks,
981    /// this month
982    ThisMonth,
983    /// last month
984    LastMonth,
985    /// this year
986    ThisYear,
987    /// unset value (NULL in DB)
988    Unset,
989    /// any value (NOT NULL in DB)
990    Any,
991}
992
993impl std::fmt::Display for DateTimeFilterPast {
994    #[expect(
995        clippy::expect_used,
996        reason = "the format_description!() macro produces a known-good format that cannot fail at runtime"
997    )]
998    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
999        let format =
1000            time::macros::format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]Z");
1001        match self {
1002            Self::ExactMatch(v) => {
1003                write!(
1004                    f,
1005                    "{}",
1006                    v.format(&format).expect(
1007                        "Error formatting OffsetDateTime in DateTimeFilterPast::ExactMatch"
1008                    )
1009                )
1010            }
1011            Self::Range(v_start, v_end) => {
1012                write!(
1013                    f,
1014                    "><{}|{}",
1015                    v_start.format(&format).expect(
1016                        "Error formatting first OffsetDateTime in DateTimeFilterPast::Range"
1017                    ),
1018                    v_end.format(&format).expect(
1019                        "Error formatting second OffsetDateTime in DateTimeFilterPast::Range"
1020                    ),
1021                )
1022            }
1023            Self::LessThanOrEqual(v) => {
1024                write!(
1025                    f,
1026                    "<={}",
1027                    v.format(&format).expect(
1028                        "Error formatting OffsetDateTime in DateTimeFilterPast::LessThanOrEqual"
1029                    )
1030                )
1031            }
1032            Self::GreaterThanOrEqual(v) => {
1033                write!(
1034                    f,
1035                    ">={}",
1036                    v.format(&format).expect(
1037                        "Error formatting OffsetDateTime in DateTimeFilterPast::GreaterThanOrEqual"
1038                    )
1039                )
1040            }
1041            Self::LessThanDaysAgo(d) => {
1042                write!(f, ">t-{d}")
1043            }
1044            Self::MoreThanDaysAgo(d) => {
1045                write!(f, "<t-{d}")
1046            }
1047            Self::WithinPastDays(d) => {
1048                write!(f, "><t-{d}")
1049            }
1050            Self::ExactDaysAgo(d) => {
1051                write!(f, "t-{d}")
1052            }
1053            Self::Today => {
1054                write!(f, "t")
1055            }
1056            Self::Yesterday => {
1057                write!(f, "ld")
1058            }
1059            Self::ThisWeek => {
1060                write!(f, "w")
1061            }
1062            Self::LastWeek => {
1063                write!(f, "lw")
1064            }
1065            Self::LastTwoWeeks => {
1066                write!(f, "l2w")
1067            }
1068            Self::ThisMonth => {
1069                write!(f, "m")
1070            }
1071            Self::LastMonth => {
1072                write!(f, "lm")
1073            }
1074            Self::ThisYear => {
1075                write!(f, "y")
1076            }
1077            Self::Unset => {
1078                write!(f, "!*")
1079            }
1080            Self::Any => {
1081                write!(f, "*")
1082            }
1083        }
1084    }
1085}
1086
1087/// Filter options for subject and description
1088#[derive(Debug, Clone)]
1089pub enum StringFieldFilter {
1090    /// match exactly this value
1091    ExactMatch(String),
1092    /// match this substring of the actual value
1093    SubStringMatch(String),
1094}
1095
1096impl std::fmt::Display for StringFieldFilter {
1097    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1098        match self {
1099            Self::ExactMatch(s) => {
1100                write!(f, "{s}")
1101            }
1102            Self::SubStringMatch(s) => {
1103                write!(f, "~{s}")
1104            }
1105        }
1106    }
1107}
1108
1109/// A filter for a custom field, consisting of its ID and a StringFieldFilter for its value.
1110#[derive(Debug, Clone)]
1111pub struct CustomFieldFilter {
1112    /// The ID of the custom field to filter by.
1113    pub id: u64,
1114    /// The value to filter the custom field by, using a `StringFieldFilter`.
1115    pub value: StringFieldFilter,
1116}
1117
1118/// Filter for float values, supporting various comparison operators.
1119#[derive(Debug, Clone)]
1120pub enum FloatFilter {
1121    /// An exact match for the float value.
1122    ExactMatch(f64),
1123    /// A range match (inclusive) for two float values.
1124    Range(f64, f64),
1125    /// Values less than or equal to the specified float.
1126    LessThanOrEqual(f64),
1127    /// Values greater than or equal to the specified float.
1128    GreaterThanOrEqual(f64),
1129    /// Any value (equivalent to `> 0`).
1130    Any,
1131    /// No value (equivalent to `= 0`).
1132    None,
1133}
1134
1135impl std::fmt::Display for FloatFilter {
1136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1137        match self {
1138            Self::ExactMatch(v) => write!(f, "{v}"),
1139            Self::Range(v_start, v_end) => write!(f, "><{v_start}|{v_end}"),
1140            Self::LessThanOrEqual(v) => write!(f, "<={v}"),
1141            Self::GreaterThanOrEqual(v) => write!(f, ">={v}"),
1142            Self::Any => write!(f, "*"),
1143            Self::None => write!(f, "!*"),
1144        }
1145    }
1146}
1147
1148/// Filter for integer values, supporting various comparison operators.
1149#[derive(Debug, Clone)]
1150pub enum IntegerFilter {
1151    /// An exact match for the integer value.
1152    ExactMatch(u64),
1153    /// A range match (inclusive) for two integer values.
1154    Range(u64, u64),
1155    /// Values less than or equal to the specified integer.
1156    LessThanOrEqual(u64),
1157    /// Values greater than or equal to the specified integer.
1158    GreaterThanOrEqual(u64),
1159    /// Any value (equivalent to `> 0`).
1160    Any,
1161    /// No value (equivalent to `= 0`).
1162    None,
1163}
1164
1165impl std::fmt::Display for IntegerFilter {
1166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1167        match self {
1168            Self::ExactMatch(v) => write!(f, "{v}"),
1169            Self::Range(v_start, v_end) => write!(f, "><{v_start}|{v_end}"),
1170            Self::LessThanOrEqual(v) => write!(f, "<={v}"),
1171            Self::GreaterThanOrEqual(v) => write!(f, ">={v}"),
1172            Self::Any => write!(f, "*"),
1173            Self::None => write!(f, "!*"),
1174        }
1175    }
1176}
1177
1178/// Filter for tracker IDs.
1179#[derive(Debug, Clone)]
1180pub enum TrackerFilter {
1181    /// Match any tracker.
1182    Any,
1183    /// Match no tracker.
1184    None,
1185    /// Match a specific list of trackers.
1186    TheseTrackers(Vec<u64>),
1187    /// Match any tracker but a specific list of trackers.
1188    NotTheseTrackers(Vec<u64>),
1189}
1190
1191impl std::fmt::Display for TrackerFilter {
1192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1193        match self {
1194            Self::Any => write!(f, "*"),
1195            Self::None => write!(f, "!*"),
1196            Self::TheseTrackers(ids) => {
1197                let s: String = ids
1198                    .iter()
1199                    .map(|e| e.to_string())
1200                    .collect::<Vec<_>>()
1201                    .join(",");
1202                write!(f, "{s}")
1203            }
1204            Self::NotTheseTrackers(ids) => {
1205                let s: String = ids
1206                    .iter()
1207                    .map(|e| format!("!{e}"))
1208                    .collect::<Vec<_>>()
1209                    .join(",");
1210                write!(f, "{s}")
1211            }
1212        }
1213    }
1214}
1215
1216/// Filter for activity IDs.
1217#[derive(Debug, Clone)]
1218pub enum ActivityFilter {
1219    /// Match any activity.
1220    Any,
1221    /// Match no activity.
1222    None,
1223    /// Match a specific list of activities.
1224    TheseActivities(Vec<u64>),
1225    /// Match any activity but a specific list of activities.
1226    NotTheseActivities(Vec<u64>),
1227}
1228
1229impl std::fmt::Display for ActivityFilter {
1230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1231        match self {
1232            Self::Any => write!(f, "*"),
1233            Self::None => write!(f, "!*"),
1234            Self::TheseActivities(ids) => {
1235                let s: String = ids
1236                    .iter()
1237                    .map(|e| e.to_string())
1238                    .collect::<Vec<_>>()
1239                    .join(",");
1240                write!(f, "{s}")
1241            }
1242            Self::NotTheseActivities(ids) => {
1243                let s: String = ids
1244                    .iter()
1245                    .map(|e| format!("!{e}"))
1246                    .collect::<Vec<_>>()
1247                    .join(",");
1248                write!(f, "{s}")
1249            }
1250        }
1251    }
1252}
1253
1254/// Filter for fixed version IDs.
1255#[derive(Debug, Clone)]
1256pub enum VersionFilter {
1257    /// Match any version.
1258    Any,
1259    /// Match no version.
1260    None,
1261    /// Match a specific list of versions.
1262    TheseVersions(Vec<u64>),
1263    /// Match any version but a specific list of versions.
1264    NotTheseVersions(Vec<u64>),
1265}
1266
1267impl std::fmt::Display for VersionFilter {
1268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1269        match self {
1270            Self::Any => write!(f, "*"),
1271            Self::None => write!(f, "!*"),
1272            Self::TheseVersions(ids) => {
1273                let s: String = ids
1274                    .iter()
1275                    .map(|e| e.to_string())
1276                    .collect::<Vec<_>>()
1277                    .join(",");
1278                write!(f, "{s}")
1279            }
1280            Self::NotTheseVersions(ids) => {
1281                let s: String = ids
1282                    .iter()
1283                    .map(|e| format!("!{e}"))
1284                    .collect::<Vec<_>>()
1285                    .join(",");
1286                write!(f, "{s}")
1287            }
1288        }
1289    }
1290}
1291
1292/// Filter for date values, supporting various comparison operators.
1293#[derive(Debug, Clone)]
1294pub enum DateFilter {
1295    /// an exact match
1296    ExactMatch(time::Date),
1297    /// a range match (inclusive)
1298    Range(time::Date, time::Date),
1299    /// we only want values less than or equal to the parameter
1300    LessThanOrEqual(time::Date),
1301    /// we only want values greater than or equal to the parameter
1302    GreaterThanOrEqual(time::Date),
1303    /// less than n days ago
1304    LessThanDaysAgo(u32),
1305    /// more than n days ago
1306    MoreThanDaysAgo(u32),
1307    /// within the past n days
1308    WithinPastDays(u32),
1309    /// exactly n days ago
1310    ExactDaysAgo(u32),
1311    /// in less than n days
1312    InLessThanDays(u32),
1313    /// in more than n days
1314    InMoreThanDays(u32),
1315    /// in the next n days
1316    WithinFutureDays(u32),
1317    /// in exactly n days
1318    InExactDays(u32),
1319    /// today
1320    Today,
1321    /// yesterday
1322    Yesterday,
1323    /// tomorrow
1324    Tomorrow,
1325    /// this week
1326    ThisWeek,
1327    /// last week
1328    LastWeek,
1329    /// last 2 weeks
1330    LastTwoWeeks,
1331    /// next week
1332    NextWeek,
1333    /// this month
1334    ThisMonth,
1335    /// last month
1336    LastMonth,
1337    /// next month
1338    NextMonth,
1339    /// this year
1340    ThisYear,
1341    /// unset value (NULL in DB)
1342    Unset,
1343    /// any value (NOT NULL in DB)
1344    Any,
1345}
1346
1347impl std::fmt::Display for DateFilter {
1348    #[expect(
1349        clippy::expect_used,
1350        reason = "the format_description!() macro produces a known-good format that cannot fail at runtime"
1351    )]
1352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1353        let format = time::macros::format_description!("[year]-[month]-[day]");
1354        match self {
1355            Self::ExactMatch(v) => {
1356                write!(
1357                    f,
1358                    "{}",
1359                    v.format(&format)
1360                        .expect("Error formatting Date in DateFilter::ExactMatch")
1361                )
1362            }
1363            Self::Range(v_start, v_end) => {
1364                write!(
1365                    f,
1366                    "><{}|{}",
1367                    v_start
1368                        .format(&format)
1369                        .expect("Error formatting first Date in DateFilter::Range"),
1370                    v_end
1371                        .format(&format)
1372                        .expect("Error formatting second Date in DateFilter::Range"),
1373                )
1374            }
1375            Self::LessThanOrEqual(v) => {
1376                write!(
1377                    f,
1378                    "<={}",
1379                    v.format(&format)
1380                        .expect("Error formatting Date in DateFilter::LessThanOrEqual")
1381                )
1382            }
1383            Self::GreaterThanOrEqual(v) => {
1384                write!(
1385                    f,
1386                    ">={}",
1387                    v.format(&format)
1388                        .expect("Error formatting Date in DateFilter::GreaterThanOrEqual")
1389                )
1390            }
1391            Self::LessThanDaysAgo(d) => {
1392                write!(f, ">t-{d}")
1393            }
1394            Self::MoreThanDaysAgo(d) => {
1395                write!(f, "<t-{d}")
1396            }
1397            Self::WithinPastDays(d) => {
1398                write!(f, "><t-{d}")
1399            }
1400            Self::ExactDaysAgo(d) => {
1401                write!(f, "t-{d}")
1402            }
1403            Self::InLessThanDays(d) => {
1404                write!(f, "<t+{d}")
1405            }
1406            Self::InMoreThanDays(d) => {
1407                write!(f, ">t+{d}")
1408            }
1409            Self::WithinFutureDays(d) => {
1410                write!(f, "><t+{d}")
1411            }
1412            Self::InExactDays(d) => {
1413                write!(f, "t+{d}")
1414            }
1415            Self::Today => {
1416                write!(f, "t")
1417            }
1418            Self::Yesterday => {
1419                write!(f, "ld")
1420            }
1421            Self::Tomorrow => {
1422                write!(f, "nd")
1423            }
1424            Self::ThisWeek => {
1425                write!(f, "w")
1426            }
1427            Self::LastWeek => {
1428                write!(f, "lw")
1429            }
1430            Self::LastTwoWeeks => {
1431                write!(f, "l2w")
1432            }
1433            Self::NextWeek => {
1434                write!(f, "nw")
1435            }
1436            Self::ThisMonth => {
1437                write!(f, "m")
1438            }
1439            Self::LastMonth => {
1440                write!(f, "lm")
1441            }
1442            Self::NextMonth => {
1443                write!(f, "nm")
1444            }
1445            Self::ThisYear => {
1446                write!(f, "y")
1447            }
1448            Self::Unset => {
1449                write!(f, "!*")
1450            }
1451            Self::Any => {
1452                write!(f, "*")
1453            }
1454        }
1455    }
1456}
1457
1458/// A structure for query parameters.
1459#[derive(Debug, Default, Clone)]
1460pub struct QueryParams<'a> {
1461    /// the actual parameters
1462    params: Vec<(Cow<'a, str>, Cow<'a, str>)>,
1463}
1464
1465impl<'a> QueryParams<'a> {
1466    /// Push a single parameter.
1467    pub fn push<'b, K, V>(&mut self, key: K, value: V) -> &mut Self
1468    where
1469        K: Into<Cow<'a, str>>,
1470        V: ParamValue<'b>,
1471        'b: 'a,
1472    {
1473        self.params.push((key.into(), value.as_value()));
1474        self
1475    }
1476
1477    /// Push a single parameter.
1478    pub fn push_opt<'b, K, V>(&mut self, key: K, value: Option<V>) -> &mut Self
1479    where
1480        K: Into<Cow<'a, str>>,
1481        V: ParamValue<'b>,
1482        'b: 'a,
1483    {
1484        if let Some(value) = value {
1485            self.params.push((key.into(), value.as_value()));
1486        }
1487        self
1488    }
1489
1490    /// Push a set of parameters.
1491    pub fn extend<'b, I, K, V>(&mut self, iter: I) -> &mut Self
1492    where
1493        I: Iterator<Item = (K, V)>,
1494        K: Into<Cow<'a, str>>,
1495        V: ParamValue<'b>,
1496        'b: 'a,
1497    {
1498        self.params
1499            .extend(iter.map(|(key, value)| (key.into(), value.as_value())));
1500        self
1501    }
1502
1503    /// Add the parameters to a URL.
1504    pub fn add_to_url(&self, url: &mut Url) {
1505        let mut pairs = url.query_pairs_mut();
1506        pairs.extend_pairs(self.params.iter());
1507    }
1508}
1509
1510/// A trait for providing the necessary information for a single REST API endpoint.
1511pub trait Endpoint {
1512    /// The HTTP method to use for the endpoint.
1513    fn method(&self) -> Method;
1514    /// The path to the endpoint.
1515    fn endpoint(&self) -> Cow<'static, str>;
1516
1517    /// Query parameters for the endpoint.
1518    fn parameters(&self) -> QueryParams<'_> {
1519        QueryParams::default()
1520    }
1521
1522    /// The body for the endpoint.
1523    ///
1524    /// Returns the `Content-Encoding` header for the data as well as the data itself.
1525    ///
1526    /// # Errors
1527    ///
1528    /// The default implementation will never return an error
1529    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1530        Ok(None)
1531    }
1532}
1533
1534/// A trait to indicate that an endpoint is expected to return a JSON result
1535pub trait ReturnsJsonResponse {}
1536
1537/// A trait to indicate that an endpoint requires pagination to yield all results
1538/// or in other words that the non-pagination API should not be used on it or one
1539/// might miss some results
1540#[diagnostic::on_unimplemented(
1541    message = "{Self} is an endpoint that either returns nothing or requires pagination, use `.ignore_response_body(&endpoint)`, `.json_response_body_page(&endpoint, offset, limit)` or `.json_response_body_all_pages(&endpoint)` instead of `.json_response_body(&endpoint)`"
1542)]
1543pub trait NoPagination {}
1544
1545/// A trait to indicate that an endpoint is pageable.
1546#[diagnostic::on_unimplemented(
1547    message = "{Self} is an endpoint that does not implement pagination or returns nothing, use `.ignore_response_body(&endpoint)` or `.json_response_body(&endpoint)` instead of `.json_response_body_page(&endpoint, offset, limit)` or `.json_response_body_all_pages(&endpoint)`"
1548)]
1549pub trait Pageable {
1550    /// returns the name of the key in the response that contains the list of results
1551    fn response_wrapper_key(&self) -> String;
1552}
1553
1554/// helper to parse created_on and updated_on in the correct format
1555/// (default time serde implementation seems to use a different format)
1556///
1557/// # Errors
1558///
1559/// This will return an error if the underlying string can not be deserialized or
1560/// can not be parsed as an RFC3339 date and time
1561pub fn deserialize_rfc3339<'de, D>(deserializer: D) -> Result<time::OffsetDateTime, D::Error>
1562where
1563    D: serde::Deserializer<'de>,
1564{
1565    let s = String::deserialize(deserializer)?;
1566
1567    time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
1568        .map_err(serde::de::Error::custom)
1569}
1570
1571/// helper to serialize created_on and updated_on in the correct format
1572/// (default time serde implementation seems to use a different format)
1573///
1574/// # Errors
1575///
1576/// This will return an error if the date time can not be formatted as an RFC3339
1577/// date time or the resulting string can not be serialized
1578pub fn serialize_rfc3339<S>(t: &time::OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
1579where
1580    S: serde::Serializer,
1581{
1582    let s = t
1583        .format(&time::format_description::well_known::Rfc3339)
1584        .map_err(serde::ser::Error::custom)?;
1585
1586    s.serialize(serializer)
1587}
1588
1589/// helper to parse created_on and updated_on in the correct format
1590/// (default time serde implementation seems to use a different format)
1591///
1592/// # Errors
1593///
1594/// This will return an error if the underlying string can not be deserialized
1595/// or it can not be parsed as an RFC3339 date and time
1596pub fn deserialize_optional_rfc3339<'de, D>(
1597    deserializer: D,
1598) -> Result<Option<time::OffsetDateTime>, D::Error>
1599where
1600    D: serde::Deserializer<'de>,
1601{
1602    let s = <Option<String> as Deserialize<'de>>::deserialize(deserializer)?;
1603
1604    if let Some(s) = s {
1605        Ok(Some(
1606            time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
1607                .map_err(serde::de::Error::custom)?,
1608        ))
1609    } else {
1610        Ok(None)
1611    }
1612}
1613
1614/// helper to serialize created_on and updated_on in the correct format
1615/// (default time serde implementation seems to use a different format)
1616///
1617/// # Errors
1618///
1619/// This will return an error if the parameter can not be formatted as RFC3339
1620/// or the resulting string can not be serialized
1621pub fn serialize_optional_rfc3339<S>(
1622    t: &Option<time::OffsetDateTime>,
1623    serializer: S,
1624) -> Result<S::Ok, S::Error>
1625where
1626    S: serde::Serializer,
1627{
1628    if let Some(t) = t {
1629        let s = t
1630            .format(&time::format_description::well_known::Rfc3339)
1631            .map_err(serde::ser::Error::custom)?;
1632
1633        s.serialize(serializer)
1634    } else {
1635        let n: Option<String> = None;
1636        n.serialize(serializer)
1637    }
1638}
1639
1640/// represents an Iterator over all result pages
1641#[derive(Debug)]
1642pub struct AllPages<'i, E, R> {
1643    /// the redmine object to fetch data from
1644    redmine: &'i Redmine,
1645    /// the endpoint to request data from
1646    endpoint: &'i E,
1647    /// the offset to fetch next
1648    offset: u64,
1649    /// the limit for each fetch
1650    limit: u64,
1651    /// the cached total count value from the last request
1652    total_count: Option<u64>,
1653    /// the number of elements already yielded
1654    yielded: u64,
1655    /// the cached values from the last fetch that have not been
1656    /// consumed yet, in reverse order to allow pop to remove them
1657    reversed_rest: Vec<R>,
1658}
1659
1660impl<'i, E, R> AllPages<'i, E, R> {
1661    /// create a new AllPages Iterator
1662    pub const fn new(redmine: &'i Redmine, endpoint: &'i E) -> Self {
1663        Self {
1664            redmine,
1665            endpoint,
1666            offset: 0,
1667            limit: 100,
1668            total_count: None,
1669            yielded: 0,
1670            reversed_rest: Vec::new(),
1671        }
1672    }
1673}
1674
1675impl<E, R> Iterator for AllPages<'_, E, R>
1676where
1677    E: Endpoint + ReturnsJsonResponse + Pageable,
1678    R: DeserializeOwned + std::fmt::Debug,
1679{
1680    type Item = Result<R, crate::Error>;
1681
1682    #[expect(
1683        clippy::arithmetic_side_effects,
1684        reason = "u64 pagination counters; overflow requires impossibly many results"
1685    )]
1686    fn next(&mut self) -> Option<Self::Item> {
1687        if let Some(next) = self.reversed_rest.pop() {
1688            self.yielded += 1;
1689            return Some(Ok(next));
1690        }
1691        if let Some(total_count) = self.total_count
1692            && self.offset > total_count
1693        {
1694            return None;
1695        }
1696        match self
1697            .redmine
1698            .json_response_body_page(self.endpoint, self.offset, self.limit)
1699        {
1700            Err(e) => Some(Err(e)),
1701            Ok(ResponsePage {
1702                values,
1703                total_count,
1704                offset,
1705                limit,
1706            }) => {
1707                self.total_count = Some(total_count);
1708                self.offset = offset + limit;
1709                self.reversed_rest = values;
1710                self.reversed_rest.reverse();
1711                if let Some(next) = self.reversed_rest.pop() {
1712                    self.yielded += 1;
1713                    return Some(Ok(next));
1714                }
1715                None
1716            }
1717        }
1718    }
1719
1720    #[expect(
1721        clippy::arithmetic_side_effects,
1722        clippy::cast_possible_truncation,
1723        clippy::as_conversions,
1724        reason = "remaining = total_count - yielded; size_hint upper bound is best-effort"
1725    )]
1726    fn size_hint(&self) -> (usize, Option<usize>) {
1727        if let Some(total_count) = self.total_count {
1728            (
1729                self.reversed_rest.len(),
1730                Some((total_count - self.yielded) as usize),
1731            )
1732        } else {
1733            (0, None)
1734        }
1735    }
1736}
1737
1738/// represents an async Stream over all result pages
1739#[pin_project::pin_project]
1740pub struct AllPagesAsync<E, R> {
1741    /// the inner future while we are fetching new data
1742    #[expect(
1743        clippy::type_complexity,
1744        reason = "boxed pinned future signature is intrinsically nested"
1745    )]
1746    #[pin]
1747    inner: Option<
1748        std::pin::Pin<Box<dyn futures::Future<Output = Result<ResponsePage<R>, crate::Error>>>>,
1749    >,
1750    /// the redmine object to fetch data from
1751    redmine: std::sync::Arc<RedmineAsync>,
1752    /// the endpoint to request data from
1753    endpoint: std::sync::Arc<E>,
1754    /// the offset to fetch next
1755    offset: u64,
1756    /// the limit for each fetch
1757    limit: u64,
1758    /// the cached total count value from the last request
1759    total_count: Option<u64>,
1760    /// the number of elements already yielded
1761    yielded: u64,
1762    /// the cached values from the last fetch that have not been
1763    /// consumed yet, in reverse order to allow pop to remove them
1764    reversed_rest: Vec<R>,
1765}
1766
1767impl<E, R> std::fmt::Debug for AllPagesAsync<E, R>
1768where
1769    R: std::fmt::Debug,
1770{
1771    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1772        f.debug_struct("AllPagesAsync")
1773            .field("redmine", &self.redmine)
1774            .field("offset", &self.offset)
1775            .field("limit", &self.limit)
1776            .field("total_count", &self.total_count)
1777            .field("yielded", &self.yielded)
1778            .field("reversed_rest", &self.reversed_rest)
1779            .finish()
1780    }
1781}
1782
1783impl<E, R> AllPagesAsync<E, R> {
1784    /// create a new AllPagesAsync Stream
1785    pub fn new(redmine: std::sync::Arc<RedmineAsync>, endpoint: std::sync::Arc<E>) -> Self {
1786        Self {
1787            inner: None,
1788            redmine,
1789            endpoint,
1790            offset: 0,
1791            limit: 100,
1792            total_count: None,
1793            yielded: 0,
1794            reversed_rest: Vec::new(),
1795        }
1796    }
1797}
1798
1799impl<E, R> futures::stream::Stream for AllPagesAsync<E, R>
1800where
1801    E: Endpoint + ReturnsJsonResponse + Pageable + 'static,
1802    R: DeserializeOwned + std::fmt::Debug + 'static,
1803{
1804    type Item = Result<R, crate::Error>;
1805
1806    #[expect(
1807        clippy::arithmetic_side_effects,
1808        clippy::clone_on_ref_ptr,
1809        clippy::renamed_function_params,
1810        reason = "u64 pagination counters; Arc clones are intentional; ctx parameter name is preserved for clarity"
1811    )]
1812    fn poll_next(
1813        mut self: std::pin::Pin<&mut Self>,
1814        ctx: &mut std::task::Context<'_>,
1815    ) -> std::task::Poll<Option<Self::Item>> {
1816        if let Some(mut inner) = self.inner.take() {
1817            match inner.as_mut().poll(ctx) {
1818                std::task::Poll::Pending => {
1819                    self.inner = Some(inner);
1820                    std::task::Poll::Pending
1821                }
1822                std::task::Poll::Ready(Err(e)) => std::task::Poll::Ready(Some(Err(e))),
1823                std::task::Poll::Ready(Ok(ResponsePage {
1824                    values,
1825                    total_count,
1826                    offset,
1827                    limit,
1828                })) => {
1829                    self.total_count = Some(total_count);
1830                    self.offset = offset + limit;
1831                    self.reversed_rest = values;
1832                    self.reversed_rest.reverse();
1833                    if let Some(next) = self.reversed_rest.pop() {
1834                        self.yielded += 1;
1835                        return std::task::Poll::Ready(Some(Ok(next)));
1836                    }
1837                    std::task::Poll::Ready(None)
1838                }
1839            }
1840        } else {
1841            if let Some(next) = self.reversed_rest.pop() {
1842                self.yielded += 1;
1843                return std::task::Poll::Ready(Some(Ok(next)));
1844            }
1845            if let Some(total_count) = self.total_count
1846                && self.offset > total_count
1847            {
1848                return std::task::Poll::Ready(None);
1849            }
1850            self.inner = Some(
1851                self.redmine
1852                    .clone()
1853                    .json_response_body_page(self.endpoint.clone(), self.offset, self.limit)
1854                    .boxed_local(),
1855            );
1856            self.poll_next(ctx)
1857        }
1858    }
1859
1860    #[expect(
1861        clippy::arithmetic_side_effects,
1862        clippy::cast_possible_truncation,
1863        clippy::as_conversions,
1864        reason = "remaining = total_count - yielded; size_hint upper bound is best-effort"
1865    )]
1866    fn size_hint(&self) -> (usize, Option<usize>) {
1867        if let Some(total_count) = self.total_count {
1868            (
1869                self.reversed_rest.len(),
1870                Some((total_count - self.yielded) as usize),
1871            )
1872        } else {
1873            (0, None)
1874        }
1875    }
1876}
1877
1878/// trait to allow both `&E` and `std::sync::Arc<E>` as parameters for endpoints
1879/// we can not just use Into because that tries to treat &Endpoint as the value E
1880/// and screws up our other trait bounds
1881///
1882/// if we just used Arc the users would have to change all old call sites
1883pub trait EndpointParameter<E> {
1884    /// convert the endpoint parameter into an Arc
1885    fn into_arc(self) -> std::sync::Arc<E>;
1886}
1887
1888impl<E> EndpointParameter<E> for &E
1889where
1890    E: Clone,
1891{
1892    fn into_arc(self) -> std::sync::Arc<E> {
1893        std::sync::Arc::new(self.to_owned())
1894    }
1895}
1896
1897impl<E> EndpointParameter<E> for std::sync::Arc<E> {
1898    fn into_arc(self) -> Self {
1899        self
1900    }
1901}